switchroom 0.13.35 → 0.13.37

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.
@@ -407,4 +407,138 @@ describe('pending-work-progress', () => {
407
407
  expect(cap.edits.filter((e) => e.messageId === 10)).toHaveLength(1)
408
408
  expect(cap.edits.filter((e) => e.messageId === 20)).toHaveLength(2)
409
409
  })
410
+
411
+ // ─── #1760 regression tests ───────────────────────────────────────────
412
+ //
413
+ // The "— still working (Nm)" ticker can get stuck forever editing an
414
+ // old outbound message if the gateway misses the SDK `turn_end` event.
415
+ // Two layers of defence:
416
+ //
417
+ // 1. PRIMARY: the gateway tears down on every `reply: finalized`
418
+ // chokepoint via `clearPending(key, 'reply_finalize')` BEFORE
419
+ // `noteOutbound` on the next turn's first reply. Verified here by
420
+ // simulating a missed-turn_end scenario: the prior turn's ticker
421
+ // is activated, then the gateway processes a fresh reply on a NEW
422
+ // turn without ever calling `noteTurnEnd` for the prior one. The
423
+ // explicit `clearPending('reply_finalize')` call must wipe the
424
+ // stale ambient.
425
+ //
426
+ // 2. DEFENSE-IN-DEPTH: at tick time, if `isActiveTurnNewerThan`
427
+ // returns true (gateway reports a newer turn is active for this
428
+ // chat), the ticker self-terminates instead of editing. Bug
429
+ // becomes "at most one stale tick" rather than "stuck forever."
430
+
431
+ it('#1760 primary: reply_finalize teardown wipes a stale activated ticker', async () => {
432
+ const cap = setup()
433
+
434
+ // Turn 1: dispatch async work, capture an anchor, end the turn.
435
+ // Ticker activates and fires one edit at +60s.
436
+ startTurn(KEY)
437
+ noteAsyncDispatch(KEY)
438
+ noteOutbound(KEY, { messageId: 100, text: 'kicking off worker' })
439
+ noteTurnEnd(KEY)
440
+ cap.now = EDIT_INTERVAL_MS
441
+ __tickForTests(cap.now)
442
+ await flush()
443
+ expect(cap.edits).toHaveLength(1)
444
+ expect(cap.edits[0]?.messageId).toBe(100)
445
+
446
+ // Turn 2 begins WITHOUT a prior `noteTurnEnd` clear (simulating the
447
+ // #1760 missed-turn_end SDK-event-drop). The gateway's reply-
448
+ // finalize chokepoint MUST call clearPending('reply_finalize')
449
+ // BEFORE noteOutbound on the new anchor. After that, the prior
450
+ // ticker is gone and further ticks (even before any new
451
+ // noteTurnEnd) edit nothing.
452
+ clearPending(KEY, 'reply_finalize')
453
+ noteOutbound(KEY, { messageId: 200, text: 'turn 2 reply' })
454
+
455
+ cap.now = EDIT_INTERVAL_MS * 5
456
+ __tickForTests(cap.now)
457
+ await flush()
458
+ // No additional edits — the stale ticker is dead. Note that the new
459
+ // turn's ticker has not been activated yet (noteTurnEnd not called),
460
+ // so nothing should fire here either.
461
+ expect(cap.edits).toHaveLength(1)
462
+
463
+ // The 'reply_finalize' clear must surface as a metric so operators
464
+ // can observe the backstop firing in production.
465
+ const reasons = cap.metrics
466
+ .filter((m): m is Extract<PendingProgressMetric, { kind: 'pending_progress_cleared' }> =>
467
+ m.kind === 'pending_progress_cleared')
468
+ .map((m) => m.reason)
469
+ expect(reasons).toContain('reply_finalize')
470
+ })
471
+
472
+ it('#1760 defense-in-depth: ticker self-terminates when isActiveTurnNewerThan is true', async () => {
473
+ const cap: Capture = { edits: [], metrics: [], now: 0 }
474
+ __resetAllForTests()
475
+ // The activatedAt epoch captured by the ticker:
476
+ const TURN_1_ACTIVATED_AT = 1_000
477
+ // A NEWER turn starts later, simulating turn-2 racing past the
478
+ // missed teardown:
479
+ const TURN_2_STARTED_AT = TURN_1_ACTIVATED_AT + 30_000
480
+
481
+ __setDepsForTests({
482
+ editMessage: async (ctx) => {
483
+ cap.edits.push(ctx)
484
+ },
485
+ emitMetric: (e) => {
486
+ cap.metrics.push(e)
487
+ },
488
+ nowMs: () => cap.now,
489
+ // Reports a newer turn always-on for this test.
490
+ isActiveTurnNewerThan: (_key, activatedAt) =>
491
+ TURN_2_STARTED_AT > activatedAt,
492
+ })
493
+
494
+ // Bootstrap a "prior turn" ticker at TURN_1_ACTIVATED_AT.
495
+ cap.now = TURN_1_ACTIVATED_AT
496
+ startTurn(KEY)
497
+ noteAsyncDispatch(KEY)
498
+ noteOutbound(KEY, { messageId: 100, text: 'kicking off worker' })
499
+ noteTurnEnd(KEY)
500
+ expect(__getStateForTests(KEY)?.activatedAt).toBe(TURN_1_ACTIVATED_AT)
501
+
502
+ // Advance past EDIT_INTERVAL_MS so the tick would otherwise fire.
503
+ cap.now = TURN_1_ACTIVATED_AT + EDIT_INTERVAL_MS + 1_000
504
+ __tickForTests(cap.now)
505
+ await flush()
506
+
507
+ // No edit fired — the predicate detected a newer active turn and
508
+ // dropped the ticker.
509
+ expect(cap.edits).toHaveLength(0)
510
+ expect(__getStateForTests(KEY)).toBeUndefined()
511
+
512
+ const cleared = cap.metrics.find(
513
+ (m): m is Extract<PendingProgressMetric, { kind: 'pending_progress_cleared' }> =>
514
+ m.kind === 'pending_progress_cleared',
515
+ )
516
+ expect(cleared?.reason).toBe('stale_turn')
517
+ })
518
+
519
+ it('#1760 defense-in-depth: predicate returning false leaves ticker alone', async () => {
520
+ const cap: Capture = { edits: [], metrics: [], now: 0 }
521
+ __resetAllForTests()
522
+ __setDepsForTests({
523
+ editMessage: async (ctx) => {
524
+ cap.edits.push(ctx)
525
+ },
526
+ emitMetric: (e) => {
527
+ cap.metrics.push(e)
528
+ },
529
+ nowMs: () => cap.now,
530
+ // No newer turn — the legitimate cross-turn ambient case.
531
+ isActiveTurnNewerThan: () => false,
532
+ })
533
+
534
+ startTurn(KEY)
535
+ noteAsyncDispatch(KEY)
536
+ noteOutbound(KEY, { messageId: 100, text: 'kicking off worker' })
537
+ noteTurnEnd(KEY)
538
+
539
+ cap.now = EDIT_INTERVAL_MS
540
+ __tickForTests(cap.now)
541
+ await flush()
542
+ expect(cap.edits).toHaveLength(1)
543
+ })
410
544
  })
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Integration test for the silent-end Stop hook .mjs.
3
+ *
4
+ * Spawns the real script as a subprocess with a synthetic transcript
5
+ * on disk and the Stop event JSON on stdin. Pins the contract that
6
+ * matters at the hook boundary: stdout JSON shape, exit code,
7
+ * retry-count side-effect on the state file.
8
+ *
9
+ * Complements `silent-end-interrupt-stop-scan.test.ts` (which pins
10
+ * the pure helper in isolation). This test guards against
11
+ * regressions in:
12
+ * - stdin parsing
13
+ * - transcript_path file IO + fail-open on read errors
14
+ * - state-file retry-count increment
15
+ * - retry-budget exhaustion → allow
16
+ * - block-decision stdout shape
17
+ */
18
+
19
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
20
+ import { spawnSync } from 'node:child_process'
21
+ import {
22
+ mkdtempSync,
23
+ mkdirSync,
24
+ writeFileSync,
25
+ readFileSync,
26
+ existsSync,
27
+ rmSync,
28
+ } from 'node:fs'
29
+ import { tmpdir } from 'node:os'
30
+ import { join, resolve } from 'node:path'
31
+
32
+ const HOOK_PATH = resolve(
33
+ __dirname,
34
+ '..',
35
+ 'hooks',
36
+ 'silent-end-interrupt-stop.mjs',
37
+ )
38
+
39
+ interface RunResult {
40
+ status: number | null
41
+ stdout: string
42
+ stderr: string
43
+ }
44
+
45
+ function runHook(input: { event: object; stateDir: string }): RunResult {
46
+ const r = spawnSync('node', [HOOK_PATH], {
47
+ input: JSON.stringify(input.event),
48
+ encoding: 'utf8',
49
+ timeout: 5000,
50
+ env: { ...process.env, TELEGRAM_STATE_DIR: input.stateDir },
51
+ })
52
+ return { status: r.status, stdout: r.stdout, stderr: r.stderr }
53
+ }
54
+
55
+ function writeTranscript(dir: string, lines: object[]): string {
56
+ const p = join(dir, 'transcript.jsonl')
57
+ writeFileSync(p, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
58
+ return p
59
+ }
60
+
61
+ const ENQUEUE = {
62
+ type: 'queue-operation',
63
+ operation: 'enqueue',
64
+ content: '<channel source="switchroom-telegram" chat_id="111" message_id="42">hi</channel>',
65
+ }
66
+
67
+ function reply(text: string, opts: { disable_notification?: boolean; done?: boolean } = {}) {
68
+ return {
69
+ type: 'assistant',
70
+ message: {
71
+ content: [
72
+ {
73
+ type: 'tool_use',
74
+ name: 'mcp__switchroom-telegram__reply',
75
+ input: { text, ...opts },
76
+ },
77
+ ],
78
+ },
79
+ }
80
+ }
81
+
82
+ describe('silent-end-interrupt-stop.mjs — integration', () => {
83
+ let tmp: string
84
+ let stateDir: string
85
+
86
+ beforeEach(() => {
87
+ tmp = mkdtempSync(join(tmpdir(), 'silent-end-hook-'))
88
+ stateDir = join(tmp, 'state')
89
+ mkdirSync(stateDir, { recursive: true })
90
+ })
91
+
92
+ afterEach(() => {
93
+ try { rmSync(tmp, { recursive: true, force: true }) } catch { /* ignore */ }
94
+ })
95
+
96
+ it('allows stop when transcript shows a notification-bearing reply', () => {
97
+ const transcript = writeTranscript(tmp, [
98
+ ENQUEUE,
99
+ reply('ok', { disable_notification: false }),
100
+ ])
101
+ const r = runHook({
102
+ event: { session_id: 's1', transcript_path: transcript },
103
+ stateDir,
104
+ })
105
+ expect(r.status).toBe(0)
106
+ expect(r.stdout.trim()).toBe('')
107
+ expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(false)
108
+ })
109
+
110
+ it("blocks + writes retryCount=1 when transcript shows ack-only (Ken's repro)", () => {
111
+ const transcript = writeTranscript(tmp, [
112
+ ENQUEUE,
113
+ reply('on it — checking now', { disable_notification: true }),
114
+ { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] } },
115
+ // 2237-char answer as plain text, no reply tool
116
+ {
117
+ type: 'assistant',
118
+ message: { content: [{ type: 'text', text: 'A'.repeat(2237) }] },
119
+ },
120
+ ])
121
+ const r = runHook({
122
+ event: { session_id: 's1', transcript_path: transcript },
123
+ stateDir,
124
+ })
125
+ expect(r.status).toBe(0)
126
+ const out = JSON.parse(r.stdout)
127
+ expect(out.decision).toBe('block')
128
+ expect(out.reason).toMatch(/Send your final answer/)
129
+ expect(out.reason).toMatch(/NO_REPLY/)
130
+ // Retry-count file was written.
131
+ const statePath = join(stateDir, 'silent-end-pending.json')
132
+ expect(existsSync(statePath)).toBe(true)
133
+ const state = JSON.parse(readFileSync(statePath, 'utf8'))
134
+ expect(state.retryCount).toBe(1)
135
+ // Reviewer-flagged regression: the hook's state-file write MUST
136
+ // include turnKey + chatId derived from the enqueue envelope. Without
137
+ // these, the gateway's later `recordSilentTurnEnd` write (~175ms after
138
+ // the hook) sees a turnKey mismatch and resets retryCount to 0,
139
+ // doubling the effective re-prompt budget. The shape here must match
140
+ // `chatKey(chatId, threadId)` at telegram-plugin/gateway/chat-key.ts:46.
141
+ expect(state.chatId).toBe('111')
142
+ expect(state.turnKey).toBe('111:_')
143
+ })
144
+
145
+ it('preserves retryCount across the hook→gateway write order (reviewer regression)', () => {
146
+ // Simulates what happens on the gateway side once it runs its own
147
+ // `writeSilentEndState` ~175ms after the hook: it reads the hook's
148
+ // file, sees matching turnKey, preserves retryCount. Then the next
149
+ // `recordSilentTurnEnd` call sees retryCount=1 >= MAX_RETRIES=1 and
150
+ // returns exhausted — the design budget. Without matching turnKey
151
+ // this branch never fires on time and the budget doubles.
152
+ const transcript = writeTranscript(tmp, [
153
+ ENQUEUE,
154
+ reply('on it', { disable_notification: true }),
155
+ ])
156
+ const r = runHook({
157
+ event: { session_id: 's1', transcript_path: transcript },
158
+ stateDir,
159
+ })
160
+ expect(r.status).toBe(0)
161
+ const statePath = join(stateDir, 'silent-end-pending.json')
162
+ const state = JSON.parse(readFileSync(statePath, 'utf8'))
163
+ expect(state.turnKey).toBe('111:_')
164
+ expect(state.retryCount).toBe(1)
165
+ })
166
+
167
+ it('allows stop when retry budget already exhausted (retryCount >= MAX_RETRIES)', () => {
168
+ const transcript = writeTranscript(tmp, [
169
+ ENQUEUE,
170
+ // Still no final reply, BUT retry already spent — gateway will
171
+ // post the user-facing fallback so the user isn't left silent.
172
+ reply('ack', { disable_notification: true }),
173
+ ])
174
+ const statePath = join(stateDir, 'silent-end-pending.json')
175
+ writeFileSync(statePath, JSON.stringify({ retryCount: 1, chatId: '111' }), 'utf8')
176
+
177
+ const r = runHook({
178
+ event: { session_id: 's1', transcript_path: transcript },
179
+ stateDir,
180
+ })
181
+ expect(r.status).toBe(0)
182
+ expect(r.stdout.trim()).toBe('')
183
+ expect(r.stderr).toMatch(/retry exhausted/)
184
+ // State unchanged.
185
+ const state = JSON.parse(readFileSync(statePath, 'utf8'))
186
+ expect(state.retryCount).toBe(1)
187
+ })
188
+
189
+ it('NO_REPLY in transcript → allow stop, no state file written', () => {
190
+ const transcript = writeTranscript(tmp, [
191
+ ENQUEUE,
192
+ reply('NO_REPLY'),
193
+ ])
194
+ const r = runHook({
195
+ event: { session_id: 's1', transcript_path: transcript },
196
+ stateDir,
197
+ })
198
+ expect(r.status).toBe(0)
199
+ expect(r.stdout.trim()).toBe('')
200
+ expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(false)
201
+ })
202
+
203
+ it('fail-open when transcript_path missing from event', () => {
204
+ const r = runHook({
205
+ event: { session_id: 's1' },
206
+ stateDir,
207
+ })
208
+ expect(r.status).toBe(0)
209
+ expect(r.stdout.trim()).toBe('')
210
+ })
211
+
212
+ it('fail-open when transcript_path does not exist on disk', () => {
213
+ const r = runHook({
214
+ event: { session_id: 's1', transcript_path: '/does/not/exist.jsonl' },
215
+ stateDir,
216
+ })
217
+ expect(r.status).toBe(0)
218
+ expect(r.stdout.trim()).toBe('')
219
+ })
220
+
221
+ it('fail-open on malformed stdin', () => {
222
+ const r = spawnSync('node', [HOOK_PATH], {
223
+ input: 'this is not JSON',
224
+ encoding: 'utf8',
225
+ timeout: 5000,
226
+ env: { ...process.env, TELEGRAM_STATE_DIR: stateDir },
227
+ })
228
+ expect(r.status).toBe(0)
229
+ expect(r.stdout.trim()).toBe('')
230
+ })
231
+
232
+ it('empty stdin → exit 0 immediately', () => {
233
+ const r = spawnSync('node', [HOOK_PATH], {
234
+ input: '',
235
+ encoding: 'utf8',
236
+ timeout: 5000,
237
+ env: { ...process.env, TELEGRAM_STATE_DIR: stateDir },
238
+ })
239
+ expect(r.status).toBe(0)
240
+ expect(r.stdout.trim()).toBe('')
241
+ })
242
+ })
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Regression guard for #1775 — the deterministic transcript-scan
3
+ * replacement of the silent-end Stop hook's signal source.
4
+ *
5
+ * Pre-fix the hook depended on a gateway-written state file as the
6
+ * block/allow signal. The state file was always written ~175ms AFTER
7
+ * the hook fired (live evidence on clerk 2026-05-25, 12 correlated
8
+ * samples), so the hook never saw its own turn's signal.
9
+ *
10
+ * Post-fix the hook reads `transcript_path` directly and scans the
11
+ * just-finished turn's tool_use entries for a qualifying reply. This
12
+ * test suite pins every branch of the new scan logic — the helper is
13
+ * a pure function (`scanTurnForFinalReply`), so we exercise it with
14
+ * synthetic JSONL fixtures rather than spawning the .mjs subprocess.
15
+ *
16
+ * Each fixture mimics the shapes the live Claude Code transcripts
17
+ * use (verified against clerk's
18
+ * `/state/agent/.claude/projects/.../{session}.jsonl` 2026-05-25).
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest'
22
+ import {
23
+ scanTurnForFinalReply,
24
+ isFinalAnswerReply,
25
+ } from '../hooks/silent-end-scan.mjs'
26
+
27
+ // ── Fixture builders ────────────────────────────────────────────────
28
+
29
+ const ENQUEUE = JSON.stringify({
30
+ type: 'queue-operation',
31
+ operation: 'enqueue',
32
+ content: '<channel source="switchroom-telegram" chat_id="111" message_id="42">hi</channel>',
33
+ })
34
+
35
+ function assistantToolUse(name: string, input: Record<string, unknown>, opts: { isSidechain?: boolean } = {}) {
36
+ const base = {
37
+ type: 'assistant',
38
+ message: { content: [{ type: 'tool_use', name, input }] },
39
+ }
40
+ if (opts.isSidechain) (base as Record<string, unknown>).isSidechain = true
41
+ return JSON.stringify(base)
42
+ }
43
+
44
+ function assistantText(text: string) {
45
+ return JSON.stringify({
46
+ type: 'assistant',
47
+ message: { content: [{ type: 'text', text }] },
48
+ })
49
+ }
50
+
51
+ function jsonl(...lines: string[]) {
52
+ return lines.join('\n')
53
+ }
54
+
55
+ // ── isFinalAnswerReply parity with TS ───────────────────────────────
56
+
57
+ describe('isFinalAnswerReply (parity with final-answer-detect.ts)', () => {
58
+ it('done:true → final answer regardless of length/notification', () => {
59
+ expect(isFinalAnswerReply({ text: '', disableNotification: true, done: true })).toBe(true)
60
+ })
61
+
62
+ it('disable_notification:false → final answer (the notification-bearing case)', () => {
63
+ expect(isFinalAnswerReply({ text: 'ok', disableNotification: false })).toBe(true)
64
+ })
65
+
66
+ it('length ≥ 200 + disable_notification:true → final answer (substantive backstop)', () => {
67
+ expect(isFinalAnswerReply({ text: 'a'.repeat(200), disableNotification: true })).toBe(true)
68
+ })
69
+
70
+ it('length 199 + disable_notification:true → interim ack', () => {
71
+ expect(isFinalAnswerReply({ text: 'a'.repeat(199), disableNotification: true })).toBe(false)
72
+ })
73
+ })
74
+
75
+ // ── scanTurnForFinalReply branches ──────────────────────────────────
76
+
77
+ describe('scanTurnForFinalReply — turn-start anchor', () => {
78
+ it('empty transcript → unknown (caller must fail-open)', () => {
79
+ const r = scanTurnForFinalReply('')
80
+ expect(r.decided).toBe('unknown')
81
+ })
82
+
83
+ it('no enqueue line in transcript → unknown', () => {
84
+ const text = jsonl(
85
+ assistantText('hello'),
86
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
87
+ )
88
+ const r = scanTurnForFinalReply(text)
89
+ expect(r.decided).toBe('unknown')
90
+ expect(r.reason).toBe('no-turn-start')
91
+ })
92
+
93
+ it('multiple enqueues → anchors on the LAST one (queued mid-turn semantics)', () => {
94
+ // First inbound got a long reply BEFORE the second inbound was
95
+ // queued. Scanning anchors on the last enqueue, so the early
96
+ // long reply does NOT count.
97
+ const text = jsonl(
98
+ ENQUEUE,
99
+ assistantToolUse('mcp__switchroom-telegram__reply', {
100
+ text: 'a'.repeat(500),
101
+ disable_notification: false,
102
+ }),
103
+ ENQUEUE, // second queued inbound
104
+ assistantToolUse('mcp__switchroom-telegram__reply', {
105
+ text: 'ack',
106
+ disable_notification: true,
107
+ }),
108
+ )
109
+ const r = scanTurnForFinalReply(text)
110
+ expect(r.decided).toBe('block')
111
+ })
112
+ })
113
+
114
+ describe('scanTurnForFinalReply — final-reply detection', () => {
115
+ it('Ken-2026-05-25 repro: ack + plain text answer → block', () => {
116
+ // The exact shape from clerk's msg 12227 slip.
117
+ const text = jsonl(
118
+ ENQUEUE,
119
+ assistantToolUse('mcp__switchroom-telegram__reply', {
120
+ text: "On it — checking the Bloomfield statement, then I'll lay out…",
121
+ disable_notification: true,
122
+ }),
123
+ assistantToolUse('Bash', { command: 'ls' }),
124
+ assistantToolUse('Read', { file_path: '/tmp/x' }),
125
+ assistantText('That was actually your FY25 NOA, not Bloomfield. ' + 'A'.repeat(2200)),
126
+ )
127
+ const r = scanTurnForFinalReply(text)
128
+ expect(r.decided).toBe('block')
129
+ expect(r.reason).toBe('no-final-reply')
130
+ })
131
+
132
+ it('notification-bearing reply → allow', () => {
133
+ const text = jsonl(
134
+ ENQUEUE,
135
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
136
+ )
137
+ expect(scanTurnForFinalReply(text).decided).toBe('allow')
138
+ })
139
+
140
+ it('stream_reply done:true → allow even with empty text', () => {
141
+ const text = jsonl(
142
+ ENQUEUE,
143
+ assistantToolUse('mcp__switchroom-telegram__stream_reply', {
144
+ text: '',
145
+ done: true,
146
+ disable_notification: true,
147
+ }),
148
+ )
149
+ const r = scanTurnForFinalReply(text)
150
+ expect(r.decided).toBe('allow')
151
+ expect(r.reason).toBe('final-reply')
152
+ })
153
+
154
+ it('long reply mis-marked disable_notification:true → still allow (≥200 chars backstop)', () => {
155
+ const text = jsonl(
156
+ ENQUEUE,
157
+ assistantToolUse('mcp__switchroom-telegram__reply', {
158
+ text: 'B'.repeat(500),
159
+ disable_notification: true,
160
+ }),
161
+ )
162
+ expect(scanTurnForFinalReply(text).decided).toBe('allow')
163
+ })
164
+
165
+ it('short ack followed by long reply → allow (later qualifies)', () => {
166
+ const text = jsonl(
167
+ ENQUEUE,
168
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'on it', disable_notification: true }),
169
+ assistantToolUse('Bash', { command: 'ls' }),
170
+ assistantToolUse('mcp__switchroom-telegram__reply', {
171
+ text: 'Here is the full answer with notification ' + 'C'.repeat(500),
172
+ disable_notification: false,
173
+ }),
174
+ )
175
+ expect(scanTurnForFinalReply(text).decided).toBe('allow')
176
+ })
177
+ })
178
+
179
+ describe('scanTurnForFinalReply — silent-marker carve-out', () => {
180
+ it('NO_REPLY → allow', () => {
181
+ const text = jsonl(
182
+ ENQUEUE,
183
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'NO_REPLY' }),
184
+ )
185
+ const r = scanTurnForFinalReply(text)
186
+ expect(r.decided).toBe('allow')
187
+ expect(r.reason).toBe('silent-marker')
188
+ })
189
+
190
+ it('NO_REPLY with trailing punctuation → allow (matches gateway tolerance)', () => {
191
+ const text = jsonl(
192
+ ENQUEUE,
193
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'NO_REPLY.' }),
194
+ )
195
+ expect(scanTurnForFinalReply(text).decided).toBe('allow')
196
+ })
197
+
198
+ it('lowercase no_reply → allow (case-insensitive)', () => {
199
+ const text = jsonl(
200
+ ENQUEUE,
201
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'no_reply' }),
202
+ )
203
+ expect(scanTurnForFinalReply(text).decided).toBe('allow')
204
+ })
205
+
206
+ it('HEARTBEAT_OK → allow (cron-silence carve-out)', () => {
207
+ const text = jsonl(
208
+ ENQUEUE,
209
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'HEARTBEAT_OK' }),
210
+ )
211
+ expect(scanTurnForFinalReply(text).decided).toBe('allow')
212
+ })
213
+ })
214
+
215
+ describe('scanTurnForFinalReply — non-reply tool_use does NOT satisfy', () => {
216
+ it('Bash + Read + Agent(sub-agent dispatch) without reply → block', () => {
217
+ const text = jsonl(
218
+ ENQUEUE,
219
+ assistantToolUse('Bash', { command: 'ls' }),
220
+ assistantToolUse('Read', { file_path: '/tmp/x' }),
221
+ assistantToolUse('Agent', { description: 'sub-agent' }),
222
+ assistantText('done thinking, but never called reply'),
223
+ )
224
+ const r = scanTurnForFinalReply(text)
225
+ expect(r.decided).toBe('block')
226
+ })
227
+
228
+ it('isSidechain:true sub-agent reply does NOT count for parent', () => {
229
+ const text = jsonl(
230
+ ENQUEUE,
231
+ assistantToolUse(
232
+ 'mcp__switchroom-telegram__reply',
233
+ { text: 'sub-agent answer', disable_notification: false },
234
+ { isSidechain: true },
235
+ ),
236
+ )
237
+ const r = scanTurnForFinalReply(text)
238
+ expect(r.decided).toBe('block')
239
+ expect(r.reason).toBe('no-final-reply')
240
+ })
241
+ })
242
+
243
+ describe('scanTurnForFinalReply — envelope-derived turnKey (block result)', () => {
244
+ it('block carries turnKey/chatId/threadId parsed from the enqueue envelope', () => {
245
+ const enq = JSON.stringify({
246
+ type: 'queue-operation',
247
+ operation: 'enqueue',
248
+ content: '<channel source="switchroom-telegram" chat_id="abc" message_thread_id="42" message_id="9">hi</channel>',
249
+ })
250
+ const text = jsonl(
251
+ enq,
252
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ack', disable_notification: true }),
253
+ )
254
+ const r = scanTurnForFinalReply(text)
255
+ expect(r.decided).toBe('block')
256
+ expect(r.chatId).toBe('abc')
257
+ expect(r.threadId).toBe(42)
258
+ expect(r.turnKey).toBe('abc:42')
259
+ })
260
+
261
+ it("DM (no message_thread_id) → turnKey uses '_' sentinel matching chatKey()", () => {
262
+ // chatKey() at telegram-plugin/gateway/chat-key.ts:46 returns
263
+ // `${chatId}:_` when threadId is missing/0. This must match.
264
+ const r = scanTurnForFinalReply(
265
+ jsonl(ENQUEUE, assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ack', disable_notification: true })),
266
+ )
267
+ expect(r.decided).toBe('block')
268
+ expect(r.turnKey).toBe('111:_')
269
+ expect(r.chatId).toBe('111')
270
+ expect(r.threadId).toBeNull()
271
+ })
272
+
273
+ it('allow result does NOT need turnKey (only block path writes the state file)', () => {
274
+ const text = jsonl(
275
+ ENQUEUE,
276
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
277
+ )
278
+ const r = scanTurnForFinalReply(text)
279
+ expect(r.decided).toBe('allow')
280
+ expect(r.turnKey).toBeUndefined()
281
+ })
282
+ })
283
+
284
+ describe('scanTurnForFinalReply — malformed input tolerance', () => {
285
+ it('malformed JSON lines interleaved → skipped, decision matches the well-formed ones', () => {
286
+ const text = jsonl(
287
+ 'this is not json',
288
+ '{partial',
289
+ ENQUEUE,
290
+ 'another bad line',
291
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
292
+ )
293
+ expect(scanTurnForFinalReply(text).decided).toBe('allow')
294
+ })
295
+
296
+ it('lines starting with non-`{` → skipped quickly (perf guard)', () => {
297
+ const text = jsonl(
298
+ '# this is a comment',
299
+ 'random plaintext',
300
+ ENQUEUE,
301
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'NO_REPLY' }),
302
+ )
303
+ expect(scanTurnForFinalReply(text).decided).toBe('allow')
304
+ })
305
+
306
+ it('assistant line with non-array content is tolerated → no crash', () => {
307
+ const text = jsonl(
308
+ ENQUEUE,
309
+ JSON.stringify({ type: 'assistant', message: { content: null } }),
310
+ JSON.stringify({ type: 'assistant', message: { content: 'a string somehow' } }),
311
+ )
312
+ expect(scanTurnForFinalReply(text).decided).toBe('block')
313
+ })
314
+ })