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.
@@ -406,7 +406,18 @@ describe('#1741 — ack reply must not clear silent-end state', () => {
406
406
  })
407
407
  })
408
408
 
409
- describe('silent-end-interrupt-stop hook — integration', () => {
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
+
410
421
  const hookPath = join(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
411
422
 
412
423
  function runHook(input: object): { exit: number; stdout: string; stderr: string } {
@@ -420,75 +431,148 @@ describe('silent-end-interrupt-stop hook — integration', () => {
420
431
  return { exit: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
421
432
  }
422
433
 
423
- it('allows the stop when no state file exists (normal completion)', () => {
424
- const r = runHook({
425
- session_id: 's',
426
- transcript_path: '/tmp/x.jsonl',
427
- hook_event_name: 'Stop',
428
- })
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' })
429
462
  expect(r.exit).toBe(0)
430
463
  expect(r.stdout.trim()).toBe('')
431
464
  })
432
465
 
433
- it('blocks the stop with decision:block when silent-end state exists at retryCount=0', () => {
434
- writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
435
- const r = runHook({
436
- session_id: 's',
437
- transcript_path: '/tmp/x.jsonl',
438
- hook_event_name: 'Stop',
439
- })
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' })
440
474
  expect(r.exit).toBe(0)
441
475
  const out = JSON.parse(r.stdout.trim())
442
476
  expect(out.decision).toBe('block')
443
477
  expect(out.reason).toContain('reply')
444
- // #1664 — the re-prompt must offer the NO_REPLY escape hatch so a
445
- // model that already delivered (or intentionally has nothing to add)
446
- // can end the turn cleanly instead of being forced to re-send.
478
+ // #1664 — the re-prompt must offer the NO_REPLY escape hatch.
447
479
  expect(out.reason).toContain('NO_REPLY')
448
- // retryCount must have been incremented to 1
480
+ // retryCount incremented to 1 (the budget bookkeeping still
481
+ // uses the state file).
449
482
  expect(readSilentEndState()!.retryCount).toBe(1)
450
483
  })
451
484
 
452
- 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.
453
488
  const path = join(stateDir, 'silent-end-pending.json')
489
+ mkdirSync(stateDir, { recursive: true })
454
490
  writeFileSync(path, JSON.stringify({
455
491
  chatId: 'c', threadId: null, turnKey: 'c:_', retryCount: 1, timestamp: 0,
456
492
  }))
457
- const r = runHook({
458
- session_id: 's',
459
- transcript_path: '/tmp/x.jsonl',
460
- hook_event_name: 'Stop',
461
- })
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' })
462
498
  expect(r.exit).toBe(0)
463
499
  expect(r.stdout.trim()).toBe('')
464
500
  expect(r.stderr).toContain('retry exhausted')
465
501
  })
466
502
 
467
- it('end-to-end: write silent-end hook blocks simulate reply next stop allows', () => {
468
- // 1. Turn ends silently gateway writes state
469
- 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
+ })
517
+
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.
470
524
 
471
- // 2. Stop hook fires, blocks, increments retryCount
472
- const r1 = runHook({ session_id: 's', transcript_path: '/tmp/x.jsonl', hook_event_name: 'Stop' })
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' })
473
531
  expect(JSON.parse(r1.stdout).decision).toBe('block')
474
532
  expect(readSilentEndState()!.retryCount).toBe(1)
475
533
 
476
- // 3. Re-prompted agent calls replygateway clears the file
477
- clearSilentEndState('c:_')
478
- expect(readSilentEndState()).toBeNull()
479
-
480
- // 4. Next Stop allows cleanly (no state file)
481
- 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' })
482
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.
483
547
  })
484
548
 
485
- 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
+ ])
486
568
  const path = join(stateDir, 'silent-end-pending.json')
487
569
  mkdirSync(stateDir, { recursive: true })
488
570
  writeFileSync(path, 'corrupt {{{', 'utf8')
489
- 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' })
490
572
  expect(r.exit).toBe(0)
491
- 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)
492
576
  })
493
577
 
494
578
  it('fails open on empty stdin', () => {