switchroom 0.15.45 → 0.16.5
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/agent-scheduler/index.js +56 -15
- package/dist/auth-broker/index.js +383 -97
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +7 -4
- package/dist/cli/notion-write-pretool.mjs +35 -4
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/switchroom.js +2894 -841
- package/dist/host-control/main.js +2685 -207
- package/dist/vault/approvals/kernel-server.js +7453 -7413
- package/dist/vault/broker/server.js +11428 -11388
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +97 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +55 -12
- package/telegram-plugin/dist/gateway/gateway.js +2938 -977
- package/telegram-plugin/dist/server.js +55 -12
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1857 -292
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-command.test.ts +134 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { createDraftStream } from '../draft-stream.js'
|
|
3
|
-
import { __resetDraftIdForTests } from '../draft-transport.js'
|
|
4
3
|
|
|
5
4
|
interface MockTelegram {
|
|
6
5
|
send: (text: string) => Promise<number>
|
|
@@ -456,829 +455,3 @@ describe('createDraftStream', () => {
|
|
|
456
455
|
expect(m.editCalls.length).toBe(1)
|
|
457
456
|
})
|
|
458
457
|
})
|
|
459
|
-
|
|
460
|
-
// ─── Draft transport (sendMessageDraft) ───────────────────────────────────
|
|
461
|
-
|
|
462
|
-
describe('createDraftStream — draft transport', () => {
|
|
463
|
-
beforeEach(() => {
|
|
464
|
-
vi.useFakeTimers()
|
|
465
|
-
__resetDraftIdForTests()
|
|
466
|
-
})
|
|
467
|
-
|
|
468
|
-
afterEach(() => {
|
|
469
|
-
vi.useRealTimers()
|
|
470
|
-
})
|
|
471
|
-
|
|
472
|
-
it('DM happy path: sendMessageDraft called per update, sendMessage NOT called during stream', async () => {
|
|
473
|
-
const m = makeMock()
|
|
474
|
-
const draftCalls: Array<{ chatId: string; draftId: number; text: string }> = []
|
|
475
|
-
const sendMessageDraft = vi.fn(async (chatId: string, draftId: number, text: string) => {
|
|
476
|
-
draftCalls.push({ chatId, draftId, text })
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
480
|
-
throttleMs: 1000,
|
|
481
|
-
previewTransport: 'auto',
|
|
482
|
-
isPrivateChat: true,
|
|
483
|
-
sendMessageDraft,
|
|
484
|
-
chatId: 'chat1',
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
void stream.update('First update')
|
|
488
|
-
await microtaskFlush()
|
|
489
|
-
|
|
490
|
-
// Draft called, not sendMessage
|
|
491
|
-
expect(sendMessageDraft).toHaveBeenCalledTimes(1)
|
|
492
|
-
expect(draftCalls[0].text).toBe('First update')
|
|
493
|
-
expect(m.sendCalls.length).toBe(0)
|
|
494
|
-
expect(m.editCalls.length).toBe(0)
|
|
495
|
-
|
|
496
|
-
// Second update after throttle
|
|
497
|
-
vi.advanceTimersByTime(1000)
|
|
498
|
-
void stream.update('Second update')
|
|
499
|
-
await microtaskFlush()
|
|
500
|
-
|
|
501
|
-
expect(sendMessageDraft).toHaveBeenCalledTimes(2)
|
|
502
|
-
expect(draftCalls[1].text).toBe('Second update')
|
|
503
|
-
expect(m.sendCalls.length).toBe(0)
|
|
504
|
-
})
|
|
505
|
-
|
|
506
|
-
it('materialize on finalize: sends real sendMessage for push notification + clears draft', async () => {
|
|
507
|
-
const m = makeMock()
|
|
508
|
-
const draftClearCalls: string[] = []
|
|
509
|
-
const sendMessageDraft = vi.fn(async (_chatId: string, _draftId: number, text: string) => {
|
|
510
|
-
if (text === '') draftClearCalls.push(text)
|
|
511
|
-
})
|
|
512
|
-
|
|
513
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
514
|
-
throttleMs: 1000,
|
|
515
|
-
previewTransport: 'draft',
|
|
516
|
-
isPrivateChat: true,
|
|
517
|
-
sendMessageDraft,
|
|
518
|
-
chatId: 'chat1',
|
|
519
|
-
})
|
|
520
|
-
|
|
521
|
-
void stream.update('Final answer')
|
|
522
|
-
await microtaskFlush()
|
|
523
|
-
expect(sendMessageDraft).toHaveBeenCalledTimes(1)
|
|
524
|
-
|
|
525
|
-
await stream.finalize()
|
|
526
|
-
|
|
527
|
-
// sendMessage should have been called to materialize
|
|
528
|
-
expect(m.sendCalls.length).toBe(1)
|
|
529
|
-
expect(m.sendCalls[0].text).toBe('Final answer')
|
|
530
|
-
expect(stream.getMessageId()).toBe(100)
|
|
531
|
-
|
|
532
|
-
// Draft should have been cleared (empty string call)
|
|
533
|
-
expect(draftClearCalls.length).toBe(1)
|
|
534
|
-
expect(stream.isFinal()).toBe(true)
|
|
535
|
-
})
|
|
536
|
-
|
|
537
|
-
it('init-time fallback when sendMessageDraft is undefined → uses sendMessage/editMessageText', async () => {
|
|
538
|
-
const m = makeMock()
|
|
539
|
-
|
|
540
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
541
|
-
throttleMs: 1000,
|
|
542
|
-
previewTransport: 'draft',
|
|
543
|
-
isPrivateChat: true,
|
|
544
|
-
// No sendMessageDraft provided
|
|
545
|
-
chatId: 'chat1',
|
|
546
|
-
})
|
|
547
|
-
|
|
548
|
-
void stream.update('Hello')
|
|
549
|
-
await microtaskFlush()
|
|
550
|
-
|
|
551
|
-
expect(m.sendCalls.length).toBe(1)
|
|
552
|
-
expect(m.sendCalls[0].text).toBe('Hello')
|
|
553
|
-
})
|
|
554
|
-
|
|
555
|
-
it('runtime fallback on rejection matching DRAFT_METHOD_UNAVAILABLE_RE', async () => {
|
|
556
|
-
const m = makeMock()
|
|
557
|
-
let draftCallCount = 0
|
|
558
|
-
const sendMessageDraft = vi.fn(async () => {
|
|
559
|
-
draftCallCount++
|
|
560
|
-
throw new Error('sendMessageDraft: unknown method')
|
|
561
|
-
})
|
|
562
|
-
|
|
563
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
564
|
-
throttleMs: 1000,
|
|
565
|
-
previewTransport: 'draft',
|
|
566
|
-
sendMessageDraft,
|
|
567
|
-
chatId: 'chat1',
|
|
568
|
-
})
|
|
569
|
-
|
|
570
|
-
void stream.update('Hello')
|
|
571
|
-
await microtaskFlush()
|
|
572
|
-
|
|
573
|
-
// Draft tried once, then fell back to sendMessage
|
|
574
|
-
expect(draftCallCount).toBe(1)
|
|
575
|
-
expect(m.sendCalls.length).toBe(1)
|
|
576
|
-
expect(m.sendCalls[0].text).toBe('Hello')
|
|
577
|
-
|
|
578
|
-
// Subsequent updates should use editMessageText, not draft
|
|
579
|
-
vi.advanceTimersByTime(1000)
|
|
580
|
-
void stream.update('Follow-up')
|
|
581
|
-
await microtaskFlush()
|
|
582
|
-
|
|
583
|
-
expect(draftCallCount).toBe(1) // no more draft calls
|
|
584
|
-
expect(m.editCalls.length).toBe(1)
|
|
585
|
-
})
|
|
586
|
-
|
|
587
|
-
it('runtime fallback on rejection matching DRAFT_CHAT_UNSUPPORTED_RE', async () => {
|
|
588
|
-
const m = makeMock()
|
|
589
|
-
const sendMessageDraft = vi.fn(async () => {
|
|
590
|
-
throw new Error("sendMessageDraft can't be used in this type of chat")
|
|
591
|
-
})
|
|
592
|
-
|
|
593
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
594
|
-
throttleMs: 1000,
|
|
595
|
-
previewTransport: 'draft',
|
|
596
|
-
sendMessageDraft,
|
|
597
|
-
chatId: 'chat1',
|
|
598
|
-
})
|
|
599
|
-
|
|
600
|
-
void stream.update('Hello')
|
|
601
|
-
await microtaskFlush()
|
|
602
|
-
|
|
603
|
-
expect(sendMessageDraft).toHaveBeenCalledTimes(1)
|
|
604
|
-
expect(m.sendCalls.length).toBe(1)
|
|
605
|
-
})
|
|
606
|
-
|
|
607
|
-
it('non-matching rejection bubbles up — does not silently swap to message transport', async () => {
|
|
608
|
-
const m = makeMock()
|
|
609
|
-
const sendMessageDraft = vi.fn(async () => {
|
|
610
|
-
throw new Error('sendMessageDraft: internal server error 500')
|
|
611
|
-
})
|
|
612
|
-
|
|
613
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
614
|
-
throttleMs: 1000,
|
|
615
|
-
previewTransport: 'draft',
|
|
616
|
-
sendMessageDraft,
|
|
617
|
-
chatId: 'chat1',
|
|
618
|
-
})
|
|
619
|
-
|
|
620
|
-
// The error should NOT trigger fallback — it should propagate (draft-stream
|
|
621
|
-
// logs it but doesn't swap; subsequent update can retry).
|
|
622
|
-
void stream.update('Hello')
|
|
623
|
-
await microtaskFlush()
|
|
624
|
-
|
|
625
|
-
// Did not fall through to sendMessage
|
|
626
|
-
expect(m.sendCalls.length).toBe(0)
|
|
627
|
-
// Draft was called
|
|
628
|
-
expect(sendMessageDraft).toHaveBeenCalledTimes(1)
|
|
629
|
-
})
|
|
630
|
-
|
|
631
|
-
it('group chat (isPrivateChat=false with auto transport) → never tries draft', async () => {
|
|
632
|
-
const m = makeMock()
|
|
633
|
-
const sendMessageDraft = vi.fn(async () => {})
|
|
634
|
-
|
|
635
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
636
|
-
throttleMs: 1000,
|
|
637
|
-
previewTransport: 'auto',
|
|
638
|
-
isPrivateChat: false,
|
|
639
|
-
sendMessageDraft,
|
|
640
|
-
chatId: 'chat1',
|
|
641
|
-
})
|
|
642
|
-
|
|
643
|
-
void stream.update('Hello group')
|
|
644
|
-
await microtaskFlush()
|
|
645
|
-
|
|
646
|
-
expect(sendMessageDraft).not.toHaveBeenCalled()
|
|
647
|
-
expect(m.sendCalls.length).toBe(1)
|
|
648
|
-
})
|
|
649
|
-
|
|
650
|
-
it('forum topic (message transport) → never tries draft', async () => {
|
|
651
|
-
const m = makeMock()
|
|
652
|
-
const sendMessageDraft = vi.fn(async () => {})
|
|
653
|
-
|
|
654
|
-
// Caller forces message transport for forum topics
|
|
655
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
656
|
-
throttleMs: 1000,
|
|
657
|
-
previewTransport: 'message',
|
|
658
|
-
isPrivateChat: true, // even if DM, message transport wins
|
|
659
|
-
sendMessageDraft,
|
|
660
|
-
chatId: 'chat1',
|
|
661
|
-
})
|
|
662
|
-
|
|
663
|
-
void stream.update('Hello forum')
|
|
664
|
-
await microtaskFlush()
|
|
665
|
-
|
|
666
|
-
expect(sendMessageDraft).not.toHaveBeenCalled()
|
|
667
|
-
expect(m.sendCalls.length).toBe(1)
|
|
668
|
-
})
|
|
669
|
-
|
|
670
|
-
it('initialMessageId — first update edits in place instead of sendMessage (#626)', async () => {
|
|
671
|
-
// Closes the duplicate-status-message regression: when a previous
|
|
672
|
-
// done=true finalized + deleted activeDraftStreams[sKey], the next
|
|
673
|
-
// emit creates a fresh stream. With initialMessageId, that fresh
|
|
674
|
-
// stream is initialized as if a previous send had landed with the
|
|
675
|
-
// given id, so the very first update fires editMessageText
|
|
676
|
-
// instead of sendMessage. No new "anchor message" lands.
|
|
677
|
-
const m = makeMock()
|
|
678
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
679
|
-
throttleMs: 200,
|
|
680
|
-
initialMessageId: 999,
|
|
681
|
-
})
|
|
682
|
-
void stream.update('Edit-only payload')
|
|
683
|
-
await stream.finalize()
|
|
684
|
-
expect(m.sendCalls.length).toBe(0)
|
|
685
|
-
expect(m.editCalls.length).toBe(1)
|
|
686
|
-
expect(m.editCalls[0].id).toBe(999)
|
|
687
|
-
expect(m.editCalls[0].text).toBe('Edit-only payload')
|
|
688
|
-
})
|
|
689
|
-
|
|
690
|
-
it('initialMessageId — stale id falls back to sendMessage on not-found error', async () => {
|
|
691
|
-
// Defense in depth: if the externally-supplied id no longer exists
|
|
692
|
-
// (message deleted, chat moved, race), the edit returns
|
|
693
|
-
// "message to edit not found" and the draft stream re-sends. The
|
|
694
|
-
// user sees one fresh anchor — degraded but never silent failure.
|
|
695
|
-
const m = makeMock()
|
|
696
|
-
let editAttempts = 0
|
|
697
|
-
m.edit = async (_id: number, _text: string) => {
|
|
698
|
-
editAttempts++
|
|
699
|
-
throw new Error('Bad Request: message to edit not found')
|
|
700
|
-
}
|
|
701
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
702
|
-
throttleMs: 200,
|
|
703
|
-
initialMessageId: 99999,
|
|
704
|
-
})
|
|
705
|
-
void stream.update('Recovery text')
|
|
706
|
-
await stream.finalize()
|
|
707
|
-
expect(editAttempts).toBeGreaterThanOrEqual(1)
|
|
708
|
-
expect(m.sendCalls.length).toBe(1)
|
|
709
|
-
expect(m.sendCalls[0].text).toBe('Recovery text')
|
|
710
|
-
})
|
|
711
|
-
|
|
712
|
-
it('initialMessageId — null/undefined behaves identically to omitted (back-compat)', async () => {
|
|
713
|
-
// The hook is opt-in. A caller that doesn't supply it (or supplies
|
|
714
|
-
// null) must observe identical behavior to the legacy path:
|
|
715
|
-
// first update sends, subsequent updates edit.
|
|
716
|
-
const m = makeMock()
|
|
717
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
718
|
-
throttleMs: 200,
|
|
719
|
-
initialMessageId: null,
|
|
720
|
-
})
|
|
721
|
-
void stream.update('First call')
|
|
722
|
-
await stream.finalize()
|
|
723
|
-
expect(m.sendCalls.length).toBe(1)
|
|
724
|
-
expect(m.editCalls.length).toBe(0)
|
|
725
|
-
})
|
|
726
|
-
|
|
727
|
-
it('draft-clear failure is swallowed (best-effort)', async () => {
|
|
728
|
-
const m = makeMock()
|
|
729
|
-
let callCount = 0
|
|
730
|
-
const sendMessageDraft = vi.fn(async (_chatId: string, _draftId: number, text: string) => {
|
|
731
|
-
callCount++
|
|
732
|
-
if (text === '') throw new Error('Draft clear failed')
|
|
733
|
-
// Normal update — succeeds
|
|
734
|
-
})
|
|
735
|
-
|
|
736
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
737
|
-
throttleMs: 1000,
|
|
738
|
-
previewTransport: 'draft',
|
|
739
|
-
sendMessageDraft,
|
|
740
|
-
chatId: 'chat1',
|
|
741
|
-
})
|
|
742
|
-
|
|
743
|
-
void stream.update('Content')
|
|
744
|
-
await microtaskFlush()
|
|
745
|
-
|
|
746
|
-
// finalize should not throw even if draft-clear fails
|
|
747
|
-
await expect(stream.finalize()).resolves.toBeUndefined()
|
|
748
|
-
expect(m.sendCalls.length).toBe(1) // materialized
|
|
749
|
-
expect(callCount).toBeGreaterThan(1) // draft update + failed clear attempt
|
|
750
|
-
})
|
|
751
|
-
|
|
752
|
-
// ─── PR A observability: gw-trace stream-start / stream-end ───────────
|
|
753
|
-
describe('gw-trace stream-start / stream-end', () => {
|
|
754
|
-
let captured: string[] = []
|
|
755
|
-
let originalWrite: typeof process.stderr.write
|
|
756
|
-
|
|
757
|
-
beforeEach(() => {
|
|
758
|
-
captured = []
|
|
759
|
-
originalWrite = process.stderr.write
|
|
760
|
-
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
761
|
-
const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
|
762
|
-
captured.push(text)
|
|
763
|
-
return true
|
|
764
|
-
}) as typeof process.stderr.write
|
|
765
|
-
})
|
|
766
|
-
afterEach(() => {
|
|
767
|
-
process.stderr.write = originalWrite
|
|
768
|
-
})
|
|
769
|
-
|
|
770
|
-
it('emits stream-start with resolved transport=draft on dm + draftApi', async () => {
|
|
771
|
-
const m = makeMock()
|
|
772
|
-
const draftApi = vi.fn(async () => ({ ok: true }))
|
|
773
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
774
|
-
throttleMs: 1000,
|
|
775
|
-
previewTransport: 'draft',
|
|
776
|
-
sendMessageDraft: draftApi,
|
|
777
|
-
chatId: 'chat-X',
|
|
778
|
-
})
|
|
779
|
-
const trace = captured.find((c) => c.includes('gw-trace stream-start'))
|
|
780
|
-
expect(trace).toBeDefined()
|
|
781
|
-
expect(trace).toContain('transport=draft')
|
|
782
|
-
expect(trace).toContain('reason=draft')
|
|
783
|
-
expect(trace).toContain('api=available')
|
|
784
|
-
expect(trace).toContain('chatId=chat-X')
|
|
785
|
-
await stream.finalize()
|
|
786
|
-
})
|
|
787
|
-
|
|
788
|
-
it('emits stream-start with reason=auto-non-dm when isPrivateChat is false', async () => {
|
|
789
|
-
const m = makeMock()
|
|
790
|
-
const draftApi = vi.fn(async () => ({ ok: true }))
|
|
791
|
-
createDraftStream(m.send, m.edit, {
|
|
792
|
-
throttleMs: 1000,
|
|
793
|
-
previewTransport: 'auto',
|
|
794
|
-
isPrivateChat: false,
|
|
795
|
-
sendMessageDraft: draftApi,
|
|
796
|
-
chatId: 'chat-Y',
|
|
797
|
-
})
|
|
798
|
-
const trace = captured.find((c) => c.includes('gw-trace stream-start'))
|
|
799
|
-
expect(trace).toBeDefined()
|
|
800
|
-
expect(trace).toContain('transport=message')
|
|
801
|
-
expect(trace).toContain('reason=auto-non-dm')
|
|
802
|
-
})
|
|
803
|
-
|
|
804
|
-
it('emits stream-end with fire counts on finalize', async () => {
|
|
805
|
-
const m = makeMock()
|
|
806
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
807
|
-
throttleMs: 50,
|
|
808
|
-
previewTransport: 'message',
|
|
809
|
-
})
|
|
810
|
-
void stream.update('first')
|
|
811
|
-
await microtaskFlush()
|
|
812
|
-
await stream.finalize()
|
|
813
|
-
const trace = captured.find((c) => c.includes('gw-trace stream-end'))
|
|
814
|
-
expect(trace).toBeDefined()
|
|
815
|
-
expect(trace).toContain('transport=message')
|
|
816
|
-
expect(trace).toMatch(/sends=[1-9]/)
|
|
817
|
-
expect(trace).toContain('firstFireMs=')
|
|
818
|
-
expect(trace).toContain('durationMs=')
|
|
819
|
-
})
|
|
820
|
-
|
|
821
|
-
it('SWITCHROOM_STREAM_TRACES=0 suppresses both traces', async () => {
|
|
822
|
-
const prev = process.env.SWITCHROOM_STREAM_TRACES
|
|
823
|
-
process.env.SWITCHROOM_STREAM_TRACES = '0'
|
|
824
|
-
try {
|
|
825
|
-
const m = makeMock()
|
|
826
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
827
|
-
throttleMs: 50,
|
|
828
|
-
previewTransport: 'message',
|
|
829
|
-
})
|
|
830
|
-
await stream.finalize()
|
|
831
|
-
expect(captured.find((c) => c.includes('gw-trace stream-'))).toBeUndefined()
|
|
832
|
-
} finally {
|
|
833
|
-
if (prev === undefined) delete process.env.SWITCHROOM_STREAM_TRACES
|
|
834
|
-
else process.env.SWITCHROOM_STREAM_TRACES = prev
|
|
835
|
-
}
|
|
836
|
-
})
|
|
837
|
-
})
|
|
838
|
-
|
|
839
|
-
// ─── PR B: transport-aware throttle defaults ──────────────────────────
|
|
840
|
-
describe('transport-aware throttle defaults (PR B)', () => {
|
|
841
|
-
let captured: string[] = []
|
|
842
|
-
let originalWrite: typeof process.stderr.write
|
|
843
|
-
|
|
844
|
-
beforeEach(() => {
|
|
845
|
-
captured = []
|
|
846
|
-
originalWrite = process.stderr.write
|
|
847
|
-
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
848
|
-
const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
|
849
|
-
captured.push(text)
|
|
850
|
-
return true
|
|
851
|
-
}) as typeof process.stderr.write
|
|
852
|
-
})
|
|
853
|
-
afterEach(() => {
|
|
854
|
-
process.stderr.write = originalWrite
|
|
855
|
-
})
|
|
856
|
-
|
|
857
|
-
it('draft transport defaults to 300ms throttle (sub-second)', () => {
|
|
858
|
-
const m = makeMock()
|
|
859
|
-
const draftApi = vi.fn(async () => ({ ok: true }))
|
|
860
|
-
createDraftStream(m.send, m.edit, {
|
|
861
|
-
previewTransport: 'draft',
|
|
862
|
-
sendMessageDraft: draftApi,
|
|
863
|
-
chatId: 'c1',
|
|
864
|
-
})
|
|
865
|
-
const trace = captured.find((c) => c.includes('gw-trace stream-start'))
|
|
866
|
-
expect(trace).toBeDefined()
|
|
867
|
-
expect(trace).toContain('throttleMs=300')
|
|
868
|
-
})
|
|
869
|
-
|
|
870
|
-
it('message transport defaults to 1000ms throttle', () => {
|
|
871
|
-
const m = makeMock()
|
|
872
|
-
createDraftStream(m.send, m.edit, {
|
|
873
|
-
previewTransport: 'message',
|
|
874
|
-
})
|
|
875
|
-
const trace = captured.find((c) => c.includes('gw-trace stream-start'))
|
|
876
|
-
expect(trace).toBeDefined()
|
|
877
|
-
expect(trace).toContain('throttleMs=1000')
|
|
878
|
-
})
|
|
879
|
-
|
|
880
|
-
it('auto + DM + draftApi resolves to draft default (300ms)', () => {
|
|
881
|
-
const m = makeMock()
|
|
882
|
-
const draftApi = vi.fn(async () => ({ ok: true }))
|
|
883
|
-
createDraftStream(m.send, m.edit, {
|
|
884
|
-
previewTransport: 'auto',
|
|
885
|
-
isPrivateChat: true,
|
|
886
|
-
sendMessageDraft: draftApi,
|
|
887
|
-
chatId: 'c1',
|
|
888
|
-
})
|
|
889
|
-
const trace = captured.find((c) => c.includes('gw-trace stream-start'))
|
|
890
|
-
expect(trace).toContain('throttleMs=300')
|
|
891
|
-
})
|
|
892
|
-
|
|
893
|
-
it('auto + non-DM resolves to message default (1000ms)', () => {
|
|
894
|
-
const m = makeMock()
|
|
895
|
-
const draftApi = vi.fn(async () => ({ ok: true }))
|
|
896
|
-
createDraftStream(m.send, m.edit, {
|
|
897
|
-
previewTransport: 'auto',
|
|
898
|
-
isPrivateChat: false,
|
|
899
|
-
sendMessageDraft: draftApi,
|
|
900
|
-
chatId: 'c1',
|
|
901
|
-
})
|
|
902
|
-
const trace = captured.find((c) => c.includes('gw-trace stream-start'))
|
|
903
|
-
expect(trace).toContain('throttleMs=1000')
|
|
904
|
-
})
|
|
905
|
-
|
|
906
|
-
it('explicit config.throttleMs wins over transport default', () => {
|
|
907
|
-
const m = makeMock()
|
|
908
|
-
const draftApi = vi.fn(async () => ({ ok: true }))
|
|
909
|
-
createDraftStream(m.send, m.edit, {
|
|
910
|
-
previewTransport: 'draft',
|
|
911
|
-
sendMessageDraft: draftApi,
|
|
912
|
-
chatId: 'c1',
|
|
913
|
-
throttleMs: 500,
|
|
914
|
-
})
|
|
915
|
-
const trace = captured.find((c) => c.includes('gw-trace stream-start'))
|
|
916
|
-
expect(trace).toContain('throttleMs=500')
|
|
917
|
-
})
|
|
918
|
-
|
|
919
|
-
it('explicit override below MIN_THROTTLE_MS is floored at 250', () => {
|
|
920
|
-
const m = makeMock()
|
|
921
|
-
createDraftStream(m.send, m.edit, {
|
|
922
|
-
previewTransport: 'message',
|
|
923
|
-
throttleMs: 100,
|
|
924
|
-
})
|
|
925
|
-
const trace = captured.find((c) => c.includes('gw-trace stream-start'))
|
|
926
|
-
expect(trace).toContain('throttleMs=250')
|
|
927
|
-
})
|
|
928
|
-
})
|
|
929
|
-
|
|
930
|
-
// ─── PR C: persist-then-continue chain at 25s / 4000-char boundary ───
|
|
931
|
-
describe('persist-chain (PR C)', () => {
|
|
932
|
-
let captured: string[] = []
|
|
933
|
-
let originalWrite: typeof process.stderr.write
|
|
934
|
-
|
|
935
|
-
beforeEach(() => {
|
|
936
|
-
// Outer describe sets useFakeTimers; keep that and drive time with
|
|
937
|
-
// advanceTimersByTimeAsync. The size-trigger doesn't need time at
|
|
938
|
-
// all (it fires on tail length); the time-trigger tests advance.
|
|
939
|
-
captured = []
|
|
940
|
-
originalWrite = process.stderr.write
|
|
941
|
-
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
942
|
-
const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
|
943
|
-
captured.push(text)
|
|
944
|
-
return true
|
|
945
|
-
}) as typeof process.stderr.write
|
|
946
|
-
})
|
|
947
|
-
afterEach(() => {
|
|
948
|
-
process.stderr.write = originalWrite
|
|
949
|
-
})
|
|
950
|
-
|
|
951
|
-
it('size-trigger: persist fires when tail crosses persistSizeLimit', async () => {
|
|
952
|
-
const m = makeMock()
|
|
953
|
-
const sendMessageDraft = vi.fn(async () => {})
|
|
954
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
955
|
-
throttleMs: 50,
|
|
956
|
-
previewTransport: 'draft',
|
|
957
|
-
sendMessageDraft,
|
|
958
|
-
chatId: 'chat-size',
|
|
959
|
-
persistSizeLimit: 200, // small for tests
|
|
960
|
-
})
|
|
961
|
-
|
|
962
|
-
// First update: 100 chars, below threshold. Drafts.
|
|
963
|
-
void stream.update('a'.repeat(100))
|
|
964
|
-
await microtaskFlush(20)
|
|
965
|
-
vi.advanceTimersByTime(300); await microtaskFlush(20)
|
|
966
|
-
// Second update: 250 chars total, tail exceeds 200 → size persist.
|
|
967
|
-
void stream.update('a'.repeat(250))
|
|
968
|
-
await microtaskFlush(20)
|
|
969
|
-
vi.advanceTimersByTime(300); await microtaskFlush(20)
|
|
970
|
-
await microtaskFlush(20)
|
|
971
|
-
await stream.finalize()
|
|
972
|
-
|
|
973
|
-
const persistLine = captured.find((c) => c.includes('gw-trace stream-persist'))
|
|
974
|
-
expect(persistLine).toBeDefined()
|
|
975
|
-
expect(persistLine).toContain('reason=size')
|
|
976
|
-
expect(m.sendCalls.length).toBeGreaterThan(0)
|
|
977
|
-
})
|
|
978
|
-
|
|
979
|
-
it('time-trigger: persist fires after persistIntervalMs elapsed', async () => {
|
|
980
|
-
const m = makeMock()
|
|
981
|
-
const sendMessageDraft = vi.fn(async () => {})
|
|
982
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
983
|
-
throttleMs: 50,
|
|
984
|
-
previewTransport: 'draft',
|
|
985
|
-
sendMessageDraft,
|
|
986
|
-
chatId: 'chat-time',
|
|
987
|
-
persistIntervalMs: 200, // small for tests
|
|
988
|
-
})
|
|
989
|
-
|
|
990
|
-
void stream.update('first chunk text')
|
|
991
|
-
await microtaskFlush(20)
|
|
992
|
-
// Advance past the time trigger.
|
|
993
|
-
vi.advanceTimersByTime(250); await microtaskFlush(20)
|
|
994
|
-
void stream.update('first chunk text continues')
|
|
995
|
-
await microtaskFlush(20)
|
|
996
|
-
vi.advanceTimersByTime(300); await microtaskFlush(20)
|
|
997
|
-
await microtaskFlush(20)
|
|
998
|
-
await stream.finalize()
|
|
999
|
-
|
|
1000
|
-
const persistLine = captured.find((c) => c.includes('gw-trace stream-persist'))
|
|
1001
|
-
expect(persistLine).toBeDefined()
|
|
1002
|
-
expect(persistLine).toContain('reason=time')
|
|
1003
|
-
})
|
|
1004
|
-
|
|
1005
|
-
it('persist bumps persists counter in stream-end trace', async () => {
|
|
1006
|
-
const m = makeMock()
|
|
1007
|
-
const sendMessageDraft = vi.fn(async () => {})
|
|
1008
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
1009
|
-
throttleMs: 50,
|
|
1010
|
-
previewTransport: 'draft',
|
|
1011
|
-
sendMessageDraft,
|
|
1012
|
-
chatId: 'chat-2',
|
|
1013
|
-
persistSizeLimit: 100,
|
|
1014
|
-
})
|
|
1015
|
-
|
|
1016
|
-
void stream.update('x'.repeat(50))
|
|
1017
|
-
await microtaskFlush(20)
|
|
1018
|
-
vi.advanceTimersByTime(300); await microtaskFlush(20)
|
|
1019
|
-
void stream.update('x'.repeat(150))
|
|
1020
|
-
await microtaskFlush(20)
|
|
1021
|
-
vi.advanceTimersByTime(300); await microtaskFlush(20)
|
|
1022
|
-
await microtaskFlush(20)
|
|
1023
|
-
await stream.finalize()
|
|
1024
|
-
|
|
1025
|
-
const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
|
|
1026
|
-
expect(endLine).toBeDefined()
|
|
1027
|
-
expect(endLine).toMatch(/persists=[1-9]/)
|
|
1028
|
-
})
|
|
1029
|
-
|
|
1030
|
-
it('finalize only materializes the unpersisted tail (no duplicate)', async () => {
|
|
1031
|
-
const m = makeMock()
|
|
1032
|
-
const sendMessageDraft = vi.fn(async () => {})
|
|
1033
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
1034
|
-
throttleMs: 50,
|
|
1035
|
-
previewTransport: 'draft',
|
|
1036
|
-
sendMessageDraft,
|
|
1037
|
-
chatId: 'chat-3',
|
|
1038
|
-
persistSizeLimit: 200,
|
|
1039
|
-
})
|
|
1040
|
-
|
|
1041
|
-
void stream.update('a'.repeat(100))
|
|
1042
|
-
await microtaskFlush(20)
|
|
1043
|
-
vi.advanceTimersByTime(300); await microtaskFlush(20)
|
|
1044
|
-
void stream.update('a'.repeat(250)) // size trigger fires
|
|
1045
|
-
await microtaskFlush(20)
|
|
1046
|
-
vi.advanceTimersByTime(300); await microtaskFlush(20)
|
|
1047
|
-
await microtaskFlush(20)
|
|
1048
|
-
const persistsBefore = m.sendCalls.length
|
|
1049
|
-
expect(persistsBefore).toBeGreaterThan(0)
|
|
1050
|
-
|
|
1051
|
-
// Now add a small tail and finalize. The tail-only send should
|
|
1052
|
-
// be much smaller than 250 — only post-persist content.
|
|
1053
|
-
void stream.update('a'.repeat(250) + 'tail')
|
|
1054
|
-
await microtaskFlush(20)
|
|
1055
|
-
vi.advanceTimersByTime(300); await microtaskFlush(20)
|
|
1056
|
-
await stream.finalize()
|
|
1057
|
-
|
|
1058
|
-
const lastSend = m.sendCalls[m.sendCalls.length - 1]
|
|
1059
|
-
expect(lastSend).toBeDefined()
|
|
1060
|
-
// Without PR C, finalize would re-send the entire 250+ chars.
|
|
1061
|
-
expect(lastSend!.text.length).toBeLessThan(50)
|
|
1062
|
-
})
|
|
1063
|
-
|
|
1064
|
-
it('message-transport stream has persists=0 (no chunking)', async () => {
|
|
1065
|
-
const m = makeMock()
|
|
1066
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
1067
|
-
throttleMs: 50,
|
|
1068
|
-
previewTransport: 'message',
|
|
1069
|
-
})
|
|
1070
|
-
void stream.update('z'.repeat(3000))
|
|
1071
|
-
await microtaskFlush(20)
|
|
1072
|
-
await stream.finalize()
|
|
1073
|
-
const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
|
|
1074
|
-
expect(endLine).toContain('persists=0')
|
|
1075
|
-
})
|
|
1076
|
-
})
|
|
1077
|
-
|
|
1078
|
-
// ─── PR D: 429 + non-true return fallback robustness ──────────────────
|
|
1079
|
-
describe('429 + non-true fallback (PR D)', () => {
|
|
1080
|
-
it('429 on draft falls back to message transport (no further draft fires)', async () => {
|
|
1081
|
-
const m = makeMock()
|
|
1082
|
-
let draftCalls = 0
|
|
1083
|
-
const sendMessageDraft = vi.fn(async () => {
|
|
1084
|
-
draftCalls++
|
|
1085
|
-
const err = new Error('sendMessageDraft: Too Many Requests: retry after 3') as Error & {
|
|
1086
|
-
error_code?: number
|
|
1087
|
-
method?: string
|
|
1088
|
-
parameters?: { retry_after?: number }
|
|
1089
|
-
}
|
|
1090
|
-
err.error_code = 429
|
|
1091
|
-
err.method = 'sendMessageDraft'
|
|
1092
|
-
err.parameters = { retry_after: 3 }
|
|
1093
|
-
throw err
|
|
1094
|
-
})
|
|
1095
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
1096
|
-
throttleMs: 50,
|
|
1097
|
-
previewTransport: 'draft',
|
|
1098
|
-
sendMessageDraft,
|
|
1099
|
-
chatId: 'chat-429',
|
|
1100
|
-
})
|
|
1101
|
-
|
|
1102
|
-
void stream.update('hello')
|
|
1103
|
-
await microtaskFlush(20)
|
|
1104
|
-
expect(draftCalls).toBe(1)
|
|
1105
|
-
await microtaskFlush(20)
|
|
1106
|
-
vi.advanceTimersByTime(3500)
|
|
1107
|
-
await microtaskFlush(20)
|
|
1108
|
-
await stream.finalize()
|
|
1109
|
-
expect(m.sendCalls.length).toBeGreaterThanOrEqual(1)
|
|
1110
|
-
// sendMessageDraft was NOT called again after the 429.
|
|
1111
|
-
expect(draftCalls).toBe(1)
|
|
1112
|
-
})
|
|
1113
|
-
|
|
1114
|
-
it('non-true (false) return from sendMessageDraft triggers fallback', async () => {
|
|
1115
|
-
const m = makeMock()
|
|
1116
|
-
let draftCalls = 0
|
|
1117
|
-
const sendMessageDraft = vi.fn(async () => {
|
|
1118
|
-
draftCalls++
|
|
1119
|
-
return false
|
|
1120
|
-
})
|
|
1121
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
1122
|
-
throttleMs: 50,
|
|
1123
|
-
previewTransport: 'draft',
|
|
1124
|
-
sendMessageDraft,
|
|
1125
|
-
chatId: 'chat-nonbool',
|
|
1126
|
-
})
|
|
1127
|
-
void stream.update('content')
|
|
1128
|
-
await microtaskFlush(20)
|
|
1129
|
-
expect(draftCalls).toBe(1)
|
|
1130
|
-
await microtaskFlush(20)
|
|
1131
|
-
vi.advanceTimersByTime(100)
|
|
1132
|
-
await microtaskFlush(20)
|
|
1133
|
-
await stream.finalize()
|
|
1134
|
-
// Subsequent updates / finalize do NOT re-invoke sendMessageDraft.
|
|
1135
|
-
expect(draftCalls).toBe(1)
|
|
1136
|
-
expect(m.sendCalls.length).toBeGreaterThanOrEqual(1)
|
|
1137
|
-
})
|
|
1138
|
-
|
|
1139
|
-
it('undefined return is treated as success (does not trigger fallback)', async () => {
|
|
1140
|
-
// grammY's typed wrapper sometimes returns void/undefined on
|
|
1141
|
-
// success. We must not false-positive fallback on those.
|
|
1142
|
-
const m = makeMock()
|
|
1143
|
-
let draftCalls = 0
|
|
1144
|
-
const sendMessageDraft = vi.fn(async () => {
|
|
1145
|
-
draftCalls++
|
|
1146
|
-
return undefined
|
|
1147
|
-
})
|
|
1148
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
1149
|
-
throttleMs: 50,
|
|
1150
|
-
previewTransport: 'draft',
|
|
1151
|
-
sendMessageDraft,
|
|
1152
|
-
chatId: 'chat-undef',
|
|
1153
|
-
})
|
|
1154
|
-
void stream.update('first')
|
|
1155
|
-
await microtaskFlush(20)
|
|
1156
|
-
vi.advanceTimersByTime(100)
|
|
1157
|
-
void stream.update('second')
|
|
1158
|
-
await microtaskFlush(20)
|
|
1159
|
-
vi.advanceTimersByTime(100)
|
|
1160
|
-
await microtaskFlush(20)
|
|
1161
|
-
await stream.finalize()
|
|
1162
|
-
expect(draftCalls).toBeGreaterThan(1)
|
|
1163
|
-
})
|
|
1164
|
-
|
|
1165
|
-
it('429 path increments fallbacks counter (visible in stream-end trace)', async () => {
|
|
1166
|
-
const captured: string[] = []
|
|
1167
|
-
const originalWrite = process.stderr.write
|
|
1168
|
-
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
1169
|
-
const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
|
1170
|
-
captured.push(text)
|
|
1171
|
-
return true
|
|
1172
|
-
}) as typeof process.stderr.write
|
|
1173
|
-
try {
|
|
1174
|
-
const m = makeMock()
|
|
1175
|
-
const sendMessageDraft = vi.fn(async () => {
|
|
1176
|
-
const err = new Error('429') as Error & {
|
|
1177
|
-
error_code?: number
|
|
1178
|
-
method?: string
|
|
1179
|
-
parameters?: { retry_after?: number }
|
|
1180
|
-
}
|
|
1181
|
-
err.error_code = 429
|
|
1182
|
-
err.method = 'sendMessageDraft'
|
|
1183
|
-
err.parameters = { retry_after: 2 }
|
|
1184
|
-
throw err
|
|
1185
|
-
})
|
|
1186
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
1187
|
-
throttleMs: 50,
|
|
1188
|
-
previewTransport: 'draft',
|
|
1189
|
-
sendMessageDraft,
|
|
1190
|
-
chatId: 'chat-429-trace',
|
|
1191
|
-
})
|
|
1192
|
-
void stream.update('text')
|
|
1193
|
-
await microtaskFlush(20)
|
|
1194
|
-
vi.advanceTimersByTime(3000)
|
|
1195
|
-
await stream.finalize()
|
|
1196
|
-
const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
|
|
1197
|
-
expect(endLine).toBeDefined()
|
|
1198
|
-
expect(endLine).toMatch(/fallbacks=[1-9]/)
|
|
1199
|
-
} finally {
|
|
1200
|
-
process.stderr.write = originalWrite
|
|
1201
|
-
}
|
|
1202
|
-
})
|
|
1203
|
-
})
|
|
1204
|
-
|
|
1205
|
-
// ─── Follow-up: stream-end `sends` counter includes finalize-materialize ───
|
|
1206
|
-
describe('finalize-materialize bumps sends counter', () => {
|
|
1207
|
-
let captured: string[] = []
|
|
1208
|
-
let originalWrite: typeof process.stderr.write
|
|
1209
|
-
|
|
1210
|
-
beforeEach(() => {
|
|
1211
|
-
captured = []
|
|
1212
|
-
originalWrite = process.stderr.write
|
|
1213
|
-
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
1214
|
-
const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
|
1215
|
-
captured.push(text)
|
|
1216
|
-
return true
|
|
1217
|
-
}) as typeof process.stderr.write
|
|
1218
|
-
})
|
|
1219
|
-
afterEach(() => {
|
|
1220
|
-
process.stderr.write = originalWrite
|
|
1221
|
-
})
|
|
1222
|
-
|
|
1223
|
-
it('draft-transport stream that materializes on finalize shows sends>=1', async () => {
|
|
1224
|
-
// Pre-fix this showed sends=0 even though sendMessage fired
|
|
1225
|
-
// inside finalize. Bug was visible in production v0.13.0 traces.
|
|
1226
|
-
const m = makeMock()
|
|
1227
|
-
const sendMessageDraft = vi.fn(async () => {})
|
|
1228
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
1229
|
-
throttleMs: 50,
|
|
1230
|
-
previewTransport: 'draft',
|
|
1231
|
-
sendMessageDraft,
|
|
1232
|
-
chatId: 'chat-x',
|
|
1233
|
-
})
|
|
1234
|
-
void stream.update('Hello world')
|
|
1235
|
-
await microtaskFlush()
|
|
1236
|
-
vi.advanceTimersByTime(100)
|
|
1237
|
-
await microtaskFlush()
|
|
1238
|
-
await stream.finalize()
|
|
1239
|
-
// Real send() called inside finalize.
|
|
1240
|
-
expect(m.sendCalls.length).toBe(1)
|
|
1241
|
-
const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
|
|
1242
|
-
expect(endLine).toBeDefined()
|
|
1243
|
-
// Counter now reflects reality.
|
|
1244
|
-
expect(endLine).toMatch(/sends=[1-9]/)
|
|
1245
|
-
})
|
|
1246
|
-
|
|
1247
|
-
it('persist-chain bump counts toward sends (size-trigger fires + finalize)', async () => {
|
|
1248
|
-
// The sibling bug at the persist-chain callsite — its bare
|
|
1249
|
-
// send(chunk) also bypasses sendViaMessage. Without the fix
|
|
1250
|
-
// a stream that crosses the size boundary would show sends=1
|
|
1251
|
-
// (only the finalize materialize), missing the chain fire.
|
|
1252
|
-
// With the fix sends counts BOTH the persist send AND the
|
|
1253
|
-
// finalize materialize → sends>=2.
|
|
1254
|
-
const m = makeMock()
|
|
1255
|
-
const sendMessageDraft = vi.fn(async () => {})
|
|
1256
|
-
const stream = createDraftStream(m.send, m.edit, {
|
|
1257
|
-
throttleMs: 50,
|
|
1258
|
-
previewTransport: 'draft',
|
|
1259
|
-
sendMessageDraft,
|
|
1260
|
-
chatId: 'chat-chain',
|
|
1261
|
-
persistSizeLimit: 200,
|
|
1262
|
-
})
|
|
1263
|
-
void stream.update('a'.repeat(100))
|
|
1264
|
-
await microtaskFlush()
|
|
1265
|
-
vi.advanceTimersByTime(300)
|
|
1266
|
-
void stream.update('a'.repeat(250)) // size trigger fires (tail=250 ≥ 200)
|
|
1267
|
-
await microtaskFlush()
|
|
1268
|
-
vi.advanceTimersByTime(300)
|
|
1269
|
-
await microtaskFlush()
|
|
1270
|
-
// Extra text after persist so finalize-materialize tail is non-empty.
|
|
1271
|
-
void stream.update('a'.repeat(250) + 'b'.repeat(50))
|
|
1272
|
-
await microtaskFlush()
|
|
1273
|
-
vi.advanceTimersByTime(300)
|
|
1274
|
-
await microtaskFlush()
|
|
1275
|
-
await stream.finalize()
|
|
1276
|
-
const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
|
|
1277
|
-
expect(endLine).toBeDefined()
|
|
1278
|
-
// Persist send + finalize materialize = at least 2.
|
|
1279
|
-
expect(endLine).toMatch(/sends=[2-9]/)
|
|
1280
|
-
expect(endLine).toMatch(/persists=[1-9]/)
|
|
1281
|
-
})
|
|
1282
|
-
})
|
|
1283
|
-
|
|
1284
|
-
})
|