switchroom 0.19.13 → 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 +4 -2
- 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 +1027 -509
- 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/outbound-send-path.ts +9 -3
- package/telegram-plugin/gateway/outbox-sweep.ts +73 -5
- 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-interrupt-stop.mjs +33 -7
- package/telegram-plugin/hooks/silent-end-scan.mjs +171 -85
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +87 -0
- 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/outbox-reply-then-recap-e2e.test.ts +600 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +19 -11
- 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
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbox-reply-then-recap-e2e.test.ts — end-to-end regression net for #3510
|
|
3
|
+
* (Telegram double-send) AND its opposite failure mode (#3502 silent drop),
|
|
4
|
+
* driven from ONE harness through the real delivery decision path:
|
|
5
|
+
*
|
|
6
|
+
* transcript on disk
|
|
7
|
+
* → gateway reply-site simulation (send + conditional journal, mirroring
|
|
8
|
+
* `outbound-send-path.ts:2374-2376` — replies go out BEFORE Stop fires)
|
|
9
|
+
* → the REAL Stop hook (`hooks/silent-end-interrupt-stop.mjs`), spawned as
|
|
10
|
+
* a subprocess exactly as Claude Code runs it
|
|
11
|
+
* → the REAL `sweepOutbox` against the real on-disk outbox/journal, ticked
|
|
12
|
+
* repeatedly across the OUTBOX_QUIET_MS boundary (age-1 / age / age+1 /
|
|
13
|
+
* past the in-memory dedup TTL) to pressure-test the one-tick race
|
|
14
|
+
* window rather than a single happy-path tick.
|
|
15
|
+
*
|
|
16
|
+
* The oracle is deliberately FUNCTION-AGNOSTIC: assertions are on what the
|
|
17
|
+
* user observably received — how many messages, with what content, via which
|
|
18
|
+
* transport (`reply-tool` = the formatted reply; `sweep` = the plain-text
|
|
19
|
+
* outbox flush). No assertion names an internal decision function; renaming or
|
|
20
|
+
* refactoring the hook internals cannot green a real double-send or a real
|
|
21
|
+
* drop.
|
|
22
|
+
*
|
|
23
|
+
* Duplicate direction (#3510): a turn that delivers a reply and then emits a
|
|
24
|
+
* trailing prose recap (short / long / paraphrased — byte-exact dedup would
|
|
25
|
+
* miss the reworded ones) must yield EXACTLY ONE user-visible message: the
|
|
26
|
+
* formatted reply. On pre-fix main the sweep flushed the recap as a second,
|
|
27
|
+
* unformatted message.
|
|
28
|
+
*
|
|
29
|
+
* Silence direction (#3502 backstop): a gateway-blind turn (task-notification
|
|
30
|
+
* handback, cron, zero-reply channel turn) whose only answer is transcript
|
|
31
|
+
* prose must yield EXACTLY ONE delivery — never zero. This pins that the
|
|
32
|
+
* #3510 fix did not regress the 2026-07-22 data-loss class.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
36
|
+
import { spawnSync } from 'node:child_process'
|
|
37
|
+
import {
|
|
38
|
+
mkdtempSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
readdirSync,
|
|
41
|
+
readFileSync,
|
|
42
|
+
rmSync,
|
|
43
|
+
existsSync,
|
|
44
|
+
} from 'node:fs'
|
|
45
|
+
import { tmpdir } from 'node:os'
|
|
46
|
+
import { join, resolve } from 'node:path'
|
|
47
|
+
import { createHash } from 'node:crypto'
|
|
48
|
+
|
|
49
|
+
import { sweepOutbox, journalExternalDelivery } from '../gateway/outbox-sweep.js'
|
|
50
|
+
import { OUTBOX_QUIET_MS, type OutboxRecord, type DeliveredEntry } from '../outbox.js'
|
|
51
|
+
import { shouldJournalReplySiteDelivery, isFinalAnswerReply } from '../final-answer-detect.js'
|
|
52
|
+
import { decideTurnEndGate } from '../gateway/turn-end-gate.js'
|
|
53
|
+
import { deliverCapturedProse, type DeliverCapturedProseDeps } from '../gateway/outbound-send-path.js'
|
|
54
|
+
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
55
|
+
import type { FlushDecision } from '../turn-flush-safety.js'
|
|
56
|
+
|
|
57
|
+
const HOOK = resolve(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
|
|
58
|
+
const REPLY_TOOL = 'mcp__switchroom-telegram__reply'
|
|
59
|
+
const CHAT = '111'
|
|
60
|
+
const MSG_ID = '42'
|
|
61
|
+
/** The gateway's `deriveTurnId` nonce for the CHAT/MSG_ID envelope. */
|
|
62
|
+
const GATEWAY_NONCE = `${CHAT}:_#${MSG_ID}`
|
|
63
|
+
/** Mirrors the gateway's in-memory `outboundDedup` TTL (~60s). */
|
|
64
|
+
const DEDUP_TTL_MS = 60_000
|
|
65
|
+
|
|
66
|
+
const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex')
|
|
67
|
+
|
|
68
|
+
interface Delivered {
|
|
69
|
+
via: 'reply-tool' | 'sweep' | 'bridge'
|
|
70
|
+
text: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Transcript-line builders. */
|
|
74
|
+
const enqueueChannel = (body: string, source = 'telegram', messageId = MSG_ID) => ({
|
|
75
|
+
type: 'queue-operation',
|
|
76
|
+
operation: 'enqueue',
|
|
77
|
+
content: `<channel source="${source}" chat_id="${CHAT}" message_id="${messageId}">${body}</channel>`,
|
|
78
|
+
timestamp: 1000,
|
|
79
|
+
})
|
|
80
|
+
const enqueueTask = () => ({
|
|
81
|
+
type: 'queue-operation',
|
|
82
|
+
operation: 'enqueue',
|
|
83
|
+
content: '<task-notification><task-id>a7cc7a0fa8f</task-id> worker done</task-notification>',
|
|
84
|
+
timestamp: 2000,
|
|
85
|
+
})
|
|
86
|
+
const prose = (text: string) => ({
|
|
87
|
+
type: 'assistant',
|
|
88
|
+
message: { content: [{ type: 'text', text }] },
|
|
89
|
+
})
|
|
90
|
+
const reply = (text: string, input: Record<string, unknown> = {}) => ({
|
|
91
|
+
type: 'assistant',
|
|
92
|
+
message: {
|
|
93
|
+
content: [{ type: 'tool_use', name: REPLY_TOOL, input: { chat_id: CHAT, text, ...input } }],
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run one full turn through the real delivery path. Returns everything the
|
|
99
|
+
* user received, in order, plus the on-disk journal/outbox for forensics.
|
|
100
|
+
*/
|
|
101
|
+
async function runTurn(opts: {
|
|
102
|
+
dir: string
|
|
103
|
+
lines: object[]
|
|
104
|
+
gatewayAlive?: boolean
|
|
105
|
+
/** register a registry-chain hit for task anchors (default: unresolvable → origin route) */
|
|
106
|
+
registryHit?: boolean
|
|
107
|
+
}): Promise<{
|
|
108
|
+
delivered: Delivered[]
|
|
109
|
+
hookStdout: string
|
|
110
|
+
hookStderr: string
|
|
111
|
+
records: OutboxRecord[]
|
|
112
|
+
journal: DeliveredEntry[]
|
|
113
|
+
}> {
|
|
114
|
+
const { dir, lines, gatewayAlive = true } = opts
|
|
115
|
+
const delivered: Delivered[] = []
|
|
116
|
+
|
|
117
|
+
// 1. Gateway reply-site simulation — replies are sent (and conditionally
|
|
118
|
+
// journaled, same gate as outbound-send-path.ts:2374) BEFORE Stop fires.
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
const content = (line as { message?: { content?: Array<Record<string, unknown>> } }).message?.content
|
|
121
|
+
if (!Array.isArray(content)) continue
|
|
122
|
+
for (const c of content) {
|
|
123
|
+
if (c.type !== 'tool_use' || c.name !== REPLY_TOOL) continue
|
|
124
|
+
const input = c.input as { text?: string; done?: boolean; disable_notification?: boolean }
|
|
125
|
+
const text = String(input.text ?? '')
|
|
126
|
+
if (/^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i.test(text.trim())) continue
|
|
127
|
+
delivered.push({ via: 'reply-tool', text })
|
|
128
|
+
if (
|
|
129
|
+
shouldJournalReplySiteDelivery({
|
|
130
|
+
text,
|
|
131
|
+
disableNotification: input.disable_notification === true,
|
|
132
|
+
done: input.done === true,
|
|
133
|
+
})
|
|
134
|
+
) {
|
|
135
|
+
journalExternalDelivery(
|
|
136
|
+
{ turnNonce: GATEWAY_NONCE, text, tgMessageId: 1, replyAlreadyDeliveredThisTurn: true },
|
|
137
|
+
dir,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. Gateway liveness heartbeat (fresh unless the case simulates a dead gateway).
|
|
144
|
+
if (gatewayAlive) writeFileSync(join(dir, 'gateway-heartbeat'), 'hb', 'utf8')
|
|
145
|
+
|
|
146
|
+
// 3. The REAL Stop hook, as a subprocess.
|
|
147
|
+
const transcriptPath = join(dir, 'transcript.jsonl')
|
|
148
|
+
writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
|
|
149
|
+
const hook = spawnSync('node', [HOOK], {
|
|
150
|
+
input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
|
|
151
|
+
encoding: 'utf8',
|
|
152
|
+
timeout: 5000,
|
|
153
|
+
env: { ...process.env, TELEGRAM_STATE_DIR: dir },
|
|
154
|
+
})
|
|
155
|
+
expect(hook.status).toBe(0)
|
|
156
|
+
|
|
157
|
+
// 3.5 Gateway turn-end — the REAL gate + the REAL captured-prose bridge
|
|
158
|
+
// (#3511 review finding 2). `finalAnswerDelivered` is computed with the
|
|
159
|
+
// REAL `isFinalAnswerReply` classifier at the reply site — the exact code
|
|
160
|
+
// the gateway runs at outbound-send-path.ts ~2293 — and the disposition
|
|
161
|
+
// comes from the REAL `decideTurnEndGate`. When (and only when) the gate
|
|
162
|
+
// says 'reprompt' and the hook persisted `pendingText`, the REAL
|
|
163
|
+
// `deliverCapturedProse` runs against the same state dir/journal. This
|
|
164
|
+
// makes the load-bearing `isFinalAnswerReply` ⇔ `finalAnswerDelivered`
|
|
165
|
+
// agreement an asserted outcome: if the hook defers a shape the gateway
|
|
166
|
+
// does not consider delivered (or vice versa), the bridge fires and the
|
|
167
|
+
// exactly-one-message assertions go red.
|
|
168
|
+
// Only simulated for reply-called turns — a gateway-blind turn (no
|
|
169
|
+
// CurrentTurn) has no turn_end at all, which is the whole point of the
|
|
170
|
+
// outbox path.
|
|
171
|
+
const replyInputs: Array<{ text: string; done: boolean; disableNotification: boolean }> = []
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const content = (line as { message?: { content?: Array<Record<string, unknown>> } }).message?.content
|
|
174
|
+
if (!Array.isArray(content)) continue
|
|
175
|
+
for (const c of content) {
|
|
176
|
+
if (c.type !== 'tool_use' || c.name !== REPLY_TOOL) continue
|
|
177
|
+
const input = c.input as { text?: string; done?: boolean; disable_notification?: boolean }
|
|
178
|
+
replyInputs.push({
|
|
179
|
+
text: String(input.text ?? ''),
|
|
180
|
+
done: input.done === true,
|
|
181
|
+
disableNotification: input.disable_notification === true,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const isSentinel = (t: string) => /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i.test(t.trim())
|
|
186
|
+
const nonSentinelReplies = replyInputs.filter((r) => !isSentinel(r.text))
|
|
187
|
+
if (replyInputs.length > 0) {
|
|
188
|
+
// Same classifier + same site semantics as outbound-send-path.ts ~2293.
|
|
189
|
+
const finalAnswerDelivered = nonSentinelReplies.some((r) =>
|
|
190
|
+
isFinalAnswerReply({ text: r.text, disableNotification: r.disableNotification, done: r.done }),
|
|
191
|
+
)
|
|
192
|
+
// Sentinel-only turns take the gateway's sentinel-suppression path and
|
|
193
|
+
// return before the bridge (stream-render.ts ~1977 comment; verified live
|
|
194
|
+
// in the #3511 review). Reply-called turns take the 'reply-called' skip.
|
|
195
|
+
const flushDecision: FlushDecision =
|
|
196
|
+
nonSentinelReplies.length === 0
|
|
197
|
+
? { kind: 'skip', reason: 'silent-marker' }
|
|
198
|
+
: { kind: 'skip', reason: 'reply-called' }
|
|
199
|
+
const gate = decideTurnEndGate({ flushDecision, finalAnswerDelivered })
|
|
200
|
+
if (gate === 'reprompt') {
|
|
201
|
+
const statePath = join(dir, 'silent-end-pending.json')
|
|
202
|
+
const pendingText = existsSync(statePath)
|
|
203
|
+
? (JSON.parse(readFileSync(statePath, 'utf8')) as { pendingText?: string }).pendingText
|
|
204
|
+
: undefined
|
|
205
|
+
if (typeof pendingText === 'string' && pendingText.length > 0) {
|
|
206
|
+
// The gateway's silent-end machinery hands pendingText to the REAL bridge.
|
|
207
|
+
const dedup = new OutboundDedupCache()
|
|
208
|
+
for (const r of nonSentinelReplies) dedup.record(CHAT, undefined, r.text, Date.now(), null)
|
|
209
|
+
const bridgeDeps: DeliverCapturedProseDeps = {
|
|
210
|
+
outboundDedup: dedup,
|
|
211
|
+
bot: {
|
|
212
|
+
api: {
|
|
213
|
+
sendRichMessage: async (_chatId: string, rich: { markdown?: string }, _opts: object) => {
|
|
214
|
+
delivered.push({ via: 'bridge', text: rich.markdown ?? '' })
|
|
215
|
+
return { message_id: 2000 }
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
} as unknown as DeliverCapturedProseDeps['bot'],
|
|
219
|
+
robustApiCall: (fn) => fn(),
|
|
220
|
+
redactOutboundText: (text) => text,
|
|
221
|
+
recordOutbound: () => {},
|
|
222
|
+
HISTORY_ENABLED: false,
|
|
223
|
+
OBLIGATION_LEDGER_ENABLED: false,
|
|
224
|
+
obligationLedger: { close: () => {} },
|
|
225
|
+
clearSilentEndState: () => {},
|
|
226
|
+
recordUndeliveredTurnEnd: () => ({ exhausted: false }),
|
|
227
|
+
hasOutboundDeliveredSince: () => false,
|
|
228
|
+
}
|
|
229
|
+
// deliverCapturedProse journals via TELEGRAM_STATE_DIR — point it at
|
|
230
|
+
// this test's isolated state dir for the duration of the call.
|
|
231
|
+
const prevStateDir = process.env.TELEGRAM_STATE_DIR
|
|
232
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
233
|
+
try {
|
|
234
|
+
await deliverCapturedProse(bridgeDeps, {
|
|
235
|
+
chatId: CHAT,
|
|
236
|
+
threadId: undefined,
|
|
237
|
+
statusKeyStr: `${CHAT}:main`,
|
|
238
|
+
registryKey: null,
|
|
239
|
+
originTurnId: GATEWAY_NONCE,
|
|
240
|
+
text: pendingText,
|
|
241
|
+
})
|
|
242
|
+
} finally {
|
|
243
|
+
if (prevStateDir == null) delete process.env.TELEGRAM_STATE_DIR
|
|
244
|
+
else process.env.TELEGRAM_STATE_DIR = prevStateDir
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 4. The REAL sweep, ticked aggressively across the quiet-window boundary.
|
|
251
|
+
// Tick times are anchored on the record's actual createdAt when one
|
|
252
|
+
// exists, so the boundary (age === quietMs - 1 / quietMs / quietMs + 1)
|
|
253
|
+
// is exercised exactly — an ordering regression is caught, not masked.
|
|
254
|
+
const recordsBefore = readOutboxRecords(dir)
|
|
255
|
+
const t0 = recordsBefore.length > 0 ? recordsBefore[0].createdAt : Date.now()
|
|
256
|
+
const tickTimes = [
|
|
257
|
+
t0, // age 0 — inside quiet window
|
|
258
|
+
t0 + OUTBOX_QUIET_MS - 1, // one ms before eligibility
|
|
259
|
+
t0 + OUTBOX_QUIET_MS, // boundary
|
|
260
|
+
t0 + OUTBOX_QUIET_MS + 1, // just past
|
|
261
|
+
t0 + DEDUP_TTL_MS + 1_000, // past the in-memory dedup TTL — journal must hold alone
|
|
262
|
+
]
|
|
263
|
+
const sentLog: Array<{ text: string; at: number }> = []
|
|
264
|
+
for (const now of tickTimes) {
|
|
265
|
+
await sweepOutbox({
|
|
266
|
+
stateDir: dir,
|
|
267
|
+
now: () => now,
|
|
268
|
+
send: async (_chatId, _threadId, text) => {
|
|
269
|
+
delivered.push({ via: 'sweep', text })
|
|
270
|
+
sentLog.push({ text, at: now })
|
|
271
|
+
return 500
|
|
272
|
+
},
|
|
273
|
+
// Mirrors the gateway's TTL-bounded exact-text `outboundDedup` cache:
|
|
274
|
+
// anything sent (reply or sweep) within the TTL dedups; older evicts.
|
|
275
|
+
textAlreadyDelivered: (_chatId, _threadId, text) =>
|
|
276
|
+
delivered.some(
|
|
277
|
+
(d) =>
|
|
278
|
+
d.text === text &&
|
|
279
|
+
(d.via === 'reply-tool' || sentLog.some((s) => s.text === text && now - s.at < DEDUP_TTL_MS)),
|
|
280
|
+
),
|
|
281
|
+
registryChainLookup: opts.registryHit ? () => ({ chatId: CHAT, threadId: null }) : () => null,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
delivered,
|
|
287
|
+
hookStdout: hook.stdout ?? '',
|
|
288
|
+
hookStderr: hook.stderr ?? '',
|
|
289
|
+
records: readOutboxRecords(dir),
|
|
290
|
+
journal: readJournal(dir),
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function readOutboxRecords(dir: string): OutboxRecord[] {
|
|
295
|
+
const outbox = join(dir, 'outbox')
|
|
296
|
+
try {
|
|
297
|
+
return readdirSync(outbox)
|
|
298
|
+
.filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl' && !f.startsWith('.'))
|
|
299
|
+
.map((f) => JSON.parse(readFileSync(join(outbox, f), 'utf8')) as OutboxRecord)
|
|
300
|
+
} catch {
|
|
301
|
+
return []
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readJournal(dir: string): DeliveredEntry[] {
|
|
306
|
+
const p = join(dir, 'outbox', 'delivered.jsonl')
|
|
307
|
+
if (!existsSync(p)) return []
|
|
308
|
+
return readFileSync(p, 'utf8')
|
|
309
|
+
.split('\n')
|
|
310
|
+
.filter((l) => l.length > 0)
|
|
311
|
+
.map((l) => JSON.parse(l) as DeliveredEntry)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Reply/recap fixtures. The short reply pings (no disable_notification, no
|
|
315
|
+
// done) → user-visible final answer, but under the 200-char reply-site journal
|
|
316
|
+
// floor — the exact #3510 window (R1 High #1 + #2).
|
|
317
|
+
const SHORT_REPLY = 'Deployed the fix to staging — all 3 checks green. ' + 'Detail: '.padEnd(100, 'x')
|
|
318
|
+
const LONG_REPLY = 'Full answer with plenty of substance. '.padEnd(250, 'y')
|
|
319
|
+
const RECAP_LONG = 'To recap what I just sent: the fix is deployed to staging and all checks are green. '.padEnd(300, 'z')
|
|
320
|
+
const RECAP_PARAPHRASE =
|
|
321
|
+
'Summary of the work above, in different words than the reply so byte-exact dedup can never match it. '.padEnd(400, 'w')
|
|
322
|
+
const RECAP_SHORT = 'Short recap under the floor.'
|
|
323
|
+
const REAL_ANSWER = 'The actual final answer that never went through a reply tool call. '.padEnd(300, 'a')
|
|
324
|
+
const HANDBACK_ANSWER = 'B'.repeat(1647)
|
|
325
|
+
|
|
326
|
+
describe('#3510 e2e — reply-then-recap turns yield EXACTLY ONE user-visible message', () => {
|
|
327
|
+
let dir: string
|
|
328
|
+
beforeEach(() => {
|
|
329
|
+
dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
|
|
330
|
+
})
|
|
331
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
332
|
+
|
|
333
|
+
// Table: every duplicate-direction shape must end with exactly the formatted
|
|
334
|
+
// reply — never a second sweep flush, whatever the recap's wording/length.
|
|
335
|
+
const duplicateCases: Array<{ name: string; replyText: string; replyInput?: Record<string, unknown>; recap: string }> = [
|
|
336
|
+
{ name: 'short ping-final reply + long recap', replyText: SHORT_REPLY, recap: RECAP_LONG },
|
|
337
|
+
{ name: 'short ping-final reply + paraphrased recap (defeats byte-exact dedup)', replyText: SHORT_REPLY, recap: RECAP_PARAPHRASE },
|
|
338
|
+
{ name: 'short ping-final reply + short recap (below capture floor)', replyText: SHORT_REPLY, recap: RECAP_SHORT },
|
|
339
|
+
{ name: 'substantive journaled reply + reworded recap', replyText: LONG_REPLY, recap: RECAP_PARAPHRASE },
|
|
340
|
+
{ name: 'done:true reply + long recap', replyText: SHORT_REPLY, replyInput: { done: true }, recap: RECAP_LONG },
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
for (const c of duplicateCases) {
|
|
344
|
+
it(c.name, async () => {
|
|
345
|
+
const r = await runTurn({
|
|
346
|
+
dir,
|
|
347
|
+
lines: [enqueueChannel('deploy it'), reply(c.replyText, c.replyInput), prose(c.recap)],
|
|
348
|
+
})
|
|
349
|
+
// THE user outcome: one message, it is the reply (formatted transport),
|
|
350
|
+
// and no sweep flush ever fires for this turn.
|
|
351
|
+
expect(r.delivered).toEqual([{ via: 'reply-tool', text: c.replyText }])
|
|
352
|
+
// Durable state agrees: nothing pending for the sweep to resurrect later.
|
|
353
|
+
expect(r.records).toHaveLength(0)
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
it('dead gateway: still never a second message (worst case is a re-prompt, not a duplicate)', async () => {
|
|
358
|
+
const r = await runTurn({
|
|
359
|
+
dir,
|
|
360
|
+
lines: [enqueueChannel('deploy it'), reply(SHORT_REPLY), prose(RECAP_LONG)],
|
|
361
|
+
gatewayAlive: false,
|
|
362
|
+
})
|
|
363
|
+
expect(r.delivered).toEqual([{ via: 'reply-tool', text: SHORT_REPLY }])
|
|
364
|
+
expect(r.records).toHaveLength(0)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('logs both SHAs when deferring, so a double-send is provable from logs alone', async () => {
|
|
368
|
+
const r = await runTurn({
|
|
369
|
+
dir,
|
|
370
|
+
lines: [enqueueChannel('deploy it'), reply(SHORT_REPLY), prose(RECAP_LONG)],
|
|
371
|
+
})
|
|
372
|
+
expect(r.hookStderr).toContain('replyAlreadyDeliveredThisTurn=true')
|
|
373
|
+
expect(r.hookStderr).toContain(`capturedTextSha256=${sha256(RECAP_LONG.trim())}`)
|
|
374
|
+
expect(r.hookStderr).toContain(`deliveredReplySha256=${sha256(SHORT_REPLY)}`)
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
describe('#3502 backstop e2e — gateway-blind turns yield EXACTLY ONE delivery, never zero', () => {
|
|
379
|
+
let dir: string
|
|
380
|
+
beforeEach(() => {
|
|
381
|
+
dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
|
|
382
|
+
})
|
|
383
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
384
|
+
|
|
385
|
+
it('task-notification handback with prose-only answer (2026-07-22 incident shape) is delivered once', async () => {
|
|
386
|
+
const r = await runTurn({
|
|
387
|
+
dir,
|
|
388
|
+
// A prior real channel inbound gives the session its origin chat (F2).
|
|
389
|
+
lines: [enqueueChannel('earlier question', 'telegram', '7'), enqueueTask(), prose(HANDBACK_ANSWER)],
|
|
390
|
+
})
|
|
391
|
+
const sweeps = r.delivered.filter((d) => d.via === 'sweep')
|
|
392
|
+
expect(sweeps).toHaveLength(1)
|
|
393
|
+
expect(sweeps[0].text).toContain(HANDBACK_ANSWER)
|
|
394
|
+
expect(r.delivered).toHaveLength(1) // and nothing else — exactly one delivery
|
|
395
|
+
expect(r.records).toHaveLength(0) // record consumed, not resurrectable
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('cron turn ending in prose is delivered once', async () => {
|
|
399
|
+
const r = await runTurn({
|
|
400
|
+
dir,
|
|
401
|
+
lines: [enqueueChannel('digest time', 'cron', '9'), prose(REAL_ANSWER)],
|
|
402
|
+
})
|
|
403
|
+
expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('zero-reply channel turn ending in prose is delivered once', async () => {
|
|
407
|
+
const r = await runTurn({ dir, lines: [enqueueChannel('question'), prose(REAL_ANSWER)] })
|
|
408
|
+
expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('interim ack then a real prose answer: the answer is delivered exactly once (not suppressed by the ack)', async () => {
|
|
412
|
+
const ack = 'On it — digging in now.'
|
|
413
|
+
const r = await runTurn({
|
|
414
|
+
dir,
|
|
415
|
+
lines: [
|
|
416
|
+
enqueueChannel('question'),
|
|
417
|
+
reply(ack, { disable_notification: true }), // interim ack: silent, short, not done
|
|
418
|
+
prose(REAL_ANSWER),
|
|
419
|
+
],
|
|
420
|
+
})
|
|
421
|
+
// User sees the ack (sent live) and the recovered answer — once each.
|
|
422
|
+
expect(r.delivered).toEqual([
|
|
423
|
+
{ via: 'reply-tool', text: ack },
|
|
424
|
+
{ via: 'sweep', text: REAL_ANSWER.trim() },
|
|
425
|
+
])
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('pure NO_REPLY turn delivers nothing', async () => {
|
|
429
|
+
const r = await runTurn({ dir, lines: [enqueueChannel('ping'), prose('NO_REPLY')] })
|
|
430
|
+
expect(r.delivered).toHaveLength(0)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('reply-tool NO_REPLY then a substantive prose answer: the answer is delivered exactly once (#3511 finding 1)', async () => {
|
|
434
|
+
// The silence regression the #3511 review caught: a `reply("NO_REPLY")`
|
|
435
|
+
// delivered NOTHING to the user, and the gateway suppresses sentinel turns
|
|
436
|
+
// BEFORE the bridge can run — so if the hook treats the sentinel as
|
|
437
|
+
// "reply already delivered" and defers to the election, the trailing
|
|
438
|
+
// answer is orphaned and DROPPED. The discriminator must count only
|
|
439
|
+
// genuine final-reply deliveries, keeping this shape on the durable
|
|
440
|
+
// outbox/sweep path.
|
|
441
|
+
const r = await runTurn({
|
|
442
|
+
dir,
|
|
443
|
+
lines: [enqueueChannel('question'), reply('NO_REPLY'), prose(REAL_ANSWER)],
|
|
444
|
+
})
|
|
445
|
+
expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
|
|
446
|
+
// Durable path was used: the record existed and was consumed, not elected.
|
|
447
|
+
expect(r.journal.filter((e) => e.deliverySource === 'sweep')).toHaveLength(1)
|
|
448
|
+
expect(r.journal[r.journal.length - 1].replyAlreadyDeliveredThisTurn).toBe(false)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('reply-tool NO_REPLY then prose survives a dead gateway (outbox is gateway-liveness-independent)', async () => {
|
|
452
|
+
// The old capture path's durability property must hold for this shape:
|
|
453
|
+
// no heartbeat, no election — the record is still written and swept.
|
|
454
|
+
const r = await runTurn({
|
|
455
|
+
dir,
|
|
456
|
+
lines: [enqueueChannel('question'), reply('NO_REPLY'), prose(REAL_ANSWER)],
|
|
457
|
+
gatewayAlive: false,
|
|
458
|
+
})
|
|
459
|
+
expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
describe('#3511 finding 2 — the gateway BRIDGE path agrees with the hook (paraphrase safe end to end)', () => {
|
|
464
|
+
let dir: string
|
|
465
|
+
beforeEach(() => {
|
|
466
|
+
dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
|
|
467
|
+
})
|
|
468
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
469
|
+
|
|
470
|
+
it('genuine reply + paraphrased recap: pendingText IS persisted for the bridge, yet the bridge never fires — exactly one message', async () => {
|
|
471
|
+
// The load-bearing invariant: the hook's fall-through classifier
|
|
472
|
+
// (isFinalAnswerReply on the deliver block) and the gateway's bridge gate
|
|
473
|
+
// (turn.finalAnswerDelivered, set by the SAME classifier) must agree. The
|
|
474
|
+
// harness computes finalAnswerDelivered with the real classifier and runs
|
|
475
|
+
// the real decideTurnEndGate + real deliverCapturedProse when it says
|
|
476
|
+
// 'reprompt' — so if either side of the agreement breaks, the paraphrased
|
|
477
|
+
// recap goes out via the bridge and this goes red with 2 messages.
|
|
478
|
+
const r = await runTurn({
|
|
479
|
+
dir,
|
|
480
|
+
lines: [enqueueChannel('deploy it'), reply(SHORT_REPLY), prose(RECAP_PARAPHRASE)],
|
|
481
|
+
})
|
|
482
|
+
// The election really did elect the bridge as the would-be single writer…
|
|
483
|
+
const state = JSON.parse(readFileSync(join(dir, 'silent-end-pending.json'), 'utf8'))
|
|
484
|
+
expect(state.pendingText).toBe(RECAP_PARAPHRASE.trim())
|
|
485
|
+
// …but the gateway's gate (finalAnswerDelivered=true, same classifier)
|
|
486
|
+
// keeps the bridge off: the user got exactly the formatted reply.
|
|
487
|
+
expect(r.delivered).toEqual([{ via: 'reply-tool', text: SHORT_REPLY }])
|
|
488
|
+
expect(r.delivered.filter((d) => d.via === 'bridge')).toHaveLength(0)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('interim ack + real answer: the bridge-eligible turn still yields the answer exactly once', async () => {
|
|
492
|
+
// Counter-case proving the bridge stage in the harness is live, not inert:
|
|
493
|
+
// an ack-only turn has finalAnswerDelivered=false (real classifier), the
|
|
494
|
+
// gate says reprompt — but the hook put the answer on the OUTBOX path
|
|
495
|
+
// (no pendingText persisted), so the sweep delivers and the bridge stays
|
|
496
|
+
// quiet. One ack + one answer, no third copy from any machine.
|
|
497
|
+
const ack = 'On it.'
|
|
498
|
+
const r = await runTurn({
|
|
499
|
+
dir,
|
|
500
|
+
lines: [enqueueChannel('question'), reply(ack, { disable_notification: true }), prose(REAL_ANSWER)],
|
|
501
|
+
})
|
|
502
|
+
const answers = r.delivered.filter((d) => d.text.includes(REAL_ANSWER.trim()))
|
|
503
|
+
expect(answers).toHaveLength(1)
|
|
504
|
+
expect(answers[0].via).toBe('sweep')
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
describe('#3510 instrumentation — the journal alone proves who delivered what', () => {
|
|
509
|
+
let dir: string
|
|
510
|
+
beforeEach(() => {
|
|
511
|
+
dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
|
|
512
|
+
})
|
|
513
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
514
|
+
|
|
515
|
+
it('sweep deliveries journal deliverySource=sweep with the record capture-time flag', async () => {
|
|
516
|
+
const r = await runTurn({
|
|
517
|
+
dir,
|
|
518
|
+
lines: [enqueueChannel('question'), prose(REAL_ANSWER)],
|
|
519
|
+
})
|
|
520
|
+
const sweepEntries = r.journal.filter((e) => e.deliverySource === 'sweep')
|
|
521
|
+
expect(sweepEntries).toHaveLength(1)
|
|
522
|
+
expect(sweepEntries[0].replyAlreadyDeliveredThisTurn).toBe(false)
|
|
523
|
+
expect(sweepEntries[0].textSha256).toBe(sha256(REAL_ANSWER.trim()))
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('reply-site deliveries journal deliverySource=reply-tool with replyAlreadyDeliveredThisTurn=true', async () => {
|
|
527
|
+
const r = await runTurn({
|
|
528
|
+
dir,
|
|
529
|
+
lines: [enqueueChannel('question'), reply(LONG_REPLY), prose(RECAP_PARAPHRASE)],
|
|
530
|
+
})
|
|
531
|
+
const replyEntries = r.journal.filter((e) => e.deliverySource === 'reply-tool')
|
|
532
|
+
expect(replyEntries).toHaveLength(1)
|
|
533
|
+
expect(replyEntries[0].replyAlreadyDeliveredThisTurn).toBe(true)
|
|
534
|
+
expect(replyEntries[0].textSha256).toBe(sha256(LONG_REPLY))
|
|
535
|
+
// And no sweep entry exists for the turn — one writer, one journal line.
|
|
536
|
+
expect(r.journal.filter((e) => e.deliverySource === 'sweep')).toHaveLength(0)
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('a bridge delivery journals replyAlreadyDeliveredThisTurn=false (#3511 finding 3)', async () => {
|
|
540
|
+
// The bridge only runs when no genuine final answer was delivered
|
|
541
|
+
// (decideTurnEndGate 'reprompt' requires finalAnswerDelivered === false),
|
|
542
|
+
// so its journal line must stamp the flag false — making a bridge
|
|
543
|
+
// double-send after a reply provable from the journal alone.
|
|
544
|
+
const dedup = new OutboundDedupCache()
|
|
545
|
+
const deps: DeliverCapturedProseDeps = {
|
|
546
|
+
outboundDedup: dedup,
|
|
547
|
+
bot: {
|
|
548
|
+
api: { sendRichMessage: async () => ({ message_id: 3000 }) },
|
|
549
|
+
} as unknown as DeliverCapturedProseDeps['bot'],
|
|
550
|
+
robustApiCall: (fn) => fn(),
|
|
551
|
+
redactOutboundText: (text) => text,
|
|
552
|
+
recordOutbound: () => {},
|
|
553
|
+
HISTORY_ENABLED: false,
|
|
554
|
+
OBLIGATION_LEDGER_ENABLED: false,
|
|
555
|
+
obligationLedger: { close: () => {} },
|
|
556
|
+
clearSilentEndState: () => {},
|
|
557
|
+
recordUndeliveredTurnEnd: () => ({ exhausted: false }),
|
|
558
|
+
hasOutboundDeliveredSince: () => false,
|
|
559
|
+
}
|
|
560
|
+
const prevStateDir = process.env.TELEGRAM_STATE_DIR
|
|
561
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
562
|
+
try {
|
|
563
|
+
await deliverCapturedProse(deps, {
|
|
564
|
+
chatId: CHAT,
|
|
565
|
+
threadId: undefined,
|
|
566
|
+
statusKeyStr: `${CHAT}:main`,
|
|
567
|
+
registryKey: null,
|
|
568
|
+
originTurnId: GATEWAY_NONCE,
|
|
569
|
+
text: REAL_ANSWER,
|
|
570
|
+
})
|
|
571
|
+
} finally {
|
|
572
|
+
if (prevStateDir == null) delete process.env.TELEGRAM_STATE_DIR
|
|
573
|
+
else process.env.TELEGRAM_STATE_DIR = prevStateDir
|
|
574
|
+
}
|
|
575
|
+
const journal = readJournal(dir)
|
|
576
|
+
expect(journal).toHaveLength(1)
|
|
577
|
+
expect(journal[0].deliverySource).toBe('reply-tool')
|
|
578
|
+
expect(journal[0].replyAlreadyDeliveredThisTurn).toBe(false)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('a captured (gateway-blind) record carries replyAlreadyDeliveredThisTurn=false on disk', async () => {
|
|
582
|
+
// Run only the hook (no sweep) so the record is observable before delivery.
|
|
583
|
+
const transcriptPath = join(dir, 'transcript.jsonl')
|
|
584
|
+
writeFileSync(
|
|
585
|
+
transcriptPath,
|
|
586
|
+
[enqueueTask(), prose(HANDBACK_ANSWER)].map((l) => JSON.stringify(l)).join('\n'),
|
|
587
|
+
'utf8',
|
|
588
|
+
)
|
|
589
|
+
const hook = spawnSync('node', [HOOK], {
|
|
590
|
+
input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
|
|
591
|
+
encoding: 'utf8',
|
|
592
|
+
timeout: 5000,
|
|
593
|
+
env: { ...process.env, TELEGRAM_STATE_DIR: dir },
|
|
594
|
+
})
|
|
595
|
+
expect(hook.status).toBe(0)
|
|
596
|
+
const records = readOutboxRecords(dir)
|
|
597
|
+
expect(records).toHaveLength(1)
|
|
598
|
+
expect(records[0].replyAlreadyDeliveredThisTurn).toBe(false)
|
|
599
|
+
})
|
|
600
|
+
})
|
|
@@ -201,12 +201,18 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
|
|
|
201
201
|
expect(state.retryCount).toBe(SILENT_END_MAX_RETRIES)
|
|
202
202
|
})
|
|
203
203
|
|
|
204
|
-
it('
|
|
205
|
-
//
|
|
206
|
-
// (
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
204
|
+
it('routes the trailing verdict through the single-writer election (NOT the outbox) when an early qualifying reply already delivered (#3510)', () => {
|
|
205
|
+
// Pre-#3510 this shape — (1) inbound, (2) an early notification-bearing
|
|
206
|
+
// (ping-final) reply, (3) a large trailing verdict as plain text — was
|
|
207
|
+
// CAPTURED to the outbox and self-exited, bypassing the #3469 election.
|
|
208
|
+
// Because the ping-final reply is delivered by the gateway but sits under
|
|
209
|
+
// the reply-site journal floor, the sweep then flushed the trailing prose
|
|
210
|
+
// as a SECOND message (the #3510 double-send). Now: a reply already
|
|
211
|
+
// delivered this turn ⇒ NO outbox record; the trailing prose goes through
|
|
212
|
+
// `decideStopHookDisposition`'s 'trailing-text-after-reply' branch, whose
|
|
213
|
+
// captured-prose bridge is the single writer (fresh heartbeat ⇒ elected
|
|
214
|
+
// allow, pendingText persisted as the bridge's input).
|
|
215
|
+
writeFileSync(join(stateDir, 'gateway-heartbeat'), 'hb', 'utf8')
|
|
210
216
|
const transcript = writeTranscript(tmp, [
|
|
211
217
|
ENQUEUE,
|
|
212
218
|
reply('running now'),
|
|
@@ -222,11 +228,13 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
|
|
|
222
228
|
stateDir,
|
|
223
229
|
})
|
|
224
230
|
expect(r.status).toBe(0)
|
|
225
|
-
expect(r.stdout.trim()).toBe('')
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
expect(
|
|
229
|
-
|
|
231
|
+
expect(r.stdout.trim()).toBe('') // elected allow — no re-prompt
|
|
232
|
+
expect(r.stderr).toMatch(/replyAlreadyDeliveredThisTurn=true/)
|
|
233
|
+
// No outbox record: the sweep must have nothing to flush as a second message.
|
|
234
|
+
expect(readOutboxRecord(stateDir)).toBeNull()
|
|
235
|
+
// The verdict is handed to the bridge (single writer) via the state file.
|
|
236
|
+
const state = JSON.parse(readFileSync(join(stateDir, 'silent-end-pending.json'), 'utf8'))
|
|
237
|
+
expect(state.pendingText).toBe('Here is the actual verdict: ' + 'X'.repeat(300))
|
|
230
238
|
})
|
|
231
239
|
|
|
232
240
|
it('does NOT false-positive on a normal single-reply turn ending on the reply tool_use', () => {
|