switchroom 0.12.28 → 0.13.0
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 +81 -80
- package/dist/auth-broker/index.js +81 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +361 -357
- package/dist/host-control/main.js +100 -99
- package/dist/vault/approvals/kernel-server.js +83 -82
- package/dist/vault/broker/server.js +84 -83
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +392 -208
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/draft-stream.ts +287 -11
- package/telegram-plugin/draft-transport.ts +50 -0
- package/telegram-plugin/gateway/gateway.ts +73 -10
- package/telegram-plugin/gateway/prefix-warmup.ts +123 -0
- package/telegram-plugin/stream-reply-handler.ts +3 -1
- package/telegram-plugin/tests/draft-stream.test.ts +453 -0
- package/telegram-plugin/tests/draft-transport.test.ts +70 -0
- package/telegram-plugin/tests/prefix-warmup.test.ts +175 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix-cache warmup turn — opt-in cold-start TTFO optimization.
|
|
3
|
+
*
|
|
4
|
+
* Per cold-start TTFO RFC (docs/rfcs/cold-start-ttfo.md, PR #1589),
|
|
5
|
+
* Option A. On every bridge-up after a restart, synthesize a synthetic
|
|
6
|
+
* inbound (`__WARMUP_PING__`, meta.source="warmup") and deliver it to
|
|
7
|
+
* the just-registered bridge. Claude processes the message — paying
|
|
8
|
+
* the full cold-cache cost on the synthetic turn — and responds
|
|
9
|
+
* `NO_REPLY` per the in-prompt instruction. The existing NO_REPLY
|
|
10
|
+
* suppression at `gateway.ts:5949` swallows the outbound.
|
|
11
|
+
*
|
|
12
|
+
* By the time the user's REAL next message arrives, Anthropic's prefix
|
|
13
|
+
* cache is warm and the user-perceived TTFO drops 4-8s on average.
|
|
14
|
+
*
|
|
15
|
+
* Phase 1 (this file): minimum-viable warmup. AGENT.md is NOT modified
|
|
16
|
+
* — the warmup TEXT carries the NO_REPLY instruction inline. Agent
|
|
17
|
+
* compliance is best-effort; non-compliant agents will emit a real
|
|
18
|
+
* reply to the primary chat (acceptable UX cost gated behind opt-in
|
|
19
|
+
* env var). Cooldown prevents the gymbro-style bridge-churn case from
|
|
20
|
+
* burning OAuth quota on every flap.
|
|
21
|
+
*
|
|
22
|
+
* Kill switch: `SWITCHROOM_PREFIX_WARMUP=1` opt-in (default OFF).
|
|
23
|
+
*
|
|
24
|
+
* Future PR (Phase 2): suppress 👀 reaction + progress card for
|
|
25
|
+
* meta.source="warmup" inbound; tag for Hindsight exclusion.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { IpcClient } from './ipc-server.js'
|
|
29
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
30
|
+
|
|
31
|
+
// Per cold-start RFC open-question #4: cooldown anchored on bridge-up
|
|
32
|
+
// time; conservative 5-minute window catches gymbro-style 6-reconnects-
|
|
33
|
+
// per-UAT-cycle without dropping legitimate every-restart warmups.
|
|
34
|
+
const WARMUP_COOLDOWN_MS = 5 * 60_000
|
|
35
|
+
|
|
36
|
+
const lastWarmupAtPerAgent = new Map<string, number>()
|
|
37
|
+
|
|
38
|
+
export const WARMUP_TEXT =
|
|
39
|
+
'__WARMUP_PING__\n\nThis is a system prefix-cache warmup (not from a user). ' +
|
|
40
|
+
'Respond with exactly `NO_REPLY` and nothing else. ' +
|
|
41
|
+
'The gateway will suppress the response — no message will be sent to anyone.'
|
|
42
|
+
|
|
43
|
+
export interface WarmupCtx {
|
|
44
|
+
readonly selfAgent: string
|
|
45
|
+
readonly client: IpcClient
|
|
46
|
+
readonly resolveBootTarget: () =>
|
|
47
|
+
| { chatId: string; threadId?: number | undefined }
|
|
48
|
+
| null
|
|
49
|
+
readonly log?: (line: string) => void
|
|
50
|
+
readonly now?: () => number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fire a prefix-cache warmup if conditions are met. Idempotent within
|
|
55
|
+
* the cooldown window. Returns true when a warmup was actually sent.
|
|
56
|
+
*
|
|
57
|
+
* Conditions:
|
|
58
|
+
* 1. `SWITCHROOM_PREFIX_WARMUP=1` env var set (opt-in).
|
|
59
|
+
* 2. Cooldown elapsed for this agent (default 5 min).
|
|
60
|
+
* 3. A boot chat target resolves (no point warming without a chat).
|
|
61
|
+
*
|
|
62
|
+
* The warmup is delivered to `client.send()` directly — it bypasses
|
|
63
|
+
* the gateway's `handleInbound`, which gates on a real Telegram
|
|
64
|
+
* Context object. The bridge forwards to claude exactly as it would a
|
|
65
|
+
* Telegram message.
|
|
66
|
+
*/
|
|
67
|
+
export function maybeFireWarmup(ctx: WarmupCtx): boolean {
|
|
68
|
+
if (process.env.SWITCHROOM_PREFIX_WARMUP !== '1') return false
|
|
69
|
+
|
|
70
|
+
const log = ctx.log ?? ((line: string) => process.stderr.write(line))
|
|
71
|
+
const now = (ctx.now ?? Date.now)()
|
|
72
|
+
|
|
73
|
+
const lastAt = lastWarmupAtPerAgent.get(ctx.selfAgent) ?? 0
|
|
74
|
+
if (now - lastAt < WARMUP_COOLDOWN_MS) {
|
|
75
|
+
log(
|
|
76
|
+
`telegram gateway: prefix-warmup skipped agent=${ctx.selfAgent} ` +
|
|
77
|
+
`reason=cooldown last=${Math.round((now - lastAt) / 1000)}s ago\n`,
|
|
78
|
+
)
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const target = ctx.resolveBootTarget()
|
|
83
|
+
if (!target) {
|
|
84
|
+
log(
|
|
85
|
+
`telegram gateway: prefix-warmup skipped agent=${ctx.selfAgent} ` +
|
|
86
|
+
`reason=no-boot-chat-target\n`,
|
|
87
|
+
)
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const msg: InboundMessage = {
|
|
92
|
+
type: 'inbound',
|
|
93
|
+
chatId: target.chatId,
|
|
94
|
+
...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
|
|
95
|
+
messageId: 0, // synthetic — never matches a real Telegram message
|
|
96
|
+
user: 'switchroom-warmup',
|
|
97
|
+
userId: 0,
|
|
98
|
+
ts: Math.floor(now / 1000),
|
|
99
|
+
text: WARMUP_TEXT,
|
|
100
|
+
meta: { source: 'warmup' },
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
ctx.client.send(msg)
|
|
105
|
+
lastWarmupAtPerAgent.set(ctx.selfAgent, now)
|
|
106
|
+
log(
|
|
107
|
+
`telegram gateway: prefix-warmup fired agent=${ctx.selfAgent} ` +
|
|
108
|
+
`chat=${target.chatId} thread=${target.threadId ?? '-'}\n`,
|
|
109
|
+
)
|
|
110
|
+
return true
|
|
111
|
+
} catch (err) {
|
|
112
|
+
log(
|
|
113
|
+
`telegram gateway: prefix-warmup send threw agent=${ctx.selfAgent}: ` +
|
|
114
|
+
`${(err as Error).message}\n`,
|
|
115
|
+
)
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Test hook: reset the cooldown state. */
|
|
121
|
+
export function __resetForTests(): void {
|
|
122
|
+
lastWarmupAtPerAgent.clear()
|
|
123
|
+
}
|
|
@@ -514,7 +514,9 @@ export async function handleStreamReply(
|
|
|
514
514
|
threadId,
|
|
515
515
|
parseMode,
|
|
516
516
|
disableLinkPreview: deps.disableLinkPreview,
|
|
517
|
-
|
|
517
|
+
// PR B: pass undefined when caller didn't override, so draft-stream's
|
|
518
|
+
// transport-aware default (300 ms draft / 1000 ms message) wins.
|
|
519
|
+
...(deps.throttleMs != null ? { throttleMs: deps.throttleMs } : {}),
|
|
518
520
|
retry: deps.retry,
|
|
519
521
|
...(replyToMessageId != null ? { replyToMessageId } : {}),
|
|
520
522
|
...(args.quote_text != null && replyToMessageId != null ? { quoteText: args.quote_text } : {}),
|
|
@@ -749,4 +749,457 @@ describe('createDraftStream — draft transport', () => {
|
|
|
749
749
|
expect(callCount).toBeGreaterThan(1) // draft update + failed clear attempt
|
|
750
750
|
})
|
|
751
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
|
+
|
|
752
1205
|
})
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
shouldFallbackFromDraftTransport,
|
|
10
10
|
allocateDraftId,
|
|
11
11
|
__resetDraftIdForTests,
|
|
12
|
+
extractDraft429RetryAfterSecs,
|
|
13
|
+
isDraft429,
|
|
12
14
|
} from '../draft-transport.js'
|
|
13
15
|
|
|
14
16
|
describe('DRAFT_METHOD_UNAVAILABLE_RE', () => {
|
|
@@ -139,3 +141,71 @@ describe('allocateDraftId', () => {
|
|
|
139
141
|
expect(allocateDraftId()).toBe(1)
|
|
140
142
|
})
|
|
141
143
|
})
|
|
144
|
+
|
|
145
|
+
// ─── PR D: 429 + non-True helpers ──────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe('extractDraft429RetryAfterSecs (PR D)', () => {
|
|
148
|
+
it('returns retry_after on a grammY 429', () => {
|
|
149
|
+
const err = { error_code: 429, parameters: { retry_after: 7 } }
|
|
150
|
+
expect(extractDraft429RetryAfterSecs(err)).toBe(7)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('returns null on non-429 error_code', () => {
|
|
154
|
+
const err = { error_code: 400, parameters: { retry_after: 7 } }
|
|
155
|
+
expect(extractDraft429RetryAfterSecs(err)).toBeNull()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('returns null when retry_after is missing', () => {
|
|
159
|
+
const err = { error_code: 429 }
|
|
160
|
+
expect(extractDraft429RetryAfterSecs(err)).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('returns null when retry_after is non-positive', () => {
|
|
164
|
+
expect(extractDraft429RetryAfterSecs({ error_code: 429, parameters: { retry_after: 0 } })).toBeNull()
|
|
165
|
+
expect(extractDraft429RetryAfterSecs({ error_code: 429, parameters: { retry_after: -1 } })).toBeNull()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('returns null on primitive errors', () => {
|
|
169
|
+
expect(extractDraft429RetryAfterSecs(null)).toBeNull()
|
|
170
|
+
expect(extractDraft429RetryAfterSecs(undefined)).toBeNull()
|
|
171
|
+
expect(extractDraft429RetryAfterSecs('boom')).toBeNull()
|
|
172
|
+
expect(extractDraft429RetryAfterSecs(42)).toBeNull()
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('isDraft429 (PR D)', () => {
|
|
177
|
+
it('returns true when 429 carries method=sendMessageDraft', () => {
|
|
178
|
+
const err = {
|
|
179
|
+
error_code: 429,
|
|
180
|
+
parameters: { retry_after: 5 },
|
|
181
|
+
method: 'sendMessageDraft',
|
|
182
|
+
}
|
|
183
|
+
expect(isDraft429(err)).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('returns true when 429 description mentions sendMessageDraft', () => {
|
|
187
|
+
const err = {
|
|
188
|
+
error_code: 429,
|
|
189
|
+
parameters: { retry_after: 5 },
|
|
190
|
+
description: 'sendMessageDraft: Too Many Requests: retry after 5',
|
|
191
|
+
}
|
|
192
|
+
expect(isDraft429(err)).toBe(true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('returns false on 429 from a different method', () => {
|
|
196
|
+
const err = {
|
|
197
|
+
error_code: 429,
|
|
198
|
+
parameters: { retry_after: 5 },
|
|
199
|
+
method: 'sendMessage',
|
|
200
|
+
}
|
|
201
|
+
expect(isDraft429(err)).toBe(false)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('returns false on non-429 errors even when mentioning sendMessageDraft', () => {
|
|
205
|
+
const err = {
|
|
206
|
+
error_code: 400,
|
|
207
|
+
description: 'sendMessageDraft: bad request',
|
|
208
|
+
}
|
|
209
|
+
expect(isDraft429(err)).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
})
|