switchroom 0.19.39 → 0.19.41
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 +10 -1
- package/dist/auth-broker/index.js +12 -3
- package/dist/cli/notion-write-pretool.mjs +10 -1
- package/dist/cli/switchroom.js +562 -196
- package/dist/host-control/main.js +287 -20
- package/dist/vault/approvals/kernel-server.js +12 -3
- package/dist/vault/broker/server.js +12 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +10 -0
- package/telegram-plugin/bridge/bridge.ts +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +10 -2
- package/telegram-plugin/dist/gateway/gateway.js +549 -232
- package/telegram-plugin/dist/server.js +10 -2
- package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
- package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
- package/telegram-plugin/gateway/compaction-marker.ts +84 -0
- package/telegram-plugin/gateway/gateway.ts +72 -72
- package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
- package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +39 -1
- package/telegram-plugin/gateway/turn-record-status.ts +80 -0
- package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
- package/telegram-plugin/hooks/hooks.json +11 -0
- package/telegram-plugin/session-tail.ts +20 -0
- package/telegram-plugin/silence-poke.ts +28 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
- package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
- package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
- package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
- package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
- package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbox-flush-ack-claim-race.test.ts — outcome regression for the turn-flush
|
|
3
|
+
* vs outbox-sweep DUPLICATE-MESSAGE race.
|
|
4
|
+
*
|
|
5
|
+
* The race (root-caused via journal forensics):
|
|
6
|
+
* 1. A gateway-visible turn ends with unsent trailing prose → the turn-flush
|
|
7
|
+
* backstop delivers it through `runBackstopDelivery` (the flush core). The
|
|
8
|
+
* chunks land (Telegram acks) immediately.
|
|
9
|
+
* 2. The flush's DURABLE exactly-once claim (`journalExternalDelivery`,
|
|
10
|
+
* `deliverySource:'flush'`) used to run only in the caller's
|
|
11
|
+
* post-`await deliverAnswer` bookkeeping — i.e. AFTER `runBackstopDelivery`
|
|
12
|
+
* returned. But `runBackstopDelivery` does not return until its read-back
|
|
13
|
+
* probe resolves, and that probe is issued at COSMETIC priority: when the
|
|
14
|
+
* edit-flood-fuse is deferring cosmetic edits it blocks ~30s.
|
|
15
|
+
* 3. The out-of-band outbox sweep waits only `OUTBOX_QUIET_MS` (5s) before
|
|
16
|
+
* checking the delivered-keys journal. In the 5–30s gap it saw no journal
|
|
17
|
+
* entry (deferred behind the probe) and — because the Stop-hook-captured
|
|
18
|
+
* record's text differs in length/hash from the flush text, so the in-memory
|
|
19
|
+
* text dedup also misses — it sent a SECOND copy of the same answer.
|
|
20
|
+
*
|
|
21
|
+
* The fix claims the nonce at SEND-ACK time (`onAckClaim`), BEFORE the read-back
|
|
22
|
+
* probe. This test drives the REAL `runBackstopDelivery` with a read-back probe
|
|
23
|
+
* held open PAST the sweep's quiet window, plus a pending outbox record for the
|
|
24
|
+
* SAME nonce with DIFFERENT text, and asserts the REAL `sweepOutbox` delivers
|
|
25
|
+
* NOTHING. It is RED on pre-fix main (no ack-time claim → the journal is empty
|
|
26
|
+
* when the sweep ticks → the sweep sends the duplicate).
|
|
27
|
+
*
|
|
28
|
+
* The oracle is what the user observably received (sweep `send` invocations) and
|
|
29
|
+
* the durable journal — no assertion names an internal decision function.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
33
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
34
|
+
import { tmpdir } from 'node:os'
|
|
35
|
+
import { join } from 'node:path'
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
BackstopDeliveryLedger,
|
|
39
|
+
runBackstopDelivery,
|
|
40
|
+
type ReadBackResult,
|
|
41
|
+
} from '../gateway/backstop-delivery.js'
|
|
42
|
+
import { sweepOutbox, journalExternalDelivery } from '../gateway/outbox-sweep.js'
|
|
43
|
+
import {
|
|
44
|
+
OUTBOX_QUIET_MS,
|
|
45
|
+
readDeliveredNonces,
|
|
46
|
+
sha256Hex,
|
|
47
|
+
writeOutboxRecordAtomic,
|
|
48
|
+
type OutboxRecord,
|
|
49
|
+
} from '../outbox.js'
|
|
50
|
+
|
|
51
|
+
const CHAT = '111'
|
|
52
|
+
/** Gateway `deriveTurnId` shape — byte-identical to the Stop-hook record nonce. */
|
|
53
|
+
const NONCE = `${CHAT}:_#42`
|
|
54
|
+
/** The flush's answer (RICH). */
|
|
55
|
+
const FLUSH_TEXT = 'The deploy is green across all three checks. '.padEnd(320, 'a')
|
|
56
|
+
/** The Stop-hook-captured record for the SAME turn — a paraphrase, so its length
|
|
57
|
+
* and sha differ from the flush text and the in-memory text dedup CANNOT match. */
|
|
58
|
+
const RECORD_TEXT =
|
|
59
|
+
'Summary of the above in different words so byte-exact dedup can never catch it. '.padEnd(
|
|
60
|
+
420,
|
|
61
|
+
'b',
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
/** A pending outbox record, aged PAST the sweep quiet window so it is eligible. */
|
|
65
|
+
function writeAgedRecord(dir: string, createdAt: number): void {
|
|
66
|
+
const record: OutboxRecord = {
|
|
67
|
+
turnNonce: NONCE,
|
|
68
|
+
chatId: CHAT,
|
|
69
|
+
threadId: null,
|
|
70
|
+
text: RECORD_TEXT,
|
|
71
|
+
textSha256: sha256Hex(RECORD_TEXT),
|
|
72
|
+
createdAt,
|
|
73
|
+
source: 'channel',
|
|
74
|
+
replyAlreadyDeliveredThisTurn: false,
|
|
75
|
+
}
|
|
76
|
+
const ok = writeOutboxRecordAtomic(record, dir)
|
|
77
|
+
expect(ok).toBe(true)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('turn-flush vs outbox-sweep — the flush claims its nonce at SEND-ACK, before a slow read-back', () => {
|
|
81
|
+
let dir: string
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
dir = mkdtempSync(join(tmpdir(), 'flush-ack-race-'))
|
|
84
|
+
})
|
|
85
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
86
|
+
|
|
87
|
+
it('a read-back slower than OUTBOX_QUIET_MS + a same-nonce/different-text record ⇒ the sweep sends NOTHING (exactly-once)', async () => {
|
|
88
|
+
const now = 10_000_000
|
|
89
|
+
// The captured record was written well before the quiet window elapsed.
|
|
90
|
+
writeAgedRecord(dir, now - (OUTBOX_QUIET_MS + 5_000))
|
|
91
|
+
|
|
92
|
+
// Read-back gate — held OPEN so runBackstopDelivery cannot return until we
|
|
93
|
+
// release it, simulating the cosmetic-edit flood-fuse deferring the probe
|
|
94
|
+
// ~30s (far longer than the sweep's 5s quiet window).
|
|
95
|
+
let releaseReadBack!: () => void
|
|
96
|
+
const readBackGate = new Promise<void>((resolve) => {
|
|
97
|
+
releaseReadBack = resolve
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const ledger = new BackstopDeliveryLedger()
|
|
101
|
+
ledger.claim(NONCE)
|
|
102
|
+
|
|
103
|
+
const sentChunks: string[] = []
|
|
104
|
+
const flushPromise = runBackstopDelivery(
|
|
105
|
+
ledger,
|
|
106
|
+
NONCE,
|
|
107
|
+
[FLUSH_TEXT],
|
|
108
|
+
null,
|
|
109
|
+
{
|
|
110
|
+
sendChunk: async (_i, text) => {
|
|
111
|
+
sentChunks.push(text)
|
|
112
|
+
return [777] // Telegram acked — a fresh non-card chat id.
|
|
113
|
+
},
|
|
114
|
+
// Mirrors the real deliverAnswer read-back: paced cosmetic, deferrable.
|
|
115
|
+
readBack: async (): Promise<ReadBackResult> => {
|
|
116
|
+
await readBackGate
|
|
117
|
+
return 'exists'
|
|
118
|
+
},
|
|
119
|
+
// The fix: journal the nonce at ack, BEFORE the read-back above resolves.
|
|
120
|
+
onAckClaim: (ackIds) => {
|
|
121
|
+
journalExternalDelivery(
|
|
122
|
+
{
|
|
123
|
+
turnNonce: NONCE,
|
|
124
|
+
text: FLUSH_TEXT,
|
|
125
|
+
tgMessageId: ackIds[0],
|
|
126
|
+
deliverySource: 'flush',
|
|
127
|
+
},
|
|
128
|
+
dir,
|
|
129
|
+
)
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
3,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
// Let the send loop run its send + (with the fix) the ack claim, then park
|
|
136
|
+
// on the still-open read-back probe. Bounded macrotask flushes — no timers,
|
|
137
|
+
// no dependence on the fix, so a missing claim surfaces as a clean assertion
|
|
138
|
+
// failure below rather than a hang.
|
|
139
|
+
for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r))
|
|
140
|
+
expect(sentChunks).toEqual([FLUSH_TEXT])
|
|
141
|
+
// Durable claim landed at ack-time — WHILE the read-back is still open.
|
|
142
|
+
expect(readDeliveredNonces(dir).has(NONCE)).toBe(true)
|
|
143
|
+
|
|
144
|
+
// Now the sweep ticks, past the quiet window, with the text dedup MISSING
|
|
145
|
+
// (record text ≠ flush text). Its only exactly-once guard is the journal.
|
|
146
|
+
const swept: string[] = []
|
|
147
|
+
const summary = await sweepOutbox({
|
|
148
|
+
stateDir: dir,
|
|
149
|
+
now: () => now,
|
|
150
|
+
send: async (_chatId, _threadId, text) => {
|
|
151
|
+
swept.push(text)
|
|
152
|
+
return { messageId: 500, chunks: [{ messageId: 500, text }] }
|
|
153
|
+
},
|
|
154
|
+
// The exact-text in-memory dedup cannot help — the texts differ.
|
|
155
|
+
textAlreadyDelivered: () => false,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// THE user outcome: the sweep sent nothing — the flush already owns this turn.
|
|
159
|
+
expect(swept).toEqual([])
|
|
160
|
+
expect(summary.delivered).toBe(0)
|
|
161
|
+
|
|
162
|
+
// Release the (slow) read-back and let the flush finish cleanly.
|
|
163
|
+
releaseReadBack()
|
|
164
|
+
const result = await flushPromise
|
|
165
|
+
expect(result.delivered).toBe(true)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('no-loss guard: a flush whose send never acks does NOT claim, so the sweep still delivers the captured answer once', async () => {
|
|
169
|
+
const now = 20_000_000
|
|
170
|
+
writeAgedRecord(dir, now - (OUTBOX_QUIET_MS + 5_000))
|
|
171
|
+
|
|
172
|
+
const ledger = new BackstopDeliveryLedger()
|
|
173
|
+
ledger.claim(NONCE)
|
|
174
|
+
|
|
175
|
+
let ackClaimCount = 0
|
|
176
|
+
const result = await runBackstopDelivery(
|
|
177
|
+
ledger,
|
|
178
|
+
NONCE,
|
|
179
|
+
[FLUSH_TEXT],
|
|
180
|
+
null,
|
|
181
|
+
{
|
|
182
|
+
// Every attempt throws — the answer never lands, so the claim predicate
|
|
183
|
+
// (all chunks landed + fresh receipt) is never met.
|
|
184
|
+
sendChunk: async () => {
|
|
185
|
+
throw new Error('telegram send rejected')
|
|
186
|
+
},
|
|
187
|
+
readBack: async (): Promise<ReadBackResult> => 'exists',
|
|
188
|
+
onAckClaim: () => {
|
|
189
|
+
ackClaimCount++
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
3,
|
|
193
|
+
)
|
|
194
|
+
// The flush genuinely failed and never claimed the nonce.
|
|
195
|
+
expect(result.delivered).toBe(false)
|
|
196
|
+
expect(ackClaimCount).toBe(0)
|
|
197
|
+
expect(readDeliveredNonces(dir).has(NONCE)).toBe(false)
|
|
198
|
+
|
|
199
|
+
// The safety net now delivers the captured answer — exactly once, no loss.
|
|
200
|
+
const swept: string[] = []
|
|
201
|
+
const summary = await sweepOutbox({
|
|
202
|
+
stateDir: dir,
|
|
203
|
+
now: () => now,
|
|
204
|
+
send: async (_chatId, _threadId, text) => {
|
|
205
|
+
swept.push(text)
|
|
206
|
+
return { messageId: 501, chunks: [{ messageId: 501, text }] }
|
|
207
|
+
},
|
|
208
|
+
textAlreadyDelivered: () => false,
|
|
209
|
+
})
|
|
210
|
+
expect(swept).toEqual([RECORD_TEXT])
|
|
211
|
+
expect(summary.delivered).toBe(1)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -268,7 +268,7 @@ async function runTurn(opts: {
|
|
|
268
268
|
send: async (_chatId, _threadId, text) => {
|
|
269
269
|
delivered.push({ via: 'sweep', text })
|
|
270
270
|
sentLog.push({ text, at: now })
|
|
271
|
-
return 500
|
|
271
|
+
return { messageId: 500, chunks: [{ messageId: 500, text }] }
|
|
272
272
|
},
|
|
273
273
|
// Mirrors the gateway's TTL-bounded exact-text `outboundDedup` cache:
|
|
274
274
|
// anything sent (reply or sweep) within the TTL dedups; older evicts.
|
|
@@ -61,7 +61,7 @@ describe('outbox sweep vs an open flood window', () => {
|
|
|
61
61
|
|
|
62
62
|
it('does not hit the wire while a flood window is open, and keeps the record', async () => {
|
|
63
63
|
writeOutboxRecordAtomic(rec(), dir)
|
|
64
|
-
const send = vi.fn(async () => 1)
|
|
64
|
+
const send = vi.fn(async () => ({ messageId: 1, chunks: [{ messageId: 1, text: rec().text }] }))
|
|
65
65
|
const log = vi.fn()
|
|
66
66
|
|
|
67
67
|
const summary = await sweepOutbox({
|
|
@@ -90,7 +90,7 @@ describe('outbox sweep vs an open flood window', () => {
|
|
|
90
90
|
|
|
91
91
|
it('delivers the SAME record once the window closes', async () => {
|
|
92
92
|
writeOutboxRecordAtomic(rec(), dir)
|
|
93
|
-
const send = vi.fn(async () => 42)
|
|
93
|
+
const send = vi.fn(async () => ({ messageId: 42, chunks: [{ messageId: 42, text: rec().text }] }))
|
|
94
94
|
let remaining = 12_247_000
|
|
95
95
|
|
|
96
96
|
const deferred = await sweepOutbox({
|
|
@@ -120,7 +120,7 @@ describe('outbox sweep vs an open flood window', () => {
|
|
|
120
120
|
|
|
121
121
|
it('sweeps normally when no window is open', async () => {
|
|
122
122
|
writeOutboxRecordAtomic(rec(), dir)
|
|
123
|
-
const send = vi.fn(async () => 7)
|
|
123
|
+
const send = vi.fn(async () => ({ messageId: 7, chunks: [{ messageId: 7, text: rec().text }] }))
|
|
124
124
|
const summary = await sweepOutbox({
|
|
125
125
|
stateDir: dir,
|
|
126
126
|
send,
|
|
@@ -135,7 +135,7 @@ describe('outbox sweep vs an open flood window', () => {
|
|
|
135
135
|
|
|
136
136
|
it('FAILS OPEN: a throwing probe must never strand the outbox', async () => {
|
|
137
137
|
writeOutboxRecordAtomic(rec(), dir)
|
|
138
|
-
const send = vi.fn(async () => 7)
|
|
138
|
+
const send = vi.fn(async () => ({ messageId: 7, chunks: [{ messageId: 7, text: rec().text }] }))
|
|
139
139
|
const summary = await sweepOutbox({
|
|
140
140
|
stateDir: dir,
|
|
141
141
|
send,
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { describe, it, expect } from 'vitest'
|
|
23
|
+
import { GrammyError } from 'grammy'
|
|
23
24
|
import { createOutboxSend } from '../gateway/outbox-sweep.js'
|
|
24
25
|
import { makeOutboxListenMarkupResolver } from '../gateway/outbox-listen-markup.js'
|
|
25
26
|
import {
|
|
@@ -38,14 +39,37 @@ const kokoroOnDemand: ListenButtonVoiceOutPlan = {
|
|
|
38
39
|
// fallback needed for the unit under test).
|
|
39
40
|
const passthroughRetry = <U>(fn: () => Promise<U>): Promise<U> => fn()
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
// The sweep now renders via sendRichMessage (raw GFM markdown → entities), the
|
|
43
|
+
// SAME path the reply path uses; a markdown parse-reject falls back to plain
|
|
44
|
+
// sendMessage. `calls` records the CANONICAL rich sends (with the raw markdown
|
|
45
|
+
// pulled out of the `{ markdown }` body as `text`) so the existing assertions
|
|
46
|
+
// keep asserting the bytes handed to the wire; `plainCalls` records the
|
|
47
|
+
// parse-reject fallback sends.
|
|
48
|
+
function fakeBot(opts?: { failRich?: boolean }) {
|
|
42
49
|
const calls: Array<{ chatId: string; text: string; opts: Record<string, unknown> }> = []
|
|
50
|
+
const plainCalls: Array<{ chatId: string; text: string; opts: Record<string, unknown> }> = []
|
|
43
51
|
let n = 100
|
|
44
52
|
return {
|
|
45
53
|
calls,
|
|
54
|
+
plainCalls,
|
|
46
55
|
api: {
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
sendRichMessage: async (chatId: string, body: { markdown: string }, o: object) => {
|
|
57
|
+
if (opts?.failRich) {
|
|
58
|
+
// A real Telegram markdown parse-reject (isParseEntitiesError keys on
|
|
59
|
+
// `instanceof GrammyError` + the description), so the sweep falls back
|
|
60
|
+
// to a plain sendMessage of the same chunk.
|
|
61
|
+
throw new GrammyError(
|
|
62
|
+
'Call to sendRichMessage failed!',
|
|
63
|
+
{ ok: false, error_code: 400, description: "Bad Request: can't parse entities" },
|
|
64
|
+
'sendRichMessage',
|
|
65
|
+
{} as never,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
calls.push({ chatId, text: body.markdown, opts: o as Record<string, unknown> })
|
|
69
|
+
return { message_id: n++ }
|
|
70
|
+
},
|
|
71
|
+
sendMessage: async (chatId: string, text: string, o: object) => {
|
|
72
|
+
plainCalls.push({ chatId, text, opts: o as Record<string, unknown> })
|
|
49
73
|
return { message_id: n++ }
|
|
50
74
|
},
|
|
51
75
|
},
|
|
@@ -99,6 +123,44 @@ describe('planListenButton — shared Listen-button decision', () => {
|
|
|
99
123
|
})
|
|
100
124
|
})
|
|
101
125
|
|
|
126
|
+
describe('createOutboxSend — RENDER PARITY (markdown is rendered, not raw)', () => {
|
|
127
|
+
it('sends through sendRichMessage with the markdown body, NOT a raw sendMessage', async () => {
|
|
128
|
+
// The bug: the sweep sent via a plain bot.api.sendMessage with no parse_mode
|
|
129
|
+
// and no rich render, so `**bold**` reached the operator verbatim. The fix
|
|
130
|
+
// routes each chunk through richMessage → sendRichMessage. This asserts the
|
|
131
|
+
// rendered payload (a `{ markdown }` body on the rich API), which the pre-fix
|
|
132
|
+
// raw-sendMessage path never produced.
|
|
133
|
+
const bot = fakeBot()
|
|
134
|
+
const send = createOutboxSend({ getBot: () => bot, retry: passthroughRetry })
|
|
135
|
+
|
|
136
|
+
const result = await send('123', null, 'Here is **bold** and `code`.')
|
|
137
|
+
|
|
138
|
+
// Rendered via the rich path (markdown preserved for Telegram to parse)…
|
|
139
|
+
expect(bot.calls).toHaveLength(1)
|
|
140
|
+
expect(bot.calls[0]!.text).toBe('Here is **bold** and `code`.')
|
|
141
|
+
// …and NOT via the raw plain-text path (the pre-fix bug).
|
|
142
|
+
expect(bot.plainCalls).toHaveLength(0)
|
|
143
|
+
// The landed chunk (id + text) is reported back for history persistence.
|
|
144
|
+
expect(result.chunks).toEqual([{ messageId: 100, text: 'Here is **bold** and `code`.' }])
|
|
145
|
+
expect(result.messageId).toBe(100)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('falls back to a PLAIN sendMessage of the same chunk on a markdown parse-reject', async () => {
|
|
149
|
+
// Mirrors the reply path's plaintext fallback: if Telegram rejects the
|
|
150
|
+
// markdown entities, resend the raw source as plain text (readable prose)
|
|
151
|
+
// rather than dropping the answer.
|
|
152
|
+
const bot = fakeBot({ failRich: true })
|
|
153
|
+
const send = createOutboxSend({ getBot: () => bot, retry: passthroughRetry })
|
|
154
|
+
|
|
155
|
+
const result = await send('123', null, 'weird **[unbalanced markdown')
|
|
156
|
+
|
|
157
|
+
expect(bot.calls).toHaveLength(0) // rich rejected
|
|
158
|
+
expect(bot.plainCalls).toHaveLength(1) // plain fallback delivered
|
|
159
|
+
expect(bot.plainCalls[0]!.text).toBe('weird **[unbalanced markdown')
|
|
160
|
+
expect(result.chunks).toHaveLength(1) // still reported for history
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
102
164
|
describe('createOutboxSend — net-delivered answer carries the Listen button (#3502)', () => {
|
|
103
165
|
it('attaches the Listen keyboard as reply_markup when voice-out on-demand is enabled', async () => {
|
|
104
166
|
const bot = fakeBot()
|
|
@@ -128,8 +190,8 @@ describe('createOutboxSend — net-delivered answer carries the Listen button (#
|
|
|
128
190
|
resolveReplyMarkup: () => markup,
|
|
129
191
|
})
|
|
130
192
|
|
|
131
|
-
// >
|
|
132
|
-
await send('123', null, 'x'.repeat(
|
|
193
|
+
// > RICH_MESSAGE_MAX_CHARS (32768) of a single unbreakable token → two chunks.
|
|
194
|
+
await send('123', null, 'x'.repeat(40000))
|
|
133
195
|
|
|
134
196
|
expect(bot.calls).toHaveLength(2)
|
|
135
197
|
expect(bot.calls[0]!.opts.reply_markup).toBeUndefined()
|
|
@@ -162,10 +224,10 @@ describe('createOutboxSend — net-delivered answer carries the Listen button (#
|
|
|
162
224
|
expect(bot.calls[0]!.opts.reply_markup).toBeUndefined()
|
|
163
225
|
})
|
|
164
226
|
|
|
165
|
-
it('empty text sends NOTHING and
|
|
227
|
+
it('empty text sends NOTHING and reports an empty result (no empty-message retry wedge)', async () => {
|
|
166
228
|
// Telegram rejects an empty message body; a stray '' chunk would throw
|
|
167
229
|
// every sweep tick and wedge the record in a permanent retry. The send
|
|
168
|
-
// must short-circuit instead of calling sendMessage.
|
|
230
|
+
// must short-circuit instead of calling sendMessage/sendRichMessage.
|
|
169
231
|
const bot = fakeBot()
|
|
170
232
|
const resolverCalls: string[] = []
|
|
171
233
|
const send = createOutboxSend({
|
|
@@ -179,8 +241,9 @@ describe('createOutboxSend — net-delivered answer carries the Listen button (#
|
|
|
179
241
|
|
|
180
242
|
const result = await send('123', null, '')
|
|
181
243
|
|
|
182
|
-
expect(result).
|
|
244
|
+
expect(result).toEqual({ messageId: undefined, chunks: [] })
|
|
183
245
|
expect(bot.calls).toHaveLength(0)
|
|
246
|
+
expect(bot.plainCalls).toHaveLength(0)
|
|
184
247
|
// No point resolving a Listen button for a body we never send.
|
|
185
248
|
expect(resolverCalls).toEqual([])
|
|
186
249
|
})
|
|
@@ -547,6 +547,53 @@ describe('deliverCapturedProse — silent-end recovery (routed into the send mod
|
|
|
547
547
|
expect(h.calls).toHaveLength(0)
|
|
548
548
|
})
|
|
549
549
|
|
|
550
|
+
it('PERSIST PARITY: the exhausted-boundary plain-text fallback records the recovered answer to history', async () => {
|
|
551
|
+
// #3228 exhausted-boundary: the rich send fails on the attempt where the
|
|
552
|
+
// re-prompt budget is already spent, so deliverCapturedProse delivers the
|
|
553
|
+
// real answer as PLAIN text. Pre-fix that plain-text delivery recorded only
|
|
554
|
+
// to the in-memory dedup and NEVER called recordOutbound — so the recovered
|
|
555
|
+
// answer reached the user but was silently absent from history.db. This
|
|
556
|
+
// asserts recordOutbound now fires once with the delivered message_id + the
|
|
557
|
+
// recovered text.
|
|
558
|
+
const dedup = new OutboundDedupCache()
|
|
559
|
+
// Rich send parse-rejects (one chunk → one queued failure); the plain
|
|
560
|
+
// sendMessage fallback succeeds.
|
|
561
|
+
const { api, calls } = makeFakeBot({ sendRichMessage: [grammy400("can't parse entities")] })
|
|
562
|
+
const recorded: Array<{ chat_id: string; thread_id: number | null; message_ids: number[]; texts: string[] }> = []
|
|
563
|
+
const text = 'The exhausted-boundary recovered answer.'
|
|
564
|
+
const deps: DeliverCapturedProseDeps = {
|
|
565
|
+
outboundDedup: dedup,
|
|
566
|
+
bot: { api } as unknown as DeliverCapturedProseDeps['bot'],
|
|
567
|
+
robustApiCall: (fn) => fn(),
|
|
568
|
+
redactOutboundText: (t) => redact(t),
|
|
569
|
+
recordOutbound: (rec) => recorded.push({ chat_id: rec.chat_id, thread_id: rec.thread_id, message_ids: rec.message_ids, texts: rec.texts }),
|
|
570
|
+
HISTORY_ENABLED: true,
|
|
571
|
+
OBLIGATION_LEDGER_ENABLED: true,
|
|
572
|
+
obligationLedger: { close: () => {} },
|
|
573
|
+
clearSilentEndState: () => {},
|
|
574
|
+
// Budget already spent → the fallback path fires.
|
|
575
|
+
recordUndeliveredTurnEnd: () => ({ exhausted: true }),
|
|
576
|
+
hasOutboundDeliveredSince: () => false,
|
|
577
|
+
}
|
|
578
|
+
await deliverCapturedProse(deps, {
|
|
579
|
+
chatId: CHAT,
|
|
580
|
+
threadId: undefined,
|
|
581
|
+
statusKeyStr: `${CHAT}:main`,
|
|
582
|
+
registryKey: null,
|
|
583
|
+
originTurnId: 'turn-45',
|
|
584
|
+
text,
|
|
585
|
+
})
|
|
586
|
+
// The plain-text fallback actually delivered.
|
|
587
|
+
const plainSends = calls.filter((c) => c.method === 'sendMessage')
|
|
588
|
+
expect(plainSends).toHaveLength(1)
|
|
589
|
+
expect(plainSends[0]!.text).toBe(text)
|
|
590
|
+
// …and it was persisted to history exactly once with the delivered id + text.
|
|
591
|
+
expect(recorded).toHaveLength(1)
|
|
592
|
+
expect(recorded[0]!.chat_id).toBe(CHAT)
|
|
593
|
+
expect(recorded[0]!.message_ids).toEqual([plainSends[0]!.message_id])
|
|
594
|
+
expect(recorded[0]!.texts).toEqual([text])
|
|
595
|
+
})
|
|
596
|
+
|
|
550
597
|
it('already-deduped content settles bookkeeping WITHOUT a wire send', async () => {
|
|
551
598
|
const dedup = new OutboundDedupCache()
|
|
552
599
|
const text = 'Already went out earlier via the stream.'
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4058 — mid-turn auto-compaction must not trip the 300s silence fallback.
|
|
3
|
+
*
|
|
4
|
+
* The bug: during compaction the model emits zero output and runs zero tools,
|
|
5
|
+
* so the gateway saw pure silence and fired the framework fallback ("⚠️ no
|
|
6
|
+
* output for 5 min — the framework ended that stalled turn") on a healthy
|
|
7
|
+
* turn that completed correctly right after compaction. Observed live:
|
|
8
|
+
* ~1m50s of tools + ~3m25s compacting = 304s silence → false fire.
|
|
9
|
+
*
|
|
10
|
+
* The fix: the PreCompact hook writes a compaction marker; silence-poke gets
|
|
11
|
+
* an `isCompactionInFlight` dep consulted in the SAME `underCeiling` defer
|
|
12
|
+
* branch as the #1292/#3519 in-flight-tool defers; the transcript's
|
|
13
|
+
* `compact_boundary` record (compaction END) clears the marker and counts as
|
|
14
|
+
* production. These tests drive the REAL tick loop and assert outcomes:
|
|
15
|
+
* - a compaction gap past 300s but under the hard ceiling → NO fire;
|
|
16
|
+
* - a genuinely-wedged turn (no compaction) → STILL fires at 300s;
|
|
17
|
+
* - a compaction stuck past the hard ceiling → STILL fires (bounded defer).
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
20
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync, utimesSync } from 'node:fs'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { execFileSync } from 'node:child_process'
|
|
24
|
+
import { projectTranscriptLine } from '../session-tail.js'
|
|
25
|
+
import { applySilencePokeSessionEvent } from '../gateway/silence-poke-session-event.js'
|
|
26
|
+
import {
|
|
27
|
+
COMPACTION_MARKER_FILE,
|
|
28
|
+
readCompactionMarkerAgeMs,
|
|
29
|
+
removeCompactionMarker,
|
|
30
|
+
} from '../gateway/compaction-marker.js'
|
|
31
|
+
import * as silencePoke from '../silence-poke.js'
|
|
32
|
+
import * as pendingProgress from '../pending-work-progress.js'
|
|
33
|
+
import {
|
|
34
|
+
startTurn,
|
|
35
|
+
__tickForTests,
|
|
36
|
+
__setDepsForTests,
|
|
37
|
+
__getStateForTests,
|
|
38
|
+
__resetAllForTests,
|
|
39
|
+
DEFAULT_THRESHOLDS,
|
|
40
|
+
type SilencePokeMetric,
|
|
41
|
+
type FrameworkFallbackContext,
|
|
42
|
+
} from '../silence-poke.js'
|
|
43
|
+
|
|
44
|
+
const HARD_CEILING = 900_000 // SILENCE_FALLBACK_HARD_MS default
|
|
45
|
+
|
|
46
|
+
interface TestFixtures {
|
|
47
|
+
emitted: SilencePokeMetric[]
|
|
48
|
+
fallbacks: FrameworkFallbackContext[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setupDeps(opts?: {
|
|
52
|
+
isCompactionInFlight?: (key: string) => boolean
|
|
53
|
+
isLegitimatelyWorking?: (key: string) => boolean
|
|
54
|
+
}): TestFixtures {
|
|
55
|
+
const fixtures: TestFixtures = { emitted: [], fallbacks: [] }
|
|
56
|
+
__setDepsForTests({
|
|
57
|
+
emitMetric: (e) => fixtures.emitted.push(e),
|
|
58
|
+
onFrameworkFallback: (ctx) => { fixtures.fallbacks.push(ctx) },
|
|
59
|
+
thresholdsMs: { ...DEFAULT_THRESHOLDS, fallbackHardCeiling: HARD_CEILING },
|
|
60
|
+
// Mirror production wiring: the callback path is active (liveness-wiring
|
|
61
|
+
// always wires isLegitimatelyWorking), returning false = "no tool work".
|
|
62
|
+
isLegitimatelyWorking: opts?.isLegitimatelyWorking ?? (() => false),
|
|
63
|
+
...(opts?.isCompactionInFlight != null
|
|
64
|
+
? { isCompactionInFlight: opts.isCompactionInFlight }
|
|
65
|
+
: {}),
|
|
66
|
+
})
|
|
67
|
+
return fixtures
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
__resetAllForTests()
|
|
72
|
+
delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
__resetAllForTests()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('silence-poke — #4058 compaction defer (outcome)', () => {
|
|
80
|
+
it('a compaction gap longer than 300s but under the hard ceiling does NOT fire the fallback', () => {
|
|
81
|
+
// Real observed shape: tools until t=110s, compaction t=110s..415s (305s
|
|
82
|
+
// of pure silence — past the 300s window), turn completes after.
|
|
83
|
+
let compacting = false
|
|
84
|
+
const fx = setupDeps({ isCompactionInFlight: () => compacting })
|
|
85
|
+
startTurn('chat:1', 0)
|
|
86
|
+
// Tools produced feed renders until 110s → production reset at 110s.
|
|
87
|
+
silencePoke.noteProduction('chat:1', 110_000)
|
|
88
|
+
compacting = true // PreCompact hook fired
|
|
89
|
+
__tickForTests(300_000) // 190s silent — under threshold anyway
|
|
90
|
+
__tickForTests(415_000) // 305s silent — WOULD fire without the fix
|
|
91
|
+
__tickForTests(500_000) // 390s silent — still compacting
|
|
92
|
+
expect(fx.fallbacks).toHaveLength(0)
|
|
93
|
+
expect(fx.emitted).toHaveLength(0)
|
|
94
|
+
// Compaction ends: the compact_boundary handler clears the marker AND
|
|
95
|
+
// counts the boundary as production (fresh window for the resumed turn).
|
|
96
|
+
compacting = false
|
|
97
|
+
silencePoke.noteProduction('chat:1', 505_000)
|
|
98
|
+
__tickForTests(510_000)
|
|
99
|
+
expect(fx.fallbacks).toHaveLength(0) // healthy turn: never fired
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('a genuinely-wedged turn with no compaction STILL fires at 300s', () => {
|
|
103
|
+
const fx = setupDeps({ isCompactionInFlight: () => false })
|
|
104
|
+
startTurn('chat:2', 0)
|
|
105
|
+
__tickForTests(300_000)
|
|
106
|
+
expect(fx.fallbacks).toHaveLength(1)
|
|
107
|
+
expect(fx.emitted.at(-1)).toMatchObject({ kind: 'silence_fallback_sent' })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('a turn that goes silent AFTER compaction ends still fires one window later', () => {
|
|
111
|
+
let compacting = true
|
|
112
|
+
const fx = setupDeps({ isCompactionInFlight: () => compacting })
|
|
113
|
+
startTurn('chat:3', 0)
|
|
114
|
+
__tickForTests(310_000)
|
|
115
|
+
expect(fx.fallbacks).toHaveLength(0) // deferred while compacting
|
|
116
|
+
compacting = false
|
|
117
|
+
silencePoke.noteProduction('chat:3', 320_000) // compact_boundary landed
|
|
118
|
+
__tickForTests(325_000)
|
|
119
|
+
expect(fx.fallbacks).toHaveLength(0)
|
|
120
|
+
// …but the model never resumes → real wedge → fires 300s after boundary.
|
|
121
|
+
__tickForTests(620_000)
|
|
122
|
+
expect(fx.fallbacks).toHaveLength(1)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('a compaction stuck past the hard ceiling STILL fires (bounded defer)', () => {
|
|
126
|
+
const fx = setupDeps({ isCompactionInFlight: () => true })
|
|
127
|
+
startTurn('chat:4', 0)
|
|
128
|
+
__tickForTests(899_000)
|
|
129
|
+
expect(fx.fallbacks).toHaveLength(0) // deferred under the ceiling
|
|
130
|
+
__tickForTests(900_000) // silence ≥ fallbackHardCeiling → defer expires
|
|
131
|
+
expect(fx.fallbacks).toHaveLength(1)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('absent dep (legacy fixtures) leaves behaviour unchanged — fires at 300s', () => {
|
|
135
|
+
const fx = setupDeps()
|
|
136
|
+
startTurn('chat:5', 0)
|
|
137
|
+
__tickForTests(300_000)
|
|
138
|
+
expect(fx.fallbacks).toHaveLength(1)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('session-tail — compact_boundary projection', () => {
|
|
143
|
+
it('projects a real transcript compact_boundary line', () => {
|
|
144
|
+
// Verbatim shape from a live agent transcript (trimmed to the fields the
|
|
145
|
+
// projection reads).
|
|
146
|
+
const line = JSON.stringify({
|
|
147
|
+
parentUuid: null,
|
|
148
|
+
isSidechain: false,
|
|
149
|
+
type: 'system',
|
|
150
|
+
subtype: 'compact_boundary',
|
|
151
|
+
content: 'Conversation compacted',
|
|
152
|
+
level: 'info',
|
|
153
|
+
compactMetadata: { trigger: 'auto', preTokens: 283797, postTokens: 224551, durationMs: 111959 },
|
|
154
|
+
})
|
|
155
|
+
expect(projectTranscriptLine(line)).toEqual([
|
|
156
|
+
{ kind: 'compact_boundary', trigger: 'auto', compactDurationMs: 111959 },
|
|
157
|
+
])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('tolerates missing compactMetadata', () => {
|
|
161
|
+
const line = JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted' })
|
|
162
|
+
expect(projectTranscriptLine(line)).toEqual([
|
|
163
|
+
{ kind: 'compact_boundary', trigger: null, compactDurationMs: null },
|
|
164
|
+
])
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('compaction-marker + session-event wiring', () => {
|
|
169
|
+
let dir: string
|
|
170
|
+
const envBefore = process.env.TELEGRAM_STATE_DIR
|
|
171
|
+
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
dir = mkdtempSync(join(tmpdir(), 'sp-compact-'))
|
|
174
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
afterEach(() => {
|
|
178
|
+
if (envBefore != null) process.env.TELEGRAM_STATE_DIR = envBefore
|
|
179
|
+
else delete process.env.TELEGRAM_STATE_DIR
|
|
180
|
+
rmSync(dir, { recursive: true, force: true })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('readCompactionMarkerAgeMs: absent → null; present → mtime age', () => {
|
|
184
|
+
expect(readCompactionMarkerAgeMs(dir, 1_000)).toBeNull()
|
|
185
|
+
const p = join(dir, COMPACTION_MARKER_FILE)
|
|
186
|
+
writeFileSync(p, '{"ts":1}\n')
|
|
187
|
+
const at = new Date(Date.now() - 10_000)
|
|
188
|
+
utimesSync(p, at, at)
|
|
189
|
+
const age = readCompactionMarkerAgeMs(dir)
|
|
190
|
+
expect(age).not.toBeNull()
|
|
191
|
+
expect(age!).toBeGreaterThanOrEqual(9_000)
|
|
192
|
+
expect(age!).toBeLessThan(60_000)
|
|
193
|
+
removeCompactionMarker(dir)
|
|
194
|
+
expect(readCompactionMarkerAgeMs(dir)).toBeNull()
|
|
195
|
+
removeCompactionMarker(dir) // idempotent
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('compact_boundary session event clears the marker and resets the silence clock', () => {
|
|
199
|
+
writeFileSync(join(dir, COMPACTION_MARKER_FILE), '{"ts":1}\n')
|
|
200
|
+
startTurn('c:9', 0)
|
|
201
|
+
setupDeps()
|
|
202
|
+
applySilencePokeSessionEvent(silencePoke, pendingProgress, 'c:9', {
|
|
203
|
+
kind: 'compact_boundary',
|
|
204
|
+
trigger: 'auto',
|
|
205
|
+
compactDurationMs: 111_959,
|
|
206
|
+
})
|
|
207
|
+
expect(existsSync(join(dir, COMPACTION_MARKER_FILE))).toBe(false)
|
|
208
|
+
// Clock reset: lastOutboundAt stamped by noteProduction.
|
|
209
|
+
expect(__getStateForTests('c:9')?.lastOutboundAt).not.toBeNull()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('PreCompact hook writes the marker from its stdin payload', () => {
|
|
213
|
+
const hook = join(__dirname, '..', 'hooks', 'compaction-marker-precompact.mjs')
|
|
214
|
+
execFileSync('node', [hook], {
|
|
215
|
+
env: { ...process.env, TELEGRAM_STATE_DIR: dir },
|
|
216
|
+
input: JSON.stringify({ session_id: 's-1', trigger: 'auto', hook_event_name: 'PreCompact' }),
|
|
217
|
+
})
|
|
218
|
+
const age = readCompactionMarkerAgeMs(dir)
|
|
219
|
+
expect(age).not.toBeNull()
|
|
220
|
+
expect(age!).toBeLessThan(30_000)
|
|
221
|
+
})
|
|
222
|
+
})
|