switchroom 0.13.33 → 0.13.36

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.
Files changed (34) hide show
  1. package/bin/timezone-hook.sh +1 -1
  2. package/dist/agent-scheduler/index.js +8 -1
  3. package/dist/auth-broker/index.js +8 -1
  4. package/dist/cli/switchroom.js +176 -26
  5. package/dist/host-control/main.js +5222 -203
  6. package/dist/vault/approvals/kernel-server.js +9 -2
  7. package/dist/vault/broker/server.js +9 -2
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +1 -1
  10. package/telegram-plugin/dist/gateway/gateway.js +234 -31
  11. package/telegram-plugin/docs/waiting-ux-spec.md +40 -0
  12. package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
  13. package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
  14. package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
  15. package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
  16. package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
  17. package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
  18. package/telegram-plugin/gateway/error-envelope-card.ts +64 -0
  19. package/telegram-plugin/gateway/gateway.ts +112 -15
  20. package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
  21. package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
  22. package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
  23. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +46 -1
  24. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
  25. package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
  26. package/telegram-plugin/pending-work-progress.ts +37 -1
  27. package/telegram-plugin/tests/boot-clears-clean-shutdown-marker.test.ts +75 -0
  28. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +79 -0
  29. package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
  30. package/telegram-plugin/tests/silent-end-integration.test.ts +268 -0
  31. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
  32. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
  33. package/telegram-plugin/tests/silent-end.test.ts +227 -38
  34. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +51 -6
@@ -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
+ })
@@ -301,7 +301,123 @@ describe('recordUndeliveredTurnEnd — #1664 extended trigger', () => {
301
301
  })
302
302
  })
303
303
 
