switchroom 0.13.35 → 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.
- package/dist/cli/switchroom.js +92 -7
- package/dist/host-control/main.js +80 -32
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +158 -26
- package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
- package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
- package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
- package/telegram-plugin/gateway/gateway.ts +42 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
- package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
- package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
- package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
- package/telegram-plugin/pending-work-progress.ts +37 -1
- package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
- package/telegram-plugin/tests/silent-end.test.ts +122 -38
|
@@ -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
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
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
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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('
|
|
468
|
-
//
|
|
469
|
-
|
|
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
|
-
//
|
|
472
|
-
const
|
|
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
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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('
|
|
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:
|
|
571
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
490
572
|
expect(r.exit).toBe(0)
|
|
491
|
-
|
|
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', () => {
|