switchroom 0.16.23 → 0.16.27
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 +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +804 -465
- package/dist/host-control/main.js +169 -163
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +4 -4
- package/telegram-plugin/answer-stream.ts +20 -49
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +113 -113
- package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
- package/telegram-plugin/dist/server.js +161 -161
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +427 -680
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +881 -633
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +39 -6
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/permission-timeout.ts +76 -0
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +39 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +43 -3
- package/telegram-plugin/retry-api-call.ts +42 -7
- package/telegram-plugin/rich-send.ts +86 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +29 -7
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +49 -98
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +88 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
|
@@ -1,78 +1,77 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Integration tests for stream-controller
|
|
3
|
-
* (issue #657).
|
|
2
|
+
* Integration tests for the stream-controller parse-failure fallback
|
|
3
|
+
* (issue #657, post-#2669 rich-message path).
|
|
4
4
|
*
|
|
5
|
-
* Contract:
|
|
6
|
-
* - When the FIRST
|
|
7
|
-
* `400 Bad Request: can't parse entities`,
|
|
8
|
-
*
|
|
9
|
-
* message_id to edit yet). Total outbound: ONE
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
5
|
+
* Contract (unchanged dup-message semantics, new rich transport):
|
|
6
|
+
* - When the FIRST `sendRichMessage({ markdown })` returns
|
|
7
|
+
* `400 Bad Request: can't parse entities`, recovery is a single fresh
|
|
8
|
+
* plain `sendMessage` (the raw markdown as a literal string, no parser)
|
|
9
|
+
* — no edit (there is no message_id to edit yet). Total outbound: ONE
|
|
10
|
+
* message_id, not two.
|
|
11
|
+
* - When a subsequent `editMessageText({ markdown })` returns the same
|
|
12
|
+
* 400, recovery is `editMessageText` AGAIN on the same message_id with
|
|
13
|
+
* a PLAIN string (no rich wrapper). Never a fresh send.
|
|
13
14
|
*
|
|
14
15
|
* The previous behaviour (the bug #657 fixes) was a duplicate plain-text
|
|
15
|
-
* sendMessage on every
|
|
16
|
-
* messages, one
|
|
16
|
+
* sendMessage on every parse rejection — visible to the user as two
|
|
17
|
+
* messages, one raw and one rendered correctly.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
import { describe, it, expect, vi } from 'vitest'
|
|
20
21
|
import { createStreamController } from '../stream-controller.js'
|
|
21
22
|
import { createFakeBotApi, errors } from './fake-bot-api.js'
|
|
22
23
|
|
|
23
|
-
describe('stream-controller
|
|
24
|
-
it('first send: parse-entities 400 → ONE retry
|
|
24
|
+
describe('stream-controller parse-failure fallback (#657)', () => {
|
|
25
|
+
it('first rich send: parse-entities 400 → ONE plain-text retry, same outbound count', async () => {
|
|
25
26
|
const bot = createFakeBotApi({ startMessageId: 1000 })
|
|
26
|
-
// Inject a parse-entities 400 for the first
|
|
27
|
-
// controller must catch it and retry
|
|
27
|
+
// Inject a parse-entities 400 for the first sendRichMessage. The
|
|
28
|
+
// controller must catch it and retry as a plain sendMessage.
|
|
28
29
|
bot.faults.next(
|
|
29
|
-
'
|
|
30
|
+
'sendRichMessage',
|
|
30
31
|
errors.badRequest("can't parse entities: Unsupported start tag \"frobnicate\""),
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
const stream = createStreamController({
|
|
34
35
|
bot: bot as unknown as { api: ReturnType<typeof createFakeBotApi>['api'] },
|
|
35
36
|
chatId: 'c1',
|
|
36
|
-
parseMode: 'HTML',
|
|
37
37
|
throttleMs: 0,
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
-
await stream.update('
|
|
40
|
+
await stream.update('**broken _markdown')
|
|
41
41
|
await stream.finalize()
|
|
42
42
|
|
|
43
43
|
// Exactly one message landed.
|
|
44
44
|
expect(bot.state.sent).toHaveLength(1)
|
|
45
|
-
// The recovery send
|
|
46
|
-
expect(bot.state.sent[0].
|
|
47
|
-
// The body
|
|
48
|
-
expect(bot.state.sent[0].text).
|
|
49
|
-
expect(bot.state.sent[0].text).not.toContain('<frobnicate>')
|
|
45
|
+
// The recovery send was a plain sendMessage, not a rich one.
|
|
46
|
+
expect(bot.state.sent[0].rich).toBeFalsy()
|
|
47
|
+
// The raw markdown body shipped verbatim as the literal fallback.
|
|
48
|
+
expect(bot.state.sent[0].text).toBe('**broken _markdown')
|
|
50
49
|
// The stream's message id matches the surviving send.
|
|
51
50
|
expect(stream.getMessageId()).toBe(bot.state.sent[0].message_id)
|
|
52
51
|
})
|
|
53
52
|
|
|
54
|
-
it('edit on existing message: parse-entities 400 → editMessageText
|
|
53
|
+
it('edit on existing message: parse-entities 400 → plain editMessageText on SAME id, never a fresh send', async () => {
|
|
55
54
|
const bot = createFakeBotApi({ startMessageId: 2000 })
|
|
56
55
|
|
|
57
56
|
const stream = createStreamController({
|
|
58
57
|
bot: bot as unknown as { api: ReturnType<typeof createFakeBotApi>['api'] },
|
|
59
58
|
chatId: 'c1',
|
|
60
|
-
parseMode: 'HTML',
|
|
61
59
|
throttleMs: 0,
|
|
62
60
|
})
|
|
63
61
|
|
|
64
|
-
// First update lands cleanly as a
|
|
65
|
-
await stream.update('
|
|
62
|
+
// First update lands cleanly as a rich send.
|
|
63
|
+
await stream.update('**v1**')
|
|
66
64
|
expect(bot.state.sent).toHaveLength(1)
|
|
65
|
+
expect(bot.state.sent[0].rich).toBe(true)
|
|
67
66
|
const firstId = bot.state.sent[0].message_id
|
|
68
67
|
|
|
69
68
|
// Second update: inject a parse-entities 400 on editMessageText.
|
|
70
69
|
bot.faults.next(
|
|
71
70
|
'editMessageText',
|
|
72
|
-
errors.badRequest("can't parse entities:
|
|
71
|
+
errors.badRequest("can't parse entities: Can't find end of the entity"),
|
|
73
72
|
)
|
|
74
73
|
|
|
75
|
-
await stream.update('
|
|
74
|
+
await stream.update('**v2 broken _extra')
|
|
76
75
|
await stream.finalize()
|
|
77
76
|
|
|
78
77
|
// Critical assertion: still ONE outbound message_id total — the
|
|
@@ -86,13 +85,13 @@ describe('stream-controller HTML parse-failure fallback (#657)', () => {
|
|
|
86
85
|
for (const call of editCalls) {
|
|
87
86
|
expect(call[1]).toBe(firstId) // same message_id
|
|
88
87
|
}
|
|
89
|
-
// The final edit
|
|
88
|
+
// The final edit passed a PLAIN string (the fallback), not { markdown }.
|
|
90
89
|
const finalCall = editCalls[editCalls.length - 1]
|
|
91
|
-
expect(finalCall[
|
|
92
|
-
|
|
90
|
+
expect(typeof finalCall[2]).toBe('string')
|
|
91
|
+
expect(finalCall[2]).toBe('**v2 broken _extra')
|
|
92
|
+
// The stored text reflects the plain-text fallback body.
|
|
93
93
|
const finalText = bot.state.currentText.get(firstId)
|
|
94
|
-
expect(finalText).
|
|
95
|
-
expect(finalText).not.toContain('<b>')
|
|
94
|
+
expect(finalText).toBe('**v2 broken _extra')
|
|
96
95
|
})
|
|
97
96
|
|
|
98
97
|
it('non-parse 400 (e.g. message-not-found) is NOT swallowed by the fallback', async () => {
|
|
@@ -101,25 +100,24 @@ describe('stream-controller HTML parse-failure fallback (#657)', () => {
|
|
|
101
100
|
const stream = createStreamController({
|
|
102
101
|
bot: bot as unknown as { api: ReturnType<typeof createFakeBotApi>['api'] },
|
|
103
102
|
chatId: 'c1',
|
|
104
|
-
parseMode: 'HTML',
|
|
105
103
|
throttleMs: 0,
|
|
106
104
|
})
|
|
107
105
|
|
|
108
|
-
await stream.update('
|
|
106
|
+
await stream.update('**v1**')
|
|
109
107
|
const firstId = bot.state.sent[0].message_id
|
|
110
108
|
|
|
111
109
|
// Inject message-not-found on the next edit. This is NOT a parse
|
|
112
110
|
// error — the existing not-found recovery in draft-stream.ts should
|
|
113
|
-
// handle it (clear messageId, re-send) and
|
|
114
|
-
//
|
|
111
|
+
// handle it (clear messageId, re-send) and the parse-fallback wrapper
|
|
112
|
+
// must let it propagate.
|
|
115
113
|
bot.faults.next('editMessageText', errors.messageToEditNotFound())
|
|
116
114
|
|
|
117
|
-
await stream.update('
|
|
115
|
+
await stream.update('**v2**')
|
|
118
116
|
await stream.finalize()
|
|
119
117
|
|
|
120
118
|
// The not-found recovery path produces a fresh send — that's the
|
|
121
|
-
// pre-existing contract. We're asserting it still fires
|
|
122
|
-
//
|
|
119
|
+
// pre-existing contract. We're asserting it still fires (i.e. the
|
|
120
|
+
// parse-fallback didn't accidentally catch this error class too).
|
|
123
121
|
// After: 1 original send + 1 re-send = 2 messages.
|
|
124
122
|
expect(bot.state.sent.length).toBeGreaterThanOrEqual(2)
|
|
125
123
|
expect(bot.state.sent[0].message_id).toBe(firstId)
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* Integration tests for `createStreamController` — the wiring layer
|
|
3
3
|
* between `createDraftStream` and grammy's `bot.api`.
|
|
4
4
|
*
|
|
5
|
+
* Post-#2669: the default path sends via `bot.api.sendRichMessage(chat,
|
|
6
|
+
* { markdown }, opts)` and edits via `bot.api.editMessageText(chat, id,
|
|
7
|
+
* { markdown }, opts)`. A `literalText: true` stream takes the plain
|
|
8
|
+
* `sendMessage` / plain-string `editMessageText` path instead. Both share
|
|
9
|
+
* one opts object, so a rich send is always followed by a rich edit.
|
|
10
|
+
*
|
|
5
11
|
* Uses the ported openclaw-style mock bot harness (`bot-api.harness.ts`).
|
|
6
12
|
*/
|
|
7
13
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
@@ -15,32 +21,32 @@ describe('createStreamController', () => {
|
|
|
15
21
|
beforeEach(() => vi.useFakeTimers())
|
|
16
22
|
afterEach(() => vi.useRealTimers())
|
|
17
23
|
|
|
18
|
-
it('first update calls bot.api.
|
|
24
|
+
it('first update calls bot.api.sendRichMessage with raw markdown + options', async () => {
|
|
19
25
|
const stream = createStreamController({
|
|
20
26
|
bot,
|
|
21
27
|
chatId: '123',
|
|
22
28
|
threadId: 42,
|
|
23
|
-
parseMode: 'HTML',
|
|
24
29
|
throttleMs: 1000,
|
|
25
30
|
})
|
|
26
31
|
|
|
27
|
-
void stream.update('
|
|
32
|
+
void stream.update('**hi**')
|
|
28
33
|
await microtaskFlush()
|
|
29
34
|
|
|
30
|
-
expect(bot.api.
|
|
35
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
36
|
+
expect(bot.api.sendMessage).not.toHaveBeenCalled()
|
|
31
37
|
expect(bot.api.editMessageText).not.toHaveBeenCalled()
|
|
32
|
-
const [chat_id,
|
|
38
|
+
const [chat_id, rich, opts] = bot.api.sendRichMessage.mock.calls[0]
|
|
33
39
|
expect(chat_id).toBe('123')
|
|
34
|
-
|
|
35
|
-
expect(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
// Raw GFM markdown passed straight through, unescaped, no HTML.
|
|
41
|
+
expect(rich).toEqual({ markdown: '**hi**' })
|
|
42
|
+
expect(opts).toMatchObject({ message_thread_id: 42 })
|
|
43
|
+
// sendRichMessage does NOT accept link_preview_options (stripped on the
|
|
44
|
+
// rich send path).
|
|
45
|
+
expect(opts.link_preview_options).toBeUndefined()
|
|
40
46
|
expect(stream.getMessageId()).toBe(500)
|
|
41
47
|
})
|
|
42
48
|
|
|
43
|
-
it('subsequent updates call editMessageText against the captured id', async () => {
|
|
49
|
+
it('subsequent updates call editMessageText({ markdown }) against the captured id', async () => {
|
|
44
50
|
const stream = createStreamController({ bot, chatId: '123', throttleMs: 1000 })
|
|
45
51
|
|
|
46
52
|
void stream.update('step 1')
|
|
@@ -49,15 +55,33 @@ describe('createStreamController', () => {
|
|
|
49
55
|
void stream.update('step 1 — step 2')
|
|
50
56
|
await microtaskFlush()
|
|
51
57
|
|
|
52
|
-
expect(bot.api.
|
|
58
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
53
59
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
54
60
|
const [chat_id, id, text] = bot.api.editMessageText.mock.calls[0]
|
|
55
61
|
expect(chat_id).toBe('123')
|
|
56
62
|
expect(id).toBe(500)
|
|
57
|
-
|
|
63
|
+
// The edit carries the rich-message wrapper, not a plain string.
|
|
64
|
+
expect(text).toEqual({ markdown: 'step 1 — step 2' })
|
|
58
65
|
})
|
|
59
66
|
|
|
60
|
-
it('
|
|
67
|
+
it('literalText:true takes the plain sendMessage / plain-string edit path', async () => {
|
|
68
|
+
const stream = createStreamController({ bot, chatId: '123', throttleMs: 1000, literalText: true })
|
|
69
|
+
|
|
70
|
+
void stream.update('raw < text >')
|
|
71
|
+
await microtaskFlush()
|
|
72
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
|
|
73
|
+
expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
|
|
74
|
+
expect(bot.api.sendMessage.mock.calls[0][1]).toBe('raw < text >')
|
|
75
|
+
|
|
76
|
+
vi.advanceTimersByTime(1000)
|
|
77
|
+
void stream.update('raw < text > more')
|
|
78
|
+
await microtaskFlush()
|
|
79
|
+
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
80
|
+
// Literal edits pass a plain string, not { markdown }.
|
|
81
|
+
expect(bot.api.editMessageText.mock.calls[0][2]).toBe('raw < text > more')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('finalize() flushes pending text as a rich edit immediately', async () => {
|
|
61
85
|
const stream = createStreamController({ bot, chatId: '123', throttleMs: 1000 })
|
|
62
86
|
|
|
63
87
|
void stream.update('draft')
|
|
@@ -69,7 +93,7 @@ describe('createStreamController', () => {
|
|
|
69
93
|
await stream.finalize()
|
|
70
94
|
|
|
71
95
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
72
|
-
expect(bot.api.editMessageText.mock.calls[0][2]).
|
|
96
|
+
expect(bot.api.editMessageText.mock.calls[0][2]).toEqual({ markdown: 'final' })
|
|
73
97
|
expect(stream.isFinal()).toBe(true)
|
|
74
98
|
})
|
|
75
99
|
|
|
@@ -96,18 +120,20 @@ describe('createStreamController', () => {
|
|
|
96
120
|
expect(onEdit).toHaveBeenCalledWith(500, 11)
|
|
97
121
|
})
|
|
98
122
|
|
|
99
|
-
it('
|
|
100
|
-
const stream = createStreamController({ bot, chatId: '1' })
|
|
101
|
-
void stream.update('
|
|
123
|
+
it('rich send strips link_preview_options but keeps message_thread_id', async () => {
|
|
124
|
+
const stream = createStreamController({ bot, chatId: '1', threadId: 9 })
|
|
125
|
+
void stream.update('hi')
|
|
102
126
|
await microtaskFlush()
|
|
103
|
-
|
|
127
|
+
const opts = bot.api.sendRichMessage.mock.calls[0][2]
|
|
128
|
+
expect(opts.link_preview_options).toBeUndefined()
|
|
129
|
+
expect(opts.message_thread_id).toBe(9)
|
|
104
130
|
})
|
|
105
131
|
|
|
106
132
|
it('omits message_thread_id when threadId is undefined', async () => {
|
|
107
133
|
const stream = createStreamController({ bot, chatId: '1' })
|
|
108
134
|
void stream.update('no thread')
|
|
109
135
|
await microtaskFlush()
|
|
110
|
-
expect(bot.api.
|
|
136
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.message_thread_id).toBeUndefined()
|
|
111
137
|
})
|
|
112
138
|
|
|
113
139
|
it('rapid updates collapse to the latest; exactly one edit lands', async () => {
|
|
@@ -115,7 +141,7 @@ describe('createStreamController', () => {
|
|
|
115
141
|
|
|
116
142
|
void stream.update('initial')
|
|
117
143
|
await microtaskFlush()
|
|
118
|
-
expect(bot.api.
|
|
144
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
119
145
|
|
|
120
146
|
void stream.update('a')
|
|
121
147
|
void stream.update('b')
|
|
@@ -127,7 +153,7 @@ describe('createStreamController', () => {
|
|
|
127
153
|
await microtaskFlush()
|
|
128
154
|
|
|
129
155
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
130
|
-
expect(bot.api.editMessageText.mock.calls[0][2]).
|
|
156
|
+
expect(bot.api.editMessageText.mock.calls[0][2]).toEqual({ markdown: 'c' })
|
|
131
157
|
})
|
|
132
158
|
|
|
133
159
|
it('treats "message is not modified" edit errors as success (swallow)', async () => {
|
|
@@ -159,7 +185,7 @@ describe('createStreamController', () => {
|
|
|
159
185
|
expect(retry.mock.calls[0][1]).toEqual({ threadId: undefined, chat_id: '1' })
|
|
160
186
|
})
|
|
161
187
|
|
|
162
|
-
it('replyMarkup is included in
|
|
188
|
+
it('replyMarkup is included in sendRichMessage opts', async () => {
|
|
163
189
|
const keyboard = { inline_keyboard: [[{ text: 'Steer', callback_data: 'steer:1' }]] }
|
|
164
190
|
const stream = createStreamController({
|
|
165
191
|
bot, chatId: '1', throttleMs: 1000, replyMarkup: keyboard,
|
|
@@ -167,11 +193,11 @@ describe('createStreamController', () => {
|
|
|
167
193
|
void stream.update('hello')
|
|
168
194
|
await microtaskFlush()
|
|
169
195
|
|
|
170
|
-
expect(bot.api.
|
|
171
|
-
expect(bot.api.
|
|
196
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
197
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBe(keyboard)
|
|
172
198
|
})
|
|
173
199
|
|
|
174
|
-
it('replyMarkup
|
|
200
|
+
it('replyMarkup rides on both the rich send AND the rich edit', async () => {
|
|
175
201
|
const keyboard = { inline_keyboard: [[{ text: 'Steer', callback_data: 'steer:1' }]] }
|
|
176
202
|
const stream = createStreamController({
|
|
177
203
|
bot, chatId: '1', throttleMs: 1000, replyMarkup: keyboard,
|
|
@@ -183,6 +209,7 @@ describe('createStreamController', () => {
|
|
|
183
209
|
void stream.update('second')
|
|
184
210
|
await microtaskFlush()
|
|
185
211
|
|
|
212
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBe(keyboard)
|
|
186
213
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
187
214
|
expect(bot.api.editMessageText.mock.calls[0][3]?.reply_markup).toBe(keyboard)
|
|
188
215
|
})
|
|
@@ -206,11 +233,11 @@ describe('createStreamController', () => {
|
|
|
206
233
|
const stream = createStreamController({ bot, chatId: '1', throttleMs: 1000 })
|
|
207
234
|
void stream.update('hello')
|
|
208
235
|
await microtaskFlush()
|
|
209
|
-
expect(bot.api.
|
|
236
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBeUndefined()
|
|
210
237
|
})
|
|
211
238
|
|
|
212
239
|
it('send failure is swallowed by draft-stream loop (pinned behaviour)', async () => {
|
|
213
|
-
bot.api.
|
|
240
|
+
bot.api.sendRichMessage.mockImplementationOnce(async () => {
|
|
214
241
|
throw new Error('network down')
|
|
215
242
|
})
|
|
216
243
|
|
|
@@ -218,45 +245,29 @@ describe('createStreamController', () => {
|
|
|
218
245
|
void stream.update('will fail')
|
|
219
246
|
await microtaskFlush()
|
|
220
247
|
|
|
221
|
-
expect(bot.api.
|
|
248
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
222
249
|
expect(stream.getMessageId()).toBeNull()
|
|
223
|
-
// This pins the current "swallow and continue" semantics. When we
|
|
224
|
-
// harden error handling later, this test flips to expect a thrown
|
|
225
|
-
// error or a status-reaction signal — whichever we decide.
|
|
226
250
|
})
|
|
227
251
|
|
|
228
|
-
it('initialMessageId — first update edits the supplied id, no
|
|
229
|
-
// The full-stack proof of the gateway-emit idempotency hook: the
|
|
230
|
-
// pin manager hands us an existing message id, the controller
|
|
231
|
-
// initializes its state to that id, and the very first update
|
|
232
|
-
// routes to bot.api.editMessageText against id 7777 — NEVER to
|
|
233
|
-
// bot.api.sendMessage. This is the invariant that makes "multiple
|
|
234
|
-
// status messages per turn" structurally impossible when the pin
|
|
235
|
-
// manager has tracked a card for the current turnKey.
|
|
252
|
+
it('initialMessageId — first update edits the supplied id via rich edit, no send fires (#626)', async () => {
|
|
236
253
|
const stream = createStreamController({
|
|
237
254
|
bot,
|
|
238
255
|
chatId: '999',
|
|
239
256
|
threadId: 7,
|
|
240
|
-
parseMode: 'HTML',
|
|
241
257
|
throttleMs: 1000,
|
|
242
258
|
initialMessageId: 7777,
|
|
243
259
|
})
|
|
244
|
-
void stream.update('
|
|
260
|
+
void stream.update('**edit-only**')
|
|
245
261
|
await microtaskFlush()
|
|
262
|
+
expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
|
|
246
263
|
expect(bot.api.sendMessage).not.toHaveBeenCalled()
|
|
247
264
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
248
265
|
const [chat_id, id, text, opts] = bot.api.editMessageText.mock.calls[0]
|
|
249
266
|
expect(chat_id).toBe('999')
|
|
250
267
|
expect(id).toBe(7777)
|
|
251
|
-
expect(text).
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
message_thread_id: 7,
|
|
255
|
-
link_preview_options: { is_disabled: true },
|
|
256
|
-
})
|
|
257
|
-
// The handle reports the supplied id immediately so the gateway
|
|
258
|
-
// can chain pinManager.considerPin with the right id even on the
|
|
259
|
-
// first edit.
|
|
268
|
+
expect(text).toEqual({ markdown: '**edit-only**' })
|
|
269
|
+
// The edit keeps message_thread_id but not reply_parameters/protect.
|
|
270
|
+
expect(opts).toMatchObject({ message_thread_id: 7 })
|
|
260
271
|
expect(stream.getMessageId()).toBe(7777)
|
|
261
272
|
})
|
|
262
273
|
})
|
|
@@ -3,30 +3,28 @@
|
|
|
3
3
|
* fake bot API (with fault injection) and the real retry policy.
|
|
4
4
|
*
|
|
5
5
|
* These tests exercise the full error chain (fake GrammyError → retry
|
|
6
|
-
* policy → draft-stream → caller result) end to end.
|
|
7
|
-
*
|
|
6
|
+
* policy → draft-stream → caller result) end to end. Post-#2669 the
|
|
7
|
+
* default path is the rich-message send (`sendRichMessage`) / rich edit
|
|
8
|
+
* (`editMessageText({ markdown })`), so faults are injected on those
|
|
9
|
+
* methods and the wire body is the raw GFM markdown (no HTML render).
|
|
8
10
|
*
|
|
9
11
|
* NOTE on draft-stream behaviour: the current implementation SILENTLY
|
|
10
12
|
* SWALLOWS any error that isn't specifically recognised (not-modified,
|
|
11
|
-
* message-to-edit-not-found). A 403, thread-not-found, or
|
|
12
|
-
* all end up logged and dropped. The handler's "finalized
|
|
13
|
-
* sending any message" check
|
|
14
|
-
*
|
|
15
|
-
* that contract rather than the one a strict API would have — changing
|
|
16
|
-
* it is tracked separately.
|
|
13
|
+
* message-to-edit-not-found, parse-entities). A 403, thread-not-found, or
|
|
14
|
+
* exhausted 429 all end up logged and dropped. The handler's "finalized
|
|
15
|
+
* without sending any message" check is the only signal a caller gets
|
|
16
|
+
* that something went wrong.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
20
20
|
import { handleStreamReply, type StreamReplyDeps, type StreamReplyState } from '../stream-reply-handler.js'
|
|
21
21
|
import type { DraftStreamHandle } from '../draft-stream.js'
|
|
22
|
-
import { markdownToHtml as realMarkdownToHtml } from '../format.js'
|
|
23
22
|
import { createRetryApiCall } from '../retry-api-call.js'
|
|
24
23
|
import { createFakeBotApi, errors, type FakeBot } from './fake-bot-api.js'
|
|
25
24
|
|
|
26
25
|
function makeState(): StreamReplyState {
|
|
27
26
|
return {
|
|
28
27
|
activeDraftStreams: new Map<string, DraftStreamHandle>(),
|
|
29
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
30
28
|
}
|
|
31
29
|
}
|
|
32
30
|
|
|
@@ -37,13 +35,12 @@ function makeDeps(
|
|
|
37
35
|
return {
|
|
38
36
|
bot: bot as unknown as StreamReplyDeps['bot'],
|
|
39
37
|
retry: createRetryApiCall(),
|
|
40
|
-
markdownToHtml: (t) => realMarkdownToHtml(t),
|
|
41
|
-
escapeMarkdownV2: (t) => t,
|
|
42
38
|
repairEscapedWhitespace: (t) => t,
|
|
43
39
|
assertAllowedChat: () => {},
|
|
44
40
|
resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
|
|
45
41
|
disableLinkPreview: true,
|
|
46
|
-
|
|
42
|
+
// Anything other than the literal 'text' is the rich-markdown path.
|
|
43
|
+
defaultFormat: 'markdown',
|
|
47
44
|
logStreamingEvent: () => {},
|
|
48
45
|
historyEnabled: false,
|
|
49
46
|
recordOutbound: () => {},
|
|
@@ -55,12 +52,7 @@ function makeDeps(
|
|
|
55
52
|
|
|
56
53
|
/**
|
|
57
54
|
* Pump vi fake timers + microtasks until all pending work settles.
|
|
58
|
-
* The draft-stream's `flushLoop` has setTimeout(0) schedules that need
|
|
59
|
-
* explicit advancement even at throttleMs=0, plus intermediate awaits.
|
|
60
|
-
*
|
|
61
55
|
* vi.advanceTimersByTimeAsync isn't implemented under bun's test runner.
|
|
62
|
-
* Polyfill via the sync advance + explicit microtask flush so this file
|
|
63
|
-
* runs cleanly under both vitest and `bun test`.
|
|
64
56
|
*/
|
|
65
57
|
async function advanceFakeClock(ms: number): Promise<void> {
|
|
66
58
|
const viAny = vi as { advanceTimersByTimeAsync?: (ms: number) => Promise<void> }
|
|
@@ -91,8 +83,8 @@ describe('stream-reply-handler × real retry × fake bot', () => {
|
|
|
91
83
|
})
|
|
92
84
|
|
|
93
85
|
describe('flood-wait retry', () => {
|
|
94
|
-
it('retries after retry_after and the message lands', async () => {
|
|
95
|
-
bot.faults.next('
|
|
86
|
+
it('retries after retry_after and the rich message lands', async () => {
|
|
87
|
+
bot.faults.next('sendRichMessage', errors.floodWait(2, 'sendRichMessage'))
|
|
96
88
|
const state = makeState()
|
|
97
89
|
const deps = makeDeps(bot)
|
|
98
90
|
|
|
@@ -109,7 +101,8 @@ describe('stream-reply-handler × real retry × fake bot', () => {
|
|
|
109
101
|
expect(result.status).toBe('finalized')
|
|
110
102
|
expect(result.messageId).toBe(500)
|
|
111
103
|
expect(bot.state.sent).toHaveLength(1)
|
|
112
|
-
expect(bot.
|
|
104
|
+
expect(bot.state.sent[0].rich).toBe(true)
|
|
105
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(2)
|
|
113
106
|
})
|
|
114
107
|
|
|
115
108
|
it('flood-wait on editMessageText is transparent to caller', async () => {
|
|
@@ -128,16 +121,12 @@ describe('stream-reply-handler × real retry × fake bot', () => {
|
|
|
128
121
|
const r = await pending
|
|
129
122
|
|
|
130
123
|
expect(r.status).toBe('updated')
|
|
131
|
-
|
|
124
|
+
// Raw markdown is the stored body — no HTML render.
|
|
125
|
+
expect(bot.state.currentText.get(500)).toBe('v2')
|
|
132
126
|
})
|
|
133
127
|
})
|
|
134
128
|
|
|
135
129
|
describe('editMessageText transient failures (swallowed by retry)', () => {
|
|
136
|
-
// These tests use REAL timers because draft-stream's throttling
|
|
137
|
-
// sequence across two updates is hard to deterministically pump with
|
|
138
|
-
// fake timers (the setTimeout(0) schedule is inside a promise chain
|
|
139
|
-
// that re-enters after the first flush completes). Real timers +
|
|
140
|
-
// a tiny throttle keeps the test fast and reliable.
|
|
141
130
|
beforeEach(() => vi.useRealTimers())
|
|
142
131
|
|
|
143
132
|
it('retry returns undefined for "message is not modified"; stream continues', async () => {
|
|
@@ -170,17 +159,8 @@ describe('stream-reply-handler × real retry × fake bot', () => {
|
|
|
170
159
|
})
|
|
171
160
|
|
|
172
161
|
describe('non-retryable errors (current behaviour: swallowed by draft-stream)', () => {
|
|
173
|
-
// The following tests document the current swallow-all contract in
|
|
174
|
-
// draft-stream.ts. When a send fails with 403 / thread-not-found / a
|
|
175
|
-
// retry-exhausted network error, draft-stream's catch-all at line 182
|
|
176
|
-
// logs + continues. The HANDLER then sees getMessageId() == null on
|
|
177
|
-
// finalize and throws the "finalized without sending" error.
|
|
178
|
-
//
|
|
179
|
-
// If draft-stream grows a lastError hook later, these assertions
|
|
180
|
-
// should be tightened to match the real underlying error.
|
|
181
|
-
|
|
182
162
|
it('403 forbidden surfaces as "finalized without sending" on done=true', async () => {
|
|
183
|
-
bot.faults.next('
|
|
163
|
+
bot.faults.next('sendRichMessage', errors.forbidden('sendRichMessage'))
|
|
184
164
|
const state = makeState()
|
|
185
165
|
const deps = makeDeps(bot)
|
|
186
166
|
|
|
@@ -190,7 +170,7 @@ describe('stream-reply-handler × real retry × fake bot', () => {
|
|
|
190
170
|
})
|
|
191
171
|
|
|
192
172
|
it('thread-not-found surfaces as "finalized without sending" on done=true', async () => {
|
|
193
|
-
bot.faults.next('
|
|
173
|
+
bot.faults.next('sendRichMessage', errors.threadNotFound('sendRichMessage'))
|
|
194
174
|
const state = makeState()
|
|
195
175
|
const deps = makeDeps(bot)
|
|
196
176
|
|
|
@@ -203,4 +183,29 @@ describe('stream-reply-handler × real retry × fake bot', () => {
|
|
|
203
183
|
).rejects.toThrowError(/finalized without sending/)
|
|
204
184
|
})
|
|
205
185
|
})
|
|
186
|
+
|
|
187
|
+
describe('parse-entities fallback (#657) end to end', () => {
|
|
188
|
+
it('first rich send rejected for bad markdown → retries SAME turn as plain text, one message', async () => {
|
|
189
|
+
bot.faults.next(
|
|
190
|
+
'sendRichMessage',
|
|
191
|
+
errors.badRequest("can't parse entities: bad markdown", 'sendRichMessage'),
|
|
192
|
+
)
|
|
193
|
+
const state = makeState()
|
|
194
|
+
const deps = makeDeps(bot)
|
|
195
|
+
|
|
196
|
+
const pending = handleStreamReply(
|
|
197
|
+
{ chat_id: 'c', text: '**broken _markdown', done: true },
|
|
198
|
+
state,
|
|
199
|
+
deps,
|
|
200
|
+
)
|
|
201
|
+
await settle(0)
|
|
202
|
+
const result = await pending
|
|
203
|
+
|
|
204
|
+
expect(result.status).toBe('finalized')
|
|
205
|
+
// Exactly one message — the plain-text recovery send.
|
|
206
|
+
expect(bot.state.sent).toHaveLength(1)
|
|
207
|
+
expect(bot.state.sent[0].rich).toBeFalsy()
|
|
208
|
+
expect(bot.state.sent[0].text).toBe('**broken _markdown')
|
|
209
|
+
})
|
|
210
|
+
})
|
|
206
211
|
})
|