304
- describe('silent-end-interrupt-stop hook — integration', () => {
304
+ describe('#1741 — ack reply must not clear silent-end state', () => {
305
+ // The gateway gates `clearSilentEndState` at the reply send-site on
306
+ // `isFinalAnswerReply`. These tests reproduce that gate as a unit:
307
+ // simulate a turn's reply sequence by calling the same predicate the
308
+ // gateway uses, and assert state-file persistence matches the contract.
309
+ //
310
+ // Why this matters: if `turn_end` never lands (Claude Code's
311
+ // `turn_duration` system event is unreliable for trivial-prompt
312
+ // turns), the only line of defence between an undelivered turn and
313
+ // the Stop hook is the persistence of `silent-end-pending.json`.
314
+ // Pre-fix, an ack reply cleared the file unconditionally — so the
315
+ // Stop hook found no state and allowed the stop on every ack-then-
316
+ // tool-then-silent shape. Post-fix, only a plausibly-final reply
317
+ // clears it.
318
+
319
+ function simulateReplyAtGateway(
320
+ reply: { text: string; disableNotification: boolean; done?: boolean },
321
+ turnKey: string,
322
+ ): void {
323
+ // The gateway calls clearSilentEndState ONLY when isFinalAnswerReply
324
+ // is true. Mirror that gate exactly.
325
+ if (isFinalAnswerReply(reply)) {
326
+ clearSilentEndState(turnKey)
327
+ }
328
+ }
329
+
330
+ it('ack reply (disable_notification, short, no done) does NOT clear pending state', () => {
331
+ // A prior turn-end already wrote state (or a re-prompt round wrote it).
332
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
333
+ // Agent sends an interim ack.
334
+ simulateReplyAtGateway(
335
+ { text: 'On it', disableNotification: true },
336
+ 'c:_',
337
+ )
338
+ // State must persist — the Stop hook still needs to be able to
339
+ // catch a subsequent silent end.
340
+ expect(readSilentEndState()).not.toBeNull()
341
+ expect(readSilentEndState()!.turnKey).toBe('c:_')
342
+ })
343
+
344
+ it('final-answer reply (disable_notification=false) clears the state', () => {
345
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
346
+ simulateReplyAtGateway(
347
+ { text: "Done — here's the result.", disableNotification: false },
348
+ 'c:_',
349
+ )
350
+ expect(readSilentEndState()).toBeNull()
351
+ })
352
+
353
+ it('stream_reply done=true clears the state even with disable_notification=true', () => {
354
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
355
+ simulateReplyAtGateway(
356
+ { text: 'ok', disableNotification: true, done: true },
357
+ 'c:_',
358
+ )
359
+ expect(readSilentEndState()).toBeNull()
360
+ })
361
+
362
+ it('long ack-shaped reply (>=200 chars) is treated as final and clears the state', () => {
363
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
364
+ simulateReplyAtGateway(
365
+ { text: 'x'.repeat(250), disableNotification: true },
366
+ 'c:_',
367
+ )
368
+ expect(readSilentEndState()).toBeNull()
369
+ })
370
+
371
+ it('ack-then-silent end-to-end: ack does not clear, Stop hook still blocks', () => {
372
+ // 1. The previous undelivered turn-end wrote state, OR the turn
373
+ // starts fresh and only the Stop hook will see this state file
374
+ // once the gateway re-writes it. Simulate the gateway's writer
375
+ // firing at turn-end with finalAnswerDelivered=false (no
376
+ // qualifying reply happened this turn).
377
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
378
+ // 2. Mid-turn ack reply lands. Pre-fix this would unlink the
379
+ // state file; post-fix it must persist.
380
+ simulateReplyAtGateway(
381
+ { text: 'On it, working on it…', disableNotification: true },
382
+ 'c:_',
383
+ )
384
+ // 3. Stop hook fires (separately tested below): it must still
385
+ // find the state file and decide to block. Verify the file is
386
+ // intact at the path the hook reads.
387
+ const state = readSilentEndState()
388
+ expect(state).not.toBeNull()
389
+ expect(state!.turnKey).toBe('c:_')
390
+ })
391
+
392
+ it('ack-then-final: ack does not clear, final clears', () => {
393
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
394
+ // Interim ack — state persists.
395
+ simulateReplyAtGateway(
396
+ { text: 'On it', disableNotification: true },
397
+ 'c:_',
398
+ )
399
+ expect(readSilentEndState()).not.toBeNull()
400
+ // Final answer — state cleared.
401
+ simulateReplyAtGateway(
402
+ { text: 'Done — the answer is 42.', disableNotification: false },
403
+ 'c:_',
404
+ )
405
+ expect(readSilentEndState()).toBeNull()
406
+ })
407
+ })
408
+
409
+ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan signal)', () => {
410
+ // Post-#1775 the hook's BLOCK/ALLOW signal is derived from the
411
+ // transcript file, not the state file. The state file remains for
412
+ // retry-count bookkeeping only. These tests pin the new contract.
413
+ //
414
+ // The race the new contract closes: pre-fix the hook read the
415
+ // state file as its signal, but the gateway wrote that file
416
+ // ~175ms AFTER the hook fired (race lost every time). Now the
417
+ // hook reads `transcript_path` directly — Claude Code flushes
418
+ // assistant content before firing Stop hooks, so the read is
419
+ // race-free.
420
+
305
421
  const hookPath = join(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
306
422
 
307
423
  function runHook(input: object): { exit: number; stdout: string; stderr: string } {
@@ -315,75 +431,148 @@ describe('silent-end-interrupt-stop hook — integration', () => {
315
431
  return { exit: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
316
432
  }
317
433
 
318
- it('allows the stop when no state file exists (normal completion)', () => {
319
- const r = runHook({
320
- session_id: 's',
321
- transcript_path: '/tmp/x.jsonl',
322
- hook_event_name: 'Stop',
323
- })
434
+ function writeTranscript(lines: object[]): string {
435
+ const path = join(stateDir, 'transcript.jsonl')
436
+ mkdirSync(stateDir, { recursive: true })
437
+ writeFileSync(path, lines.map(l => JSON.stringify(l)).join('\n'), 'utf8')
438
+ return path
439
+ }
440
+
441
+ const ENQUEUE = {
442
+ type: 'queue-operation',
443
+ operation: 'enqueue',
444
+ content: '<channel source="switchroom-telegram" chat_id="c">hi</channel>',
445
+ }
446
+
447
+ function replyToolUse(text: string, opts: { disable_notification?: boolean; done?: boolean } = {}) {
448
+ return {
449
+ type: 'assistant',
450
+ message: {
451
+ content: [
452
+ { type: 'tool_use', name: 'mcp__switchroom-telegram__reply', input: { text, ...opts } },
453
+ ],
454
+ },
455
+ }
456
+ }
457
+
458
+ it('allows the stop when transcript_path is missing from the event (fail-open)', () => {
459
+ // Pre-#1775 this branch was "no state file → allow". Post-fix
460
+ // the same outcome holds via the fail-open transcript guard.
461
+ const r = runHook({ session_id: 's', hook_event_name: 'Stop' })
324
462
  expect(r.exit).toBe(0)
325
463
  expect(r.stdout.trim()).toBe('')
326
464
  })
327
465
 
328
- it('blocks the stop with decision:block when silent-end state exists at retryCount=0', () => {
329
- writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
330
- const r = runHook({
331
- session_id: 's',
332
- transcript_path: '/tmp/x.jsonl',
333
- hook_event_name: 'Stop',
334
- })
466
+ it('blocks the stop when transcript shows ack-only (no final reply) — Ken-2026-05-25 repro', () => {
467
+ const transcript = writeTranscript([
468
+ ENQUEUE,
469
+ replyToolUse('on it — checking now', { disable_notification: true }),
470
+ { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] } },
471
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(2237) }] } },
472
+ ])
473
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
335
474
  expect(r.exit).toBe(0)
336
475
  const out = JSON.parse(r.stdout.trim())
337
476
  expect(out.decision).toBe('block')
338
477
  expect(out.reason).toContain('reply')
339
- // #1664 — the re-prompt must offer the NO_REPLY escape hatch so a
340
- // model that already delivered (or intentionally has nothing to add)
341
- // can end the turn cleanly instead of being forced to re-send.
478
+ // #1664 — the re-prompt must offer the NO_REPLY escape hatch.
342
479
  expect(out.reason).toContain('NO_REPLY')
343
- // retryCount must have been incremented to 1
480
+ // retryCount incremented to 1 (the budget bookkeeping still
481
+ // uses the state file).
344
482
  expect(readSilentEndState()!.retryCount).toBe(1)
345
483
  })
346
484
 
347
- it('allows the stop when retryCount >= MAX_RETRIES (1)', () => {
485
+ it('allows the stop when retryCount >= MAX_RETRIES (1), even if transcript still shows no reply', () => {
486
+ // Retry already spent — gateway will post the user-facing
487
+ // fallback so the user isn't left silent.
348
488
  const path = join(stateDir, 'silent-end-pending.json')
489
+ mkdirSync(stateDir, { recursive: true })
349
490
  writeFileSync(path, JSON.stringify({
350
491
  chatId: 'c', threadId: null, turnKey: 'c:_', retryCount: 1, timestamp: 0,
351
492
  }))
352
- const r = runHook({
353
- session_id: 's',
354
- transcript_path: '/tmp/x.jsonl',
355
- hook_event_name: 'Stop',
356
- })
493
+ const transcript = writeTranscript([
494
+ ENQUEUE,
495
+ replyToolUse('still just an ack', { disable_notification: true }),
496
+ ])
497
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
357
498
  expect(r.exit).toBe(0)
358
499
  expect(r.stdout.trim()).toBe('')
359
500
  expect(r.stderr).toContain('retry exhausted')
360
501
  })
361
502
 
362
- it('end-to-end: write silent-end hook blocks simulate reply next stop allows', () => {
363
- // 1. Turn ends silently gateway writes state
364
- writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
503
+ it('allows the stop when transcript shows a notification-bearing reply (no state needed)', () => {
504
+ // Pre-#1775 this scenario depended on the gateway having
505
+ // already cleared the state file (race-prone). Post-fix the
506
+ // transcript scan finds the qualifying reply directly.
507
+ const transcript = writeTranscript([
508
+ ENQUEUE,
509
+ replyToolUse('here is the answer', { disable_notification: false }),
510
+ ])
511
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
512
+ expect(r.exit).toBe(0)
513
+ expect(r.stdout.trim()).toBe('')
514
+ // No state file written (nothing to bookkeep — no block).
515
+ expect(readSilentEndState()).toBeNull()
516
+ })
365
517
 
366
- // 2. Stop hook fires, blocks, increments retryCount
367
- const r1 = runHook({ session_id: 's', transcript_path: '/tmp/x.jsonl', hook_event_name: 'Stop' })
518
+ it('end-to-end: silent turn hook blocks → re-prompt delivers → next stop allows', () => {
519
+ // The contract for the HOOK's stdout decision independent of
520
+ // gateway state-file lifecycle. Gateway's clear happens via
521
+ // recordSilentTurnEnd / clearSilentEndState; this test only
522
+ // exercises the hook's decision based on the transcript that's
523
+ // on disk at hook-time.
524
+
525
+ // 1. Turn first-stop with ack-only transcript → block + retryCount→1.
526
+ const transcriptAck = writeTranscript([
527
+ ENQUEUE,
528
+ replyToolUse('ack', { disable_notification: true }),
529
+ ])
530
+ const r1 = runHook({ session_id: 's', transcript_path: transcriptAck, hook_event_name: 'Stop' })
368
531
  expect(JSON.parse(r1.stdout).decision).toBe('block')
369
532
  expect(readSilentEndState()!.retryCount).toBe(1)
370
533
 
371
- // 3. Re-prompted agent calls replygateway clears the file
372
- clearSilentEndState('c:_')
373
- expect(readSilentEndState()).toBeNull()
374
-
375
- // 4. Next Stop allows cleanly (no state file)
376
- const r2 = runHook({ session_id: 's', transcript_path: '/tmp/x.jsonl', hook_event_name: 'Stop' })
534
+ // 2. Model retried within the same turn transcript now has the
535
+ // final reply appended. The hook scans transcript at its next
536
+ // fire and finds the qualifying reply.
537
+ const transcriptFinal = writeTranscript([
538
+ ENQUEUE,
539
+ replyToolUse('ack', { disable_notification: true }),
540
+ replyToolUse('here is the actual answer', { disable_notification: false }),
541
+ ])
542
+ const r2 = runHook({ session_id: 's', transcript_path: transcriptFinal, hook_event_name: 'Stop' })
377
543
  expect(r2.stdout.trim()).toBe('')
544
+ // State file still present (retryCount=1) — gateway's
545
+ // clearSilentEndState path (post-finalAnswerDelivered) is what
546
+ // clears it; the hook doesn't manage that.
378
547
  })
379
548
 
380
- it('fails open on a corrupt state file', () => {
549
+ it('NO_REPLY silent-marker in transcript allow stop even without final reply', () => {
550
+ const transcript = writeTranscript([
551
+ ENQUEUE,
552
+ replyToolUse('NO_REPLY'),
553
+ ])
554
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
555
+ expect(r.exit).toBe(0)
556
+ expect(r.stdout.trim()).toBe('')
557
+ })
558
+
559
+ it('fails open on a corrupt state file (when block would otherwise fire)', () => {
560
+ // Transcript shows ack-only (would block), but state file is
561
+ // corrupt. The hook treats the state file as fresh (retryCount=0)
562
+ // and proceeds to block + write a fresh state file. This is the
563
+ // safe behavior — corrupt state shouldn't cause perpetual stop.
564
+ const transcript = writeTranscript([
565
+ ENQUEUE,
566
+ replyToolUse('ack', { disable_notification: true }),
567
+ ])
381
568
  const path = join(stateDir, 'silent-end-pending.json')
382
569
  mkdirSync(stateDir, { recursive: true })
383
570
  writeFileSync(path, 'corrupt {{{', 'utf8')
384
- const r = runHook({ session_id: 's', transcript_path: '/tmp/x.jsonl', hook_event_name: 'Stop' })
571
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
385
572
  expect(r.exit).toBe(0)
386
- expect(r.stdout.trim()).toBe('')
573
+ // Decision is block (transcript says block, state is treated as fresh).
574
+ expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
575
+ expect(readSilentEndState()!.retryCount).toBe(1)
387
576
  })
388
577
 
389
578
  it('fails open on empty stdin', () => {