switchroom 0.19.12 → 0.19.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -0
- package/profiles/_base/start.sh.hbs +11 -0
- package/telegram-plugin/dist/gateway/gateway.js +1382 -1032
- package/telegram-plugin/final-answer-detect.ts +23 -0
- package/telegram-plugin/gateway/gateway.ts +29 -36
- package/telegram-plugin/gateway/outbound-send-path.ts +109 -8
- package/telegram-plugin/gateway/outbox-sweep.ts +282 -0
- package/telegram-plugin/gateway/stream-render.ts +26 -50
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +94 -2
- package/telegram-plugin/hooks/silent-end-scan.mjs +279 -0
- package/telegram-plugin/outbox.ts +472 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +91 -0
- package/telegram-plugin/temporal-normalize.ts +347 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +22 -12
- package/telegram-plugin/tests/outbound-send-path.test.ts +191 -0
- package/telegram-plugin/tests/outbox-capture-scan.test.ts +222 -0
- package/telegram-plugin/tests/outbox-delivery.test.ts +278 -0
- package/telegram-plugin/tests/outbox-hook-capture.test.ts +112 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +60 -63
- package/telegram-plugin/tests/silent-end.test.ts +21 -28
- package/telegram-plugin/tests/temporal-normalize.test.ts +222 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +23 -18
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbox-capture-scan.test.ts — the capture half of guaranteed final-message
|
|
3
|
+
* delivery (`work/final-message-delivery/`).
|
|
4
|
+
*
|
|
5
|
+
* Exercises the class-agnostic Stop-hook capture scan (`scanForOutboxCapture`)
|
|
6
|
+
* and the shared nonce (`deriveTurnNonce`) against the adversarial-review
|
|
7
|
+
* oracles: the incident replay (task-notification handback with trailing prose
|
|
8
|
+
* and no reply), same-content re-notification (H2, no nonce collision), cron
|
|
9
|
+
* capture (H5), unknown-anchor fail-closed (H4), and prose + trailing NO_REPLY
|
|
10
|
+
* (H6). Pure functions — no subprocess needed.
|
|
11
|
+
*
|
|
12
|
+
* RED-then-green: these functions are new; against pre-fix code the import
|
|
13
|
+
* resolves to `undefined` and every assertion throws. The hook-subprocess
|
|
14
|
+
* outcome test (`outbox-hook-capture.test.ts`) is the behavioural RED replay.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import {
|
|
19
|
+
scanForOutboxCapture,
|
|
20
|
+
deriveTurnNonce,
|
|
21
|
+
stripTrailingSilentMarker,
|
|
22
|
+
} from '../hooks/silent-end-scan.mjs'
|
|
23
|
+
|
|
24
|
+
const A_LONG = 'x'.repeat(1647) // the incident's 1,647-char final answer shape
|
|
25
|
+
|
|
26
|
+
function jsonl(lines: object[]): string {
|
|
27
|
+
return lines.map((l) => JSON.stringify(l)).join('\n')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function taskNotification(content = 'worker done', taskId = 'a7cc7a0fa8f', ts = 1000): object {
|
|
31
|
+
return {
|
|
32
|
+
type: 'queue-operation',
|
|
33
|
+
operation: 'enqueue',
|
|
34
|
+
content: `<task-notification><task-id>${taskId}</task-id> ${content}</task-notification>`,
|
|
35
|
+
timestamp: ts,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function channel(chatId: string, messageId: string, body = 'hi', ts = 1000, source = 'telegram'): object {
|
|
40
|
+
return {
|
|
41
|
+
type: 'queue-operation',
|
|
42
|
+
operation: 'enqueue',
|
|
43
|
+
content: `<channel source="${source}" chat_id="${chatId}" message_id="${messageId}">${body}</channel>`,
|
|
44
|
+
timestamp: ts,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assistantText(text: string): object {
|
|
49
|
+
return { type: 'assistant', message: { content: [{ type: 'text', text }] } }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function reply(text: string, opts: Record<string, unknown> = {}): object {
|
|
53
|
+
return {
|
|
54
|
+
type: 'assistant',
|
|
55
|
+
message: {
|
|
56
|
+
content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__reply', input: { text, ...opts } }],
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('scanForOutboxCapture — incident replay (task-notification handback)', () => {
|
|
62
|
+
it('captures the trailing final answer of a task-notification turn with no reply', () => {
|
|
63
|
+
const t = jsonl([taskNotification(), assistantText(A_LONG)])
|
|
64
|
+
const cap = scanForOutboxCapture(t)
|
|
65
|
+
expect(cap.capture).toBe(true)
|
|
66
|
+
if (cap.capture) {
|
|
67
|
+
expect(cap.text).toBe(A_LONG)
|
|
68
|
+
expect(cap.chatId).toBeNull() // envelope-less → routed at sweep (H3)
|
|
69
|
+
expect(cap.source).toBe('task-notification')
|
|
70
|
+
expect(cap.turnNonce).toBeTruthy()
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('H2 — nonce uniqueness on same-content re-notification', () => {
|
|
76
|
+
it('two identical task-notifications at different timestamps get distinct nonces', () => {
|
|
77
|
+
const c1 = scanForOutboxCapture(jsonl([taskNotification('done', 'task-A', 1000), assistantText(A_LONG)]))
|
|
78
|
+
const c2 = scanForOutboxCapture(jsonl([taskNotification('done', 'task-A', 2000), assistantText('y'.repeat(500))]))
|
|
79
|
+
expect(c1.capture && c2.capture).toBe(true)
|
|
80
|
+
if (c1.capture && c2.capture) expect(c1.turnNonce).not.toBe(c2.turnNonce)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('a gateway-visible turn keeps the deriveTurnId nonce shape', () => {
|
|
84
|
+
const cap = scanForOutboxCapture(jsonl([channel('999', '77'), assistantText(A_LONG)]))
|
|
85
|
+
expect(cap.capture).toBe(true)
|
|
86
|
+
if (cap.capture) expect(cap.turnNonce).toBe('999:_#77')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('H4 — unknown anchor fails CLOSED (still captures)', () => {
|
|
91
|
+
it('captures trailing prose when there is NO queue-operation anchor at all', () => {
|
|
92
|
+
// A future/unknown wake shape: no enqueue line, only a user boundary + prose.
|
|
93
|
+
const t = jsonl([
|
|
94
|
+
{ type: 'user', message: { content: 'resume' } },
|
|
95
|
+
assistantText(A_LONG),
|
|
96
|
+
])
|
|
97
|
+
const cap = scanForOutboxCapture(t)
|
|
98
|
+
expect(cap.capture).toBe(true)
|
|
99
|
+
if (cap.capture) {
|
|
100
|
+
expect(cap.text).toBe(A_LONG)
|
|
101
|
+
expect(cap.source).toBe('unknown')
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('still captures with no anchor line whatsoever (bare tail)', () => {
|
|
106
|
+
const cap = scanForOutboxCapture(jsonl([assistantText(A_LONG)]))
|
|
107
|
+
expect(cap.capture).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('H5 — cron turns are captured (no silent-loss carve-out)', () => {
|
|
112
|
+
it('captures a cron turn that ends with substantive prose and no reply', () => {
|
|
113
|
+
const cron = channel('555', '0', 'digest', 3000, 'cron')
|
|
114
|
+
const cap = scanForOutboxCapture(jsonl([cron, assistantText(A_LONG)]))
|
|
115
|
+
expect(cap.capture).toBe(true)
|
|
116
|
+
if (cap.capture) expect(cap.source).toBe('cron')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('H6 — prose + trailing NO_REPLY must not suppress a real answer', () => {
|
|
121
|
+
it('captures the prose that precedes a stray trailing NO_REPLY (same block)', () => {
|
|
122
|
+
const cap = scanForOutboxCapture(jsonl([channel('1', '2'), assistantText(`${A_LONG}\nNO_REPLY`)]))
|
|
123
|
+
expect(cap.capture).toBe(true)
|
|
124
|
+
if (cap.capture) expect(cap.text).toBe(A_LONG)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('captures a real answer in one block followed by a SEPARATE bare NO_REPLY block (multi-block)', () => {
|
|
128
|
+
// The multi-block masking case: a genuine undelivered answer, then a later
|
|
129
|
+
// standalone NO_REPLY block. The bare marker must NOT move the delivery
|
|
130
|
+
// cursor past the answer and suppress it.
|
|
131
|
+
const cap = scanForOutboxCapture(jsonl([
|
|
132
|
+
channel('1', '2'),
|
|
133
|
+
assistantText(A_LONG),
|
|
134
|
+
assistantText('NO_REPLY'),
|
|
135
|
+
]))
|
|
136
|
+
expect(cap.capture).toBe(true)
|
|
137
|
+
if (cap.capture) expect(cap.text).toBe(A_LONG)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('does NOT capture a pure NO_REPLY turn (legitimately silent)', () => {
|
|
141
|
+
expect(scanForOutboxCapture(jsonl([channel('1', '2'), assistantText('NO_REPLY')])).capture).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('does NOT capture a HEARTBEAT_OK-only cron turn', () => {
|
|
145
|
+
expect(
|
|
146
|
+
scanForOutboxCapture(jsonl([channel('5', '0', 'x', 1, 'cron'), assistantText('HEARTBEAT_OK')])).capture,
|
|
147
|
+
).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('does NOT capture when the final answer was delivered via reply', () => {
|
|
151
|
+
expect(scanForOutboxCapture(jsonl([channel('1', '2'), reply(A_LONG)])).capture).toBe(false)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('does NOT capture narration-only trailing text', () => {
|
|
155
|
+
expect(scanForOutboxCapture(jsonl([channel('1', '2'), assistantText('Let me check that…')])).capture).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('F2 — per-session origin chat stamped for envelope-less routing', () => {
|
|
160
|
+
it('stamps the session origin chat on a task-notification handback captured after a DM inbound', () => {
|
|
161
|
+
const cap = scanForOutboxCapture(jsonl([
|
|
162
|
+
channel('777', '5', 'do the thing'), // the DM the user spawned the task from
|
|
163
|
+
taskNotification('worker done'), // envelope-less handback wake
|
|
164
|
+
assistantText(A_LONG),
|
|
165
|
+
]))
|
|
166
|
+
expect(cap.capture).toBe(true)
|
|
167
|
+
if (cap.capture) {
|
|
168
|
+
expect(cap.chatId).toBeNull() // envelope-less
|
|
169
|
+
expect((cap as { originChatId?: string | null }).originChatId).toBe('777')
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('leaves origin null when the session has no prior channel inbound (fail-closed)', () => {
|
|
174
|
+
const cap = scanForOutboxCapture(jsonl([taskNotification('worker done'), assistantText(A_LONG)]))
|
|
175
|
+
expect(cap.capture).toBe(true)
|
|
176
|
+
if (cap.capture) expect((cap as { originChatId?: string | null }).originChatId ?? null).toBeNull()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('interim-ack then undelivered answer (at-least-once)', () => {
|
|
181
|
+
it('captures the substantive answer written after a short interim ack reply', () => {
|
|
182
|
+
const t = jsonl([
|
|
183
|
+
channel('1', '2'),
|
|
184
|
+
reply('on it', { disable_notification: true }),
|
|
185
|
+
assistantText(A_LONG),
|
|
186
|
+
])
|
|
187
|
+
const cap = scanForOutboxCapture(t)
|
|
188
|
+
expect(cap.capture).toBe(true)
|
|
189
|
+
if (cap.capture) expect(cap.text).toBe(A_LONG)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('stripTrailingSilentMarker', () => {
|
|
194
|
+
it('strips a trailing bare marker and returns preceding prose', () => {
|
|
195
|
+
expect(stripTrailingSilentMarker('the answer\nNO_REPLY')).toEqual({ prose: 'the answer', hadMarker: true })
|
|
196
|
+
})
|
|
197
|
+
it('reports no marker for plain prose', () => {
|
|
198
|
+
expect(stripTrailingSilentMarker('just prose')).toEqual({ prose: 'just prose', hadMarker: false })
|
|
199
|
+
})
|
|
200
|
+
it('handles a marker-only block', () => {
|
|
201
|
+
expect(stripTrailingSilentMarker('NO_REPLY')).toEqual({ prose: '', hadMarker: true })
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('deriveTurnNonce', () => {
|
|
206
|
+
it('uses the deriveTurnId shape when a message_id is present', () => {
|
|
207
|
+
expect(
|
|
208
|
+
deriveTurnNonce({ chatId: '9', threadId: null, messageId: '3', anchorTimestampMs: 1, anchorContent: 'x' }),
|
|
209
|
+
).toBe('9:_#3')
|
|
210
|
+
})
|
|
211
|
+
it('threads the thread id into the key', () => {
|
|
212
|
+
expect(
|
|
213
|
+
deriveTurnNonce({ chatId: '9', threadId: 44, messageId: '3', anchorTimestampMs: 1, anchorContent: 'x' }),
|
|
214
|
+
).toBe('9:44#3')
|
|
215
|
+
})
|
|
216
|
+
it('falls back to a timestamped content hash without a message_id', () => {
|
|
217
|
+
const a = deriveTurnNonce({ chatId: null, threadId: null, messageId: null, anchorTimestampMs: 1, anchorContent: 'x' })
|
|
218
|
+
const b = deriveTurnNonce({ chatId: null, threadId: null, messageId: null, anchorTimestampMs: 2, anchorContent: 'x' })
|
|
219
|
+
expect(a).not.toBe(b)
|
|
220
|
+
expect(a).toMatch(/^[0-9a-f]{64}$/)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbox-delivery.test.ts — the delivery half of guaranteed final-message
|
|
3
|
+
* delivery. Exercises the pure sweep decision (`decideOutboxSweep`), routing
|
|
4
|
+
* (`resolveOutboxChat`, H3), the shared-nonce parity between the .mjs hook and
|
|
5
|
+
* the .ts gateway, and an end-to-end `sweepOutbox` run against injected IO deps
|
|
6
|
+
* on a real temp state dir: exactly-once (H1), concurrent siblings → two
|
|
7
|
+
* distinct deliveries, text-dedup skip, and delivered-journal suppression.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import {
|
|
16
|
+
decideOutboxSweep,
|
|
17
|
+
deriveTurnNonce,
|
|
18
|
+
resolveOutboxChat,
|
|
19
|
+
writeOutboxRecordAtomic,
|
|
20
|
+
readDeliveredNonces,
|
|
21
|
+
listPendingRecords,
|
|
22
|
+
appendDelivered,
|
|
23
|
+
resolveOutboxDir,
|
|
24
|
+
JOURNAL_ROTATE_AT,
|
|
25
|
+
JOURNAL_KEEP,
|
|
26
|
+
sha256Hex,
|
|
27
|
+
type OutboxRecord,
|
|
28
|
+
OUTBOX_MAX_AGE_MS,
|
|
29
|
+
} from '../outbox.js'
|
|
30
|
+
import { deriveTurnNonce as deriveTurnNonceMjs } from '../hooks/silent-end-scan.mjs'
|
|
31
|
+
import { sweepOutbox, journalExternalDelivery } from '../gateway/outbox-sweep.js'
|
|
32
|
+
import { shouldJournalReplySiteDelivery } from '../final-answer-detect.js'
|
|
33
|
+
|
|
34
|
+
function rec(over: Partial<OutboxRecord>): OutboxRecord {
|
|
35
|
+
const text = over.text ?? 'x'.repeat(300)
|
|
36
|
+
return {
|
|
37
|
+
turnNonce: over.turnNonce ?? 'n1',
|
|
38
|
+
chatId: 'chatId' in over ? (over.chatId ?? null) : '111',
|
|
39
|
+
threadId: over.threadId ?? null,
|
|
40
|
+
text,
|
|
41
|
+
textSha256: over.textSha256 ?? sha256Hex(text),
|
|
42
|
+
createdAt: over.createdAt ?? 0,
|
|
43
|
+
source: over.source ?? 'channel',
|
|
44
|
+
anchorContent: over.anchorContent,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('deriveTurnNonce — .mjs / .ts parity (shared namespace, H1)', () => {
|
|
49
|
+
it('produces byte-identical nonces for the message_id shape', () => {
|
|
50
|
+
const args = { chatId: '111', threadId: 7, messageId: '42', anchorTimestampMs: 5, anchorContent: 'c' }
|
|
51
|
+
expect(deriveTurnNonceMjs(args)).toBe(deriveTurnNonce(args))
|
|
52
|
+
})
|
|
53
|
+
it('produces byte-identical nonces for the content-hash shape', () => {
|
|
54
|
+
const args = { chatId: null, threadId: null, messageId: null, anchorTimestampMs: 5, anchorContent: 'c' }
|
|
55
|
+
expect(deriveTurnNonceMjs(args)).toBe(deriveTurnNonce(args))
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('decideOutboxSweep', () => {
|
|
60
|
+
const base = { deliveredNonces: new Set<string>(), textAlreadyDelivered: false, routable: true }
|
|
61
|
+
it('sends a routable, un-journaled, past-quiet record', () => {
|
|
62
|
+
expect(decideOutboxSweep({ record: rec({}), now: 10_000, ...base }).action).toBe('send')
|
|
63
|
+
})
|
|
64
|
+
it('skips a nonce already in the journal (exactly-once)', () => {
|
|
65
|
+
expect(
|
|
66
|
+
decideOutboxSweep({ record: rec({ turnNonce: 'd' }), now: 10_000, ...base, deliveredNonces: new Set(['d']) }).action,
|
|
67
|
+
).toBe('skip-journaled')
|
|
68
|
+
})
|
|
69
|
+
it('skips inside the quiet period', () => {
|
|
70
|
+
expect(decideOutboxSweep({ record: rec({ createdAt: 9999 }), now: 10_000, ...base }).action).toBe('skip-quiet')
|
|
71
|
+
})
|
|
72
|
+
it('skips on text-dedup', () => {
|
|
73
|
+
expect(decideOutboxSweep({ record: rec({}), now: 10_000, ...base, textAlreadyDelivered: true }).action).toBe(
|
|
74
|
+
'skip-dedup',
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
it('holds an unroutable record rather than dropping', () => {
|
|
78
|
+
expect(decideOutboxSweep({ record: rec({}), now: 10_000, ...base, routable: false }).action).toBe('skip-unroutable')
|
|
79
|
+
})
|
|
80
|
+
it('delivers a beyond-max-age record with a (delayed) prefix, never drops', () => {
|
|
81
|
+
const d = decideOutboxSweep({ record: rec({ text: 'answer' }), now: OUTBOX_MAX_AGE_MS + 10_000, ...base })
|
|
82
|
+
expect(d.action).toBe('send-delayed')
|
|
83
|
+
expect(d.text).toBe('(delayed) answer')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('resolveOutboxChat (H3/F2)', () => {
|
|
88
|
+
it('routes via the anchor envelope when chatId is present', () => {
|
|
89
|
+
const r = resolveOutboxChat(rec({ chatId: '111', threadId: 5 }), {})
|
|
90
|
+
expect(r).toEqual({ chatId: '111', threadId: 5, via: 'anchor' })
|
|
91
|
+
})
|
|
92
|
+
it('routes an envelope-less record via the transitive registry chain (preferred over origin)', () => {
|
|
93
|
+
const r = resolveOutboxChat({ chatId: null, threadId: null, anchorContent: '<task-id>T1</task-id>', originChatId: 'WRONG', originThreadId: null }, {
|
|
94
|
+
registryChainLookup: (c) => (c.includes('T1') ? { chatId: '222', threadId: 9 } : null),
|
|
95
|
+
})
|
|
96
|
+
expect(r).toEqual({ chatId: '222', threadId: 9, via: 'registry' })
|
|
97
|
+
})
|
|
98
|
+
it('falls back to the record OWN per-session origin when the chain does not resolve', () => {
|
|
99
|
+
const r = resolveOutboxChat({ chatId: null, threadId: null, anchorContent: 'no-task-id-here', originChatId: '333', originThreadId: 4 }, {
|
|
100
|
+
registryChainLookup: () => null,
|
|
101
|
+
})
|
|
102
|
+
expect(r).toEqual({ chatId: '333', threadId: 4, via: 'origin' })
|
|
103
|
+
})
|
|
104
|
+
it('FAILS CLOSED (null) when neither the chain nor a stamped origin resolves — never a global fallback', () => {
|
|
105
|
+
expect(resolveOutboxChat({ chatId: null, threadId: null, anchorContent: 'no-task', originChatId: null, originThreadId: null }, { registryChainLookup: () => null })).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('sweepOutbox — end to end (exactly-once, siblings, dedup)', () => {
|
|
110
|
+
let dir: string
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
dir = mkdtempSync(join(tmpdir(), 'outbox-'))
|
|
113
|
+
})
|
|
114
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
115
|
+
|
|
116
|
+
function sink() {
|
|
117
|
+
const sent: Array<{ chatId: string; threadId: number | null; text: string }> = []
|
|
118
|
+
return {
|
|
119
|
+
sent,
|
|
120
|
+
send: async (chatId: string, threadId: number | null, text: string) => {
|
|
121
|
+
sent.push({ chatId, threadId, text })
|
|
122
|
+
return sent.length
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
it('delivers a record exactly once across repeated sweeps (H1)', async () => {
|
|
128
|
+
writeOutboxRecordAtomic(rec({ turnNonce: 'n1', createdAt: 0 }), dir)
|
|
129
|
+
const s = sink()
|
|
130
|
+
const deps = { ...s, textAlreadyDelivered: () => false, stateDir: dir, now: () => 10_000 }
|
|
131
|
+
await sweepOutbox(deps)
|
|
132
|
+
await sweepOutbox(deps) // journal now suppresses
|
|
133
|
+
await sweepOutbox(deps)
|
|
134
|
+
expect(s.sent).toHaveLength(1)
|
|
135
|
+
expect(readDeliveredNonces(dir).has('n1')).toBe(true)
|
|
136
|
+
expect(listPendingRecords(dir)).toHaveLength(0)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('two concurrent sibling handbacks with distinct nonces both deliver, no clobber', async () => {
|
|
140
|
+
// Same content, distinct enqueue timestamps → distinct nonces (H2).
|
|
141
|
+
const n1 = deriveTurnNonce({ chatId: null, threadId: null, messageId: null, anchorTimestampMs: 1000, anchorContent: 'sib' })
|
|
142
|
+
const n2 = deriveTurnNonce({ chatId: null, threadId: null, messageId: null, anchorTimestampMs: 2000, anchorContent: 'sib' })
|
|
143
|
+
expect(n1).not.toBe(n2)
|
|
144
|
+
writeOutboxRecordAtomic(rec({ turnNonce: n1, chatId: '111', text: 'answer one xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' }), dir)
|
|
145
|
+
writeOutboxRecordAtomic(rec({ turnNonce: n2, chatId: '111', text: 'answer two yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy' }), dir)
|
|
146
|
+
const s = sink()
|
|
147
|
+
await sweepOutbox({ ...s, textAlreadyDelivered: () => false, stateDir: dir, now: () => 10_000 })
|
|
148
|
+
expect(s.sent).toHaveLength(2)
|
|
149
|
+
expect(new Set(s.sent.map((x) => x.text)).size).toBe(2)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('F1: on a text-dedup hit, journals the nonce AND deletes the record so it cannot re-fire', async () => {
|
|
153
|
+
// The legacy flush delivered this turn's text; the in-memory dedup reports a
|
|
154
|
+
// hit on the first eligible sweep. The record must be journaled + removed —
|
|
155
|
+
// NOT left pending (the pre-fix bug), which re-sent once the 60s TTL evicted.
|
|
156
|
+
writeOutboxRecordAtomic(rec({ turnNonce: 'n1', text: 'dup', createdAt: 0 }), dir)
|
|
157
|
+
const s = sink()
|
|
158
|
+
await sweepOutbox({ ...s, textAlreadyDelivered: () => true, stateDir: dir, now: () => 10_000 })
|
|
159
|
+
expect(s.sent).toHaveLength(0)
|
|
160
|
+
expect(readDeliveredNonces(dir).has('n1')).toBe(true)
|
|
161
|
+
expect(listPendingRecords(dir)).toHaveLength(0)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('F1: NO second send after the in-memory dedup TTL evicts and the gateway restarts', async () => {
|
|
165
|
+
// Simulate the exact review failure: flush delivers (+0.5s), sweep at +5s
|
|
166
|
+
// hits the dedup and settles the record. Then the process "restarts" (fresh
|
|
167
|
+
// deps, in-memory dedup CLEARED → reports false) and the sweep runs again at
|
|
168
|
+
// +65s. With the pre-fix code the record was still pending and re-sent here.
|
|
169
|
+
writeOutboxRecordAtomic(rec({ turnNonce: 'n1', text: 'dup', createdAt: 0 }), dir)
|
|
170
|
+
const s1 = sink()
|
|
171
|
+
await sweepOutbox({ ...s1, textAlreadyDelivered: () => true, stateDir: dir, now: () => 5_000 })
|
|
172
|
+
expect(s1.sent).toHaveLength(0)
|
|
173
|
+
// Restart: brand-new sink + a dedup that no longer remembers the text.
|
|
174
|
+
const s2 = sink()
|
|
175
|
+
await sweepOutbox({ ...s2, textAlreadyDelivered: () => false, stateDir: dir, now: () => 65_000 })
|
|
176
|
+
expect(s2.sent).toHaveLength(0) // journal (durable) suppresses — no double-post
|
|
177
|
+
expect(listPendingRecords(dir)).toHaveLength(0)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('F3: a PINGING interim ack does NOT poison the turn nonce — the real answer is still delivered exactly once', async () => {
|
|
181
|
+
// The exact F3 loss scenario, end-to-end through the production reply-site
|
|
182
|
+
// journal gate (`shouldJournalReplySiteDelivery`) + the sweep:
|
|
183
|
+
// 1. DM inbound → turn nonce N.
|
|
184
|
+
// 2. Model calls reply("On it — digging in") WITHOUT disable_notification
|
|
185
|
+
// (a PINGING interim ack — the tool's default; models routinely omit
|
|
186
|
+
// the flag). `isFinalAnswerReply` would classify this "final" (ping
|
|
187
|
+
// clause), so the pre-fix reply site journaled N here.
|
|
188
|
+
// 3. Model ends the turn with gateway-invisible trailing prose (the real
|
|
189
|
+
// answer) → the Stop hook captures it as a pending outbox record under
|
|
190
|
+
// the SAME nonce N.
|
|
191
|
+
// 4. The sweep must DELIVER the real answer.
|
|
192
|
+
// Pre-fix (gate === isFinalAnswerReply) the ack journaled N → the sweep hits
|
|
193
|
+
// skip-journaled → clearOutboxRecord → 0 sent → the real answer is silently
|
|
194
|
+
// destroyed (RED). Post-fix (gate === isSubstantiveFinalReply) the pinging
|
|
195
|
+
// ack does NOT journal → the sweep delivers the real answer exactly once.
|
|
196
|
+
const nonce = deriveTurnNonce({ chatId: '8', threadId: null, messageId: '5', anchorTimestampMs: 1, anchorContent: 'c' })
|
|
197
|
+
const pingingAck = { text: 'On it — digging in', disableNotification: false }
|
|
198
|
+
// Model the reply site EXACTLY: journal via journalExternalDelivery iff the
|
|
199
|
+
// production gate says to (same call shape as outbound-send-path.ts sendReply).
|
|
200
|
+
if (shouldJournalReplySiteDelivery(pingingAck)) {
|
|
201
|
+
journalExternalDelivery({ turnNonce: nonce, text: pingingAck.text }, dir)
|
|
202
|
+
}
|
|
203
|
+
// The Stop hook captured the real (undelivered) answer under the same nonce.
|
|
204
|
+
const realAnswer = 'The root cause is a race in the flush path — here is the full write-up. '.repeat(4)
|
|
205
|
+
writeOutboxRecordAtomic(rec({ turnNonce: nonce, chatId: '8', text: realAnswer, createdAt: 0 }), dir)
|
|
206
|
+
const s = sink()
|
|
207
|
+
await sweepOutbox({ ...s, textAlreadyDelivered: () => false, stateDir: dir, now: () => 10_000 })
|
|
208
|
+
expect(s.sent).toHaveLength(1)
|
|
209
|
+
expect(s.sent[0].text).toBe(realAnswer)
|
|
210
|
+
expect(listPendingRecords(dir)).toHaveLength(0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('F3: a SUBSTANTIVE final reply STILL journals so the sweep does not double-post the same answer', async () => {
|
|
214
|
+
// The counterpart guard — the fix must not reopen F1. A genuine substantive
|
|
215
|
+
// answer delivered at the reply site journals the nonce, so a captured
|
|
216
|
+
// record for the same turn is suppressed (no duplicate).
|
|
217
|
+
const nonce = deriveTurnNonce({ chatId: '8', threadId: null, messageId: '6', anchorTimestampMs: 1, anchorContent: 'c' })
|
|
218
|
+
const realReply = { text: 'x'.repeat(300), disableNotification: false }
|
|
219
|
+
expect(shouldJournalReplySiteDelivery(realReply)).toBe(true)
|
|
220
|
+
if (shouldJournalReplySiteDelivery(realReply)) {
|
|
221
|
+
journalExternalDelivery({ turnNonce: nonce, text: realReply.text }, dir)
|
|
222
|
+
}
|
|
223
|
+
writeOutboxRecordAtomic(rec({ turnNonce: nonce, chatId: '8', text: realReply.text, createdAt: 0 }), dir)
|
|
224
|
+
const s = sink()
|
|
225
|
+
await sweepOutbox({ ...s, textAlreadyDelivered: () => false, stateDir: dir, now: () => 10_000 })
|
|
226
|
+
expect(s.sent).toHaveLength(0) // journaled → sweep suppresses → no double-post
|
|
227
|
+
expect(listPendingRecords(dir)).toHaveLength(0)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('routes an envelope-less record through its OWN per-session origin with a prefix', async () => {
|
|
231
|
+
writeOutboxRecordAtomic({ ...rec({ turnNonce: 'nx', chatId: null, text: 'z'.repeat(300), anchorContent: 'no-task' }), originChatId: '888', originThreadId: null }, dir)
|
|
232
|
+
const s = sink()
|
|
233
|
+
await sweepOutbox({ ...s, textAlreadyDelivered: () => false, stateDir: dir, now: () => 10_000 })
|
|
234
|
+
expect(s.sent).toHaveLength(1)
|
|
235
|
+
expect(s.sent[0].chatId).toBe('888')
|
|
236
|
+
expect(s.sent[0].text.startsWith('(from background task) ')).toBe(true)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('F2: a DM-origin handback whose registry chain fails does NOT deliver to a different (group) chat', async () => {
|
|
240
|
+
// Record stamped with the DM origin (777). A group chat (999) is the most
|
|
241
|
+
// recent gateway inbound — but the sweep has NO global last-inbound fallback,
|
|
242
|
+
// so it must route to the DM origin, never the group.
|
|
243
|
+
writeOutboxRecordAtomic({ ...rec({ turnNonce: 'dm', chatId: null, text: 'private answer '.repeat(20), anchorContent: '<task-id>MISS</task-id>' }), originChatId: '777', originThreadId: null }, dir)
|
|
244
|
+
const s = sink()
|
|
245
|
+
await sweepOutbox({
|
|
246
|
+
...s,
|
|
247
|
+
textAlreadyDelivered: () => false,
|
|
248
|
+
registryChainLookup: () => null, // chain fails
|
|
249
|
+
stateDir: dir,
|
|
250
|
+
now: () => 10_000,
|
|
251
|
+
})
|
|
252
|
+
expect(s.sent).toHaveLength(1)
|
|
253
|
+
expect(s.sent[0].chatId).toBe('777') // DM origin, NOT any group
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('F2: an envelope-less handback with NO stamped origin and a failed chain FAILS CLOSED (held, not misrouted)', async () => {
|
|
257
|
+
writeOutboxRecordAtomic(rec({ turnNonce: 'held', chatId: null, anchorContent: 'no-task' }), dir)
|
|
258
|
+
const s = sink()
|
|
259
|
+
await sweepOutbox({ ...s, textAlreadyDelivered: () => false, registryChainLookup: () => null, stateDir: dir, now: () => 10_000 })
|
|
260
|
+
expect(s.sent).toHaveLength(0)
|
|
261
|
+
expect(listPendingRecords(dir)).toContain('held.json')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('S5: the delivered-keys journal is compacted once it exceeds the rotate threshold (bounded growth)', () => {
|
|
265
|
+
for (let i = 0; i < JOURNAL_ROTATE_AT + 5; i++) {
|
|
266
|
+
appendDelivered({ turnNonce: `k${i}`, textSha256: 'h', ts: i }, dir)
|
|
267
|
+
}
|
|
268
|
+
const lines = readFileSync(join(resolveOutboxDir(dir), 'delivered.jsonl'), 'utf8')
|
|
269
|
+
.split('\n')
|
|
270
|
+
.filter((l) => l.length > 0)
|
|
271
|
+
// Bounded: compaction keeps the file under the rotate threshold rather than
|
|
272
|
+
// growing without limit (it drops to ~JOURNAL_KEEP each time it trips).
|
|
273
|
+
expect(lines.length).toBeLessThanOrEqual(JOURNAL_ROTATE_AT)
|
|
274
|
+
expect(lines.length).toBeLessThan(JOURNAL_KEEP + 100)
|
|
275
|
+
// The most-recent nonces survive compaction (still suppress a re-send).
|
|
276
|
+
expect(readDeliveredNonces(dir).has(`k${JOURNAL_ROTATE_AT + 4}`)).toBe(true)
|
|
277
|
+
})
|
|
278
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbox-hook-capture.test.ts — behavioural RED replay of the 2026-07-22
|
|
3
|
+
* incident at the real Stop-hook boundary. Spawns
|
|
4
|
+
* `hooks/silent-end-interrupt-stop.mjs` as a subprocess with a synthetic
|
|
5
|
+
* task-notification-shaped transcript that ends with a 1,647-char plain-text
|
|
6
|
+
* final answer and no reply tool call — the exact shape whose answer was
|
|
7
|
+
* silently lost — and asserts the hook writes exactly one durable outbox record
|
|
8
|
+
* to `$TELEGRAM_STATE_DIR/outbox/` and allows the stop.
|
|
9
|
+
*
|
|
10
|
+
* Against pre-fix code the hook wrote NO outbox record (the mechanism did not
|
|
11
|
+
* exist) → this test's record-count assertion fails RED. Post-fix → green.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { mkdtempSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { join, resolve } from 'node:path'
|
|
19
|
+
|
|
20
|
+
const HOOK = resolve(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
|
|
21
|
+
|
|
22
|
+
function runHook(event: object, stateDir: string, extraEnv: Record<string, string> = {}) {
|
|
23
|
+
return spawnSync('node', [HOOK], {
|
|
24
|
+
input: JSON.stringify(event),
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
timeout: 5000,
|
|
27
|
+
env: { ...process.env, TELEGRAM_STATE_DIR: stateDir, ...extraEnv },
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function transcript(dir: string, lines: object[]): string {
|
|
32
|
+
const p = join(dir, 'transcript.jsonl')
|
|
33
|
+
writeFileSync(p, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
|
|
34
|
+
return p
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function outboxRecords(dir: string): object[] {
|
|
38
|
+
const outbox = join(dir, 'outbox')
|
|
39
|
+
let files: string[]
|
|
40
|
+
try {
|
|
41
|
+
files = readdirSync(outbox).filter((f) => f.endsWith('.json'))
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
return files.map((f) => JSON.parse(readFileSync(join(outbox, f), 'utf8')))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('Stop hook — outbox capture (incident replay)', () => {
|
|
49
|
+
let dir: string
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
dir = mkdtempSync(join(tmpdir(), 'hook-outbox-'))
|
|
52
|
+
})
|
|
53
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
54
|
+
|
|
55
|
+
const answer = 'B'.repeat(1647)
|
|
56
|
+
|
|
57
|
+
it('captures the lost final answer of a task-notification turn to exactly one record', () => {
|
|
58
|
+
const t = transcript(dir, [
|
|
59
|
+
{
|
|
60
|
+
type: 'queue-operation',
|
|
61
|
+
operation: 'enqueue',
|
|
62
|
+
content: '<task-notification><task-id>a7cc7a0fa8f</task-id> worker done</task-notification>',
|
|
63
|
+
timestamp: 1000,
|
|
64
|
+
},
|
|
65
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: answer }] } },
|
|
66
|
+
])
|
|
67
|
+
const r = runHook({ session_id: 's', transcript_path: t }, dir)
|
|
68
|
+
expect(r.status).toBe(0)
|
|
69
|
+
const records = outboxRecords(dir) as Array<{ text: string; chatId: string | null; source: string }>
|
|
70
|
+
expect(records).toHaveLength(1)
|
|
71
|
+
expect(records[0].text).toBe(answer)
|
|
72
|
+
expect(records[0].chatId).toBeNull()
|
|
73
|
+
expect(records[0].source).toBe('task-notification')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('writes NO record for a pure NO_REPLY turn', () => {
|
|
77
|
+
const t = transcript(dir, [
|
|
78
|
+
{ type: 'queue-operation', operation: 'enqueue', content: '<channel source="telegram" chat_id="1" message_id="2">hi</channel>' },
|
|
79
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'NO_REPLY' }] } },
|
|
80
|
+
])
|
|
81
|
+
const r = runHook({ session_id: 's', transcript_path: t }, dir)
|
|
82
|
+
expect(r.status).toBe(0)
|
|
83
|
+
expect(outboxRecords(dir)).toHaveLength(0)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('kill-switch symmetry: SWITCHROOM_TG_OUTBOX_DELIVERY=0 disables capture too (no orphaned records)', () => {
|
|
87
|
+
// With delivery off, capture MUST also be off — otherwise the hook would
|
|
88
|
+
// write records that the (disabled) sweep never delivers = silent loss.
|
|
89
|
+
const t = transcript(dir, [
|
|
90
|
+
{
|
|
91
|
+
type: 'queue-operation',
|
|
92
|
+
operation: 'enqueue',
|
|
93
|
+
content: '<task-notification><task-id>a7cc7a0fa8f</task-id> worker done</task-notification>',
|
|
94
|
+
timestamp: 1000,
|
|
95
|
+
},
|
|
96
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: answer }] } },
|
|
97
|
+
])
|
|
98
|
+
const r = runHook({ session_id: 's', transcript_path: t }, dir, { SWITCHROOM_TG_OUTBOX_DELIVERY: '0' })
|
|
99
|
+
expect(r.status).toBe(0)
|
|
100
|
+
expect(outboxRecords(dir)).toHaveLength(0) // no capture ⇒ no orphaned record
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('capture is idempotent — a second hook run does not write a duplicate', () => {
|
|
104
|
+
const t = transcript(dir, [
|
|
105
|
+
{ type: 'queue-operation', operation: 'enqueue', content: '<channel source="telegram" chat_id="9" message_id="3">q</channel>', timestamp: 1000 },
|
|
106
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: answer }] } },
|
|
107
|
+
])
|
|
108
|
+
runHook({ session_id: 's', transcript_path: t }, dir)
|
|
109
|
+
runHook({ session_id: 's', transcript_path: t }, dir)
|
|
110
|
+
expect(outboxRecords(dir)).toHaveLength(1)
|
|
111
|
+
})
|
|
112
|
+
})
|