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,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Integration tests for the `stream_reply` MCP tool handler.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Post-#2669: there is one rendering path. The handler passes the raw GFM
|
|
5
|
+
* markdown straight to the stream controller, which sends it via
|
|
6
|
+
* `bot.api.sendRichMessage(chat, { markdown }, opts)` and edits via
|
|
7
|
+
* `bot.api.editMessageText(chat, id, { markdown }, opts)`. The only fork is
|
|
8
|
+
* `format:'text'` → the literal plain `sendMessage` path. There is no
|
|
9
|
+
* markdownToHtml / escapeMarkdownV2 dep and no activeDraftParseModes state.
|
|
7
10
|
*/
|
|
8
11
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
9
12
|
import {
|
|
@@ -12,7 +15,7 @@ import {
|
|
|
12
15
|
type StreamReplyState,
|
|
13
16
|
} from '../stream-reply-handler.js'
|
|
14
17
|
import type { DraftStreamHandle } from '../draft-stream.js'
|
|
15
|
-
import {
|
|
18
|
+
import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
16
19
|
import { createMockBot, installBotResetHook, microtaskFlush } from './bot-api.harness.js'
|
|
17
20
|
import {
|
|
18
21
|
handlePtyPartialPure,
|
|
@@ -22,7 +25,6 @@ import {
|
|
|
22
25
|
function makeState(): StreamReplyState {
|
|
23
26
|
return {
|
|
24
27
|
activeDraftStreams: new Map<string, DraftStreamHandle>(),
|
|
25
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -32,13 +34,12 @@ function makeDeps(
|
|
|
32
34
|
): StreamReplyDeps {
|
|
33
35
|
return {
|
|
34
36
|
bot,
|
|
35
|
-
markdownToHtml: (t) => `<b>${t}</b>`,
|
|
36
|
-
escapeMarkdownV2: (t) => `\\${t}\\`,
|
|
37
37
|
repairEscapedWhitespace: (t) => t,
|
|
38
38
|
assertAllowedChat: () => {},
|
|
39
39
|
resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
|
|
40
40
|
disableLinkPreview: true,
|
|
41
|
-
|
|
41
|
+
// Anything other than the literal 'text' is the rich-markdown path.
|
|
42
|
+
defaultFormat: 'markdown',
|
|
42
43
|
logStreamingEvent: () => {},
|
|
43
44
|
historyEnabled: false,
|
|
44
45
|
recordOutbound: () => {},
|
|
@@ -48,6 +49,15 @@ function makeDeps(
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** The raw markdown body of the i-th rich send. */
|
|
53
|
+
function richSendMarkdown(bot: ReturnType<typeof createMockBot>, i = 0): string {
|
|
54
|
+
return (bot.api.sendRichMessage.mock.calls[i][1] as { markdown: string }).markdown
|
|
55
|
+
}
|
|
56
|
+
/** The raw markdown body of the i-th rich edit. */
|
|
57
|
+
function richEditMarkdown(bot: ReturnType<typeof createMockBot>, i = 0): string {
|
|
58
|
+
return (bot.api.editMessageText.mock.calls[i][2] as { markdown: string }).markdown
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
describe('handleStreamReply', () => {
|
|
52
62
|
const bot = createMockBot()
|
|
53
63
|
installBotResetHook(bot)
|
|
@@ -55,7 +65,7 @@ describe('handleStreamReply', () => {
|
|
|
55
65
|
beforeEach(() => vi.useFakeTimers())
|
|
56
66
|
afterEach(() => vi.useRealTimers())
|
|
57
67
|
|
|
58
|
-
it('first call creates stream + sends
|
|
68
|
+
it('first call creates stream + sends raw markdown via sendRichMessage', async () => {
|
|
59
69
|
const state = makeState()
|
|
60
70
|
const deps = makeDeps(bot)
|
|
61
71
|
|
|
@@ -65,102 +75,81 @@ describe('handleStreamReply', () => {
|
|
|
65
75
|
|
|
66
76
|
expect(result.status).toBe('updated')
|
|
67
77
|
expect(result.messageId).toBe(500)
|
|
68
|
-
expect(bot.api.
|
|
69
|
-
expect(bot.api.sendMessage.
|
|
70
|
-
expect(bot
|
|
78
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
79
|
+
expect(bot.api.sendMessage).not.toHaveBeenCalled()
|
|
80
|
+
expect(richSendMarkdown(bot)).toBe('hi')
|
|
71
81
|
expect(state.activeDraftStreams.size).toBe(1)
|
|
72
82
|
})
|
|
73
83
|
|
|
74
|
-
it('
|
|
84
|
+
it('a non-text format ships raw GFM markdown unescaped (no parse_mode)', async () => {
|
|
75
85
|
const state = makeState()
|
|
76
86
|
const deps = makeDeps(bot)
|
|
77
87
|
|
|
78
88
|
const pending = handleStreamReply(
|
|
79
|
-
{ chat_id: '1', text: 'hi', format: '
|
|
89
|
+
{ chat_id: '1', text: '**hi** _there_', format: 'gfm' },
|
|
80
90
|
state,
|
|
81
91
|
deps,
|
|
82
92
|
)
|
|
83
93
|
await microtaskFlush()
|
|
84
94
|
await pending
|
|
85
95
|
|
|
86
|
-
expect(bot.api.
|
|
87
|
-
expect(bot
|
|
96
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
97
|
+
expect(richSendMarkdown(bot)).toBe('**hi** _there_')
|
|
98
|
+
// No parse_mode on rich opts.
|
|
99
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
|
|
88
100
|
})
|
|
89
101
|
|
|
90
|
-
it('respects format=text —
|
|
102
|
+
it('respects format=text — literal plain sendMessage, raw text', async () => {
|
|
91
103
|
const state = makeState()
|
|
92
104
|
const deps = makeDeps(bot)
|
|
93
105
|
|
|
94
106
|
const pending = handleStreamReply(
|
|
95
|
-
{ chat_id: '1', text: 'plain', format: 'text' },
|
|
107
|
+
{ chat_id: '1', text: 'plain < text >', format: 'text' },
|
|
96
108
|
state,
|
|
97
109
|
deps,
|
|
98
110
|
)
|
|
99
111
|
await microtaskFlush()
|
|
100
112
|
await pending
|
|
101
113
|
|
|
102
|
-
expect(bot.api.sendMessage
|
|
114
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
|
|
115
|
+
expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
|
|
116
|
+
expect(bot.api.sendMessage.mock.calls[0][1]).toBe('plain < text >')
|
|
103
117
|
expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
|
|
104
118
|
})
|
|
105
119
|
|
|
106
|
-
it('throws when text exceeds
|
|
107
|
-
// Pins the bug found in prod: a >4096-char text would hit draft-
|
|
108
|
-
// stream's length guard, silently stop, and the handler would return
|
|
109
|
-
// status:finalized, messageId:null — the MCP response read
|
|
110
|
-
// "finalized (id: pending)" looking like success. Fixed upstream by
|
|
111
|
-
// an over-limit pre-check that throws BEFORE touching stream state,
|
|
112
|
-
// so both first-send-over-limit AND mid-stream-over-limit fail loudly
|
|
113
|
-
// instead of corrupting the stream. done=true not required.
|
|
120
|
+
it('throws when text exceeds the rich-message cap (no silent id:pending)', async () => {
|
|
114
121
|
const state = makeState()
|
|
115
122
|
const deps = makeDeps(bot)
|
|
116
|
-
const tooLong = 'x'.repeat(
|
|
123
|
+
const tooLong = 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 1)
|
|
117
124
|
|
|
118
125
|
await expect(
|
|
119
126
|
handleStreamReply({ chat_id: '1', text: tooLong, done: true }, state, deps),
|
|
120
|
-
).rejects.toThrow(
|
|
127
|
+
).rejects.toThrow(new RegExp(`exceeds Telegram's ${RICH_MESSAGE_MAX_CHARS}-char`))
|
|
121
128
|
|
|
122
|
-
|
|
129
|
+
expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
|
|
123
130
|
expect(bot.api.sendMessage).not.toHaveBeenCalled()
|
|
124
131
|
})
|
|
125
132
|
|
|
126
133
|
it('mid-stream over-limit throws without corrupting stream state', async () => {
|
|
127
|
-
// A stream that starts small but a later update() goes over 4096.
|
|
128
|
-
// Before the upfront length check, the draft-stream would set its
|
|
129
|
-
// internal stopped=true flag and silently drop all further text —
|
|
130
|
-
// including the done=true final answer. The pre-check now throws
|
|
131
|
-
// on the over-limit call, leaving the stream intact so the caller
|
|
132
|
-
// can fall back to `reply`. The previously-sent short text stays
|
|
133
|
-
// visible in Telegram; the throw is the signal to the caller.
|
|
134
134
|
const state = makeState()
|
|
135
135
|
const deps = makeDeps(bot)
|
|
136
136
|
|
|
137
|
-
await handleStreamReply(
|
|
138
|
-
{ chat_id: '1', text: 'short' },
|
|
139
|
-
state,
|
|
140
|
-
deps,
|
|
141
|
-
)
|
|
137
|
+
await handleStreamReply({ chat_id: '1', text: 'short' }, state, deps)
|
|
142
138
|
await microtaskFlush()
|
|
143
|
-
expect(bot.api.
|
|
139
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
144
140
|
|
|
145
|
-
// Second call: now over limit.
|
|
146
141
|
await expect(
|
|
147
142
|
handleStreamReply(
|
|
148
|
-
{ chat_id: '1', text: 'y'.repeat(
|
|
143
|
+
{ chat_id: '1', text: 'y'.repeat(RICH_MESSAGE_MAX_CHARS + 1), done: true },
|
|
149
144
|
state,
|
|
150
145
|
deps,
|
|
151
146
|
),
|
|
152
|
-
).rejects.toThrow(
|
|
147
|
+
).rejects.toThrow(new RegExp(`exceeds Telegram's ${RICH_MESSAGE_MAX_CHARS}-char`))
|
|
153
148
|
|
|
154
|
-
|
|
155
|
-
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
|
|
149
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
156
150
|
})
|
|
157
151
|
|
|
158
152
|
it('done=true finalizes the stream but does NOT touch the reaction (#1713)', async () => {
|
|
159
|
-
// #1713: stream_reply done=true is a NON-EVENT for the status
|
|
160
|
-
// reaction. Stream completion is "I'm done speaking", not "turn
|
|
161
|
-
// over"; the model may continue with post-stream tool work. Only
|
|
162
|
-
// the gateway's turn_end IPC handler finalizes the reaction.
|
|
163
|
-
// This is a deliberate revert of the Bug Z fix (PR #602 follow-up).
|
|
164
153
|
const state = makeState()
|
|
165
154
|
const deps = makeDeps(bot)
|
|
166
155
|
|
|
@@ -177,10 +166,6 @@ describe('handleStreamReply', () => {
|
|
|
177
166
|
})
|
|
178
167
|
|
|
179
168
|
it('done=true on a named lane does NOT fire terminal 👍', async () => {
|
|
180
|
-
// Named lanes (lane:'progress', lane:'thinking', lane:'activity'
|
|
181
|
-
// etc.) are internal driver emits, not user-visible answers. They
|
|
182
|
-
// must not be allowed to claim turn-completion: a progress-lane
|
|
183
|
-
// emit firing setDone would race the actual answer message.
|
|
184
169
|
const state = makeState()
|
|
185
170
|
const deps = makeDeps(bot)
|
|
186
171
|
|
|
@@ -194,20 +179,16 @@ describe('handleStreamReply', () => {
|
|
|
194
179
|
})
|
|
195
180
|
|
|
196
181
|
it('done=true does NOT fire 👍 if finalize never produced a messageId', async () => {
|
|
197
|
-
// The over-limit branch throws before getMessageId() is non-null.
|
|
198
|
-
// Even if it didn't throw, a null messageId means the initial send
|
|
199
|
-
// never landed, so 👍 must not fire. Pinning that the gating on
|
|
200
|
-
// `getMessageId() != null` holds.
|
|
201
182
|
const state = makeState()
|
|
202
183
|
const deps = makeDeps(bot)
|
|
203
184
|
|
|
204
185
|
await expect(
|
|
205
186
|
handleStreamReply(
|
|
206
|
-
{ chat_id: '1', text: 'x'.repeat(
|
|
187
|
+
{ chat_id: '1', text: 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 1), done: true },
|
|
207
188
|
state,
|
|
208
189
|
deps,
|
|
209
190
|
),
|
|
210
|
-
).rejects.toThrow(
|
|
191
|
+
).rejects.toThrow(new RegExp(`exceeds Telegram's ${RICH_MESSAGE_MAX_CHARS}-char`))
|
|
211
192
|
})
|
|
212
193
|
|
|
213
194
|
it('done=true with historyEnabled records the final message row', async () => {
|
|
@@ -231,7 +212,7 @@ describe('handleStreamReply', () => {
|
|
|
231
212
|
chat_id: '1',
|
|
232
213
|
thread_id: 42,
|
|
233
214
|
message_ids: [500],
|
|
234
|
-
texts: ['final text'], // raw text
|
|
215
|
+
texts: ['final text'], // raw text
|
|
235
216
|
})
|
|
236
217
|
})
|
|
237
218
|
|
|
@@ -282,10 +263,11 @@ describe('handleStreamReply', () => {
|
|
|
282
263
|
handleStreamReply({ chat_id: 'evil', text: 'x' }, state, deps),
|
|
283
264
|
).rejects.toThrow('chat not allowed')
|
|
284
265
|
|
|
266
|
+
expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
|
|
285
267
|
expect(bot.api.sendMessage).not.toHaveBeenCalled()
|
|
286
268
|
})
|
|
287
269
|
|
|
288
|
-
it('subsequent calls reuse the same stream + edit in place', async () => {
|
|
270
|
+
it('subsequent calls reuse the same stream + rich-edit in place', async () => {
|
|
289
271
|
const state = makeState()
|
|
290
272
|
const deps = makeDeps(bot)
|
|
291
273
|
|
|
@@ -298,13 +280,13 @@ describe('handleStreamReply', () => {
|
|
|
298
280
|
await microtaskFlush()
|
|
299
281
|
await p2
|
|
300
282
|
|
|
301
|
-
expect(bot.api.
|
|
283
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
302
284
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
303
285
|
expect(bot.api.editMessageText.mock.calls[0][1]).toBe(500) // same id
|
|
304
|
-
expect(bot
|
|
286
|
+
expect(richEditMarkdown(bot)).toBe('step 2')
|
|
305
287
|
})
|
|
306
288
|
|
|
307
|
-
it('passes repairEscapedWhitespace through before
|
|
289
|
+
it('passes repairEscapedWhitespace through before sending', async () => {
|
|
308
290
|
const state = makeState()
|
|
309
291
|
const deps = makeDeps(bot, {
|
|
310
292
|
repairEscapedWhitespace: (t) => t.replace(/\\n/g, '\n'),
|
|
@@ -314,8 +296,8 @@ describe('handleStreamReply', () => {
|
|
|
314
296
|
await microtaskFlush()
|
|
315
297
|
await pending
|
|
316
298
|
|
|
317
|
-
// repair happens first;
|
|
318
|
-
expect(bot
|
|
299
|
+
// repair happens first; the repaired raw markdown is the wire payload.
|
|
300
|
+
expect(richSendMarkdown(bot)).toBe('a\nb')
|
|
319
301
|
})
|
|
320
302
|
|
|
321
303
|
it('different lanes for same chat produce independent Telegram messages', async () => {
|
|
@@ -338,7 +320,7 @@ describe('handleStreamReply', () => {
|
|
|
338
320
|
await microtaskFlush()
|
|
339
321
|
const r2 = await p2
|
|
340
322
|
|
|
341
|
-
expect(bot.api.
|
|
323
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(2)
|
|
342
324
|
expect(r1.messageId).not.toBe(r2.messageId) // separate messages
|
|
343
325
|
expect(state.activeDraftStreams.size).toBe(2)
|
|
344
326
|
expect(state.activeDraftStreams.has('1:_')).toBe(true)
|
|
@@ -366,9 +348,9 @@ describe('handleStreamReply', () => {
|
|
|
366
348
|
await microtaskFlush()
|
|
367
349
|
await p2
|
|
368
350
|
|
|
369
|
-
expect(bot.api.
|
|
351
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
370
352
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
371
|
-
expect(bot
|
|
353
|
+
expect(richEditMarkdown(bot)).toBe('step 1 — step 2')
|
|
372
354
|
})
|
|
373
355
|
|
|
374
356
|
it('done=true on one lane does not affect other lanes', async () => {
|
|
@@ -396,14 +378,6 @@ describe('handleStreamReply', () => {
|
|
|
396
378
|
})
|
|
397
379
|
|
|
398
380
|
// ─── Regression: concurrent turns on the same chat+thread+lane ───────
|
|
399
|
-
// Before the fix, two simultaneously active turns emitting on
|
|
400
|
-
// lane:'progress' (the progress-card driver's lane) computed the same
|
|
401
|
-
// streamKey and collapsed into one draft stream. Telegram saw a single
|
|
402
|
-
// message flapping between the two turns' narratives instead of two
|
|
403
|
-
// separate pinned cards. The fix threads a per-turn `turnKey` through
|
|
404
|
-
// `StreamReplyArgs` → `streamKey()` so each active turn gets its own
|
|
405
|
-
// slot in `activeDraftStreams` (and therefore its own Telegram message
|
|
406
|
-
// and its own pin via `progressPinnedMsgIds`).
|
|
407
381
|
it('concurrent turns with different turnKeys produce separate draft streams and messages', async () => {
|
|
408
382
|
const state = makeState()
|
|
409
383
|
const deps = makeDeps(bot)
|
|
@@ -427,7 +401,7 @@ describe('handleStreamReply', () => {
|
|
|
427
401
|
const rB = await pB
|
|
428
402
|
|
|
429
403
|
// Two independent Telegram messages (not one edited twice).
|
|
430
|
-
expect(bot.api.
|
|
404
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(2)
|
|
431
405
|
expect(bot.api.editMessageText).not.toHaveBeenCalled()
|
|
432
406
|
expect(rA.messageId).not.toBe(rB.messageId)
|
|
433
407
|
|
|
@@ -436,9 +410,9 @@ describe('handleStreamReply', () => {
|
|
|
436
410
|
expect(state.activeDraftStreams.has('1:_:progress:1:_:1')).toBe(true)
|
|
437
411
|
expect(state.activeDraftStreams.has('1:_:progress:1:_:2')).toBe(true)
|
|
438
412
|
|
|
439
|
-
// Each message carried its own turn's
|
|
440
|
-
expect(bot
|
|
441
|
-
expect(bot
|
|
413
|
+
// Each message carried its own turn's raw markdown.
|
|
414
|
+
expect(richSendMarkdown(bot, 0)).toBe('turn A step 1')
|
|
415
|
+
expect(richSendMarkdown(bot, 1)).toBe('turn B step 1')
|
|
442
416
|
})
|
|
443
417
|
|
|
444
418
|
it('subsequent updates with same turnKey reuse the stream (edit in place)', async () => {
|
|
@@ -463,11 +437,10 @@ describe('handleStreamReply', () => {
|
|
|
463
437
|
await microtaskFlush()
|
|
464
438
|
await p2
|
|
465
439
|
|
|
466
|
-
|
|
467
|
-
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
|
|
440
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
468
441
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
469
442
|
expect(bot.api.editMessageText.mock.calls[0][1]).toBe(500)
|
|
470
|
-
expect(bot
|
|
443
|
+
expect(richEditMarkdown(bot)).toBe('A first + second')
|
|
471
444
|
expect(state.activeDraftStreams.size).toBe(1)
|
|
472
445
|
expect(state.activeDraftStreams.has('1:_:progress:1:_:1')).toBe(true)
|
|
473
446
|
})
|
|
@@ -476,7 +449,6 @@ describe('handleStreamReply', () => {
|
|
|
476
449
|
const state = makeState()
|
|
477
450
|
const deps = makeDeps(bot)
|
|
478
451
|
|
|
479
|
-
// Turn A opens
|
|
480
452
|
const pa1 = handleStreamReply(
|
|
481
453
|
{ chat_id: '1', text: 'A step 1', lane: 'progress', turnKey: '1:_:1' },
|
|
482
454
|
state, deps,
|
|
@@ -484,7 +456,6 @@ describe('handleStreamReply', () => {
|
|
|
484
456
|
await microtaskFlush()
|
|
485
457
|
await pa1
|
|
486
458
|
|
|
487
|
-
// Turn B opens
|
|
488
459
|
const pb1 = handleStreamReply(
|
|
489
460
|
{ chat_id: '1', text: 'B step 1', lane: 'progress', turnKey: '1:_:2' },
|
|
490
461
|
state, deps,
|
|
@@ -494,7 +465,6 @@ describe('handleStreamReply', () => {
|
|
|
494
465
|
|
|
495
466
|
vi.advanceTimersByTime(1000)
|
|
496
467
|
|
|
497
|
-
// Turn A updates
|
|
498
468
|
const pa2 = handleStreamReply(
|
|
499
469
|
{ chat_id: '1', text: 'A step 1 + 2', lane: 'progress', turnKey: '1:_:1' },
|
|
500
470
|
state, deps,
|
|
@@ -504,7 +474,6 @@ describe('handleStreamReply', () => {
|
|
|
504
474
|
|
|
505
475
|
vi.advanceTimersByTime(1000)
|
|
506
476
|
|
|
507
|
-
// Turn B updates
|
|
508
477
|
const pb2 = handleStreamReply(
|
|
509
478
|
{ chat_id: '1', text: 'B step 1 + 2', lane: 'progress', turnKey: '1:_:2' },
|
|
510
479
|
state, deps,
|
|
@@ -512,19 +481,17 @@ describe('handleStreamReply', () => {
|
|
|
512
481
|
await microtaskFlush()
|
|
513
482
|
await pb2
|
|
514
483
|
|
|
515
|
-
|
|
516
|
-
expect(bot.api.sendMessage).toHaveBeenCalledTimes(2)
|
|
484
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(2)
|
|
517
485
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(2)
|
|
518
486
|
|
|
519
|
-
// The edits must target distinct message ids — one per turn's
|
|
520
|
-
// original message — not both collapse onto the same id.
|
|
521
487
|
const editTargets = bot.api.editMessageText.mock.calls.map((c) => c[1])
|
|
522
488
|
expect(new Set(editTargets).size).toBe(2)
|
|
523
489
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
expect(editTexts).toContain('
|
|
490
|
+
const editTexts = bot.api.editMessageText.mock.calls.map(
|
|
491
|
+
(c) => (c[2] as { markdown: string }).markdown,
|
|
492
|
+
)
|
|
493
|
+
expect(editTexts).toContain('A step 1 + 2')
|
|
494
|
+
expect(editTexts).toContain('B step 1 + 2')
|
|
528
495
|
})
|
|
529
496
|
|
|
530
497
|
it('done=true on one turnKey does not close the other concurrent turn', async () => {
|
|
@@ -547,11 +514,8 @@ describe('handleStreamReply', () => {
|
|
|
547
514
|
|
|
548
515
|
expect(state.activeDraftStreams.size).toBe(2)
|
|
549
516
|
|
|
550
|
-
// Advance past the throttle window so the finalize edit can flush
|
|
551
|
-
// instead of sitting on the debounce timer (fake timers).
|
|
552
517
|
vi.advanceTimersByTime(1000)
|
|
553
518
|
|
|
554
|
-
// Finalize turn A
|
|
555
519
|
const pAFinal = handleStreamReply(
|
|
556
520
|
{ chat_id: '1', text: 'A final', lane: 'progress', turnKey: '1:_:1', done: true },
|
|
557
521
|
state, deps,
|
|
@@ -559,18 +523,12 @@ describe('handleStreamReply', () => {
|
|
|
559
523
|
await microtaskFlush()
|
|
560
524
|
await pAFinal
|
|
561
525
|
|
|
562
|
-
// Turn A's slot is gone; turn B's is still live.
|
|
563
526
|
expect(state.activeDraftStreams.has('1:_:progress:1:_:1')).toBe(false)
|
|
564
527
|
expect(state.activeDraftStreams.has('1:_:progress:1:_:2')).toBe(true)
|
|
565
528
|
expect(state.activeDraftStreams.size).toBe(1)
|
|
566
529
|
})
|
|
567
530
|
|
|
568
531
|
it('turnKey omitted falls back to legacy chat+thread+lane key (no regression for non-progress callers)', async () => {
|
|
569
|
-
// Other lanes (default, thinking, activity) don't pass turnKey. They
|
|
570
|
-
// must still multiplex the legacy way: one stream per chat+thread+lane.
|
|
571
|
-
// This pins the backwards-compatible behavior of streamKey() when
|
|
572
|
-
// turnKey is undefined — a non-progress caller shouldn't suddenly
|
|
573
|
-
// create a new stream on every call.
|
|
574
532
|
const state = makeState()
|
|
575
533
|
const deps = makeDeps(bot)
|
|
576
534
|
|
|
@@ -582,67 +540,48 @@ describe('handleStreamReply', () => {
|
|
|
582
540
|
await microtaskFlush()
|
|
583
541
|
await p2
|
|
584
542
|
|
|
585
|
-
expect(bot.api.
|
|
543
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
586
544
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
587
545
|
expect(state.activeDraftStreams.size).toBe(1)
|
|
588
546
|
expect(state.activeDraftStreams.has('1:_')).toBe(true)
|
|
589
547
|
})
|
|
590
548
|
|
|
591
|
-
it('
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
// stream and create a fresh one with parse_mode=HTML so the markdown
|
|
597
|
-
// converts to HTML tags instead of sending literal asterisks.
|
|
549
|
+
it('single-mode reuse: a format switch on the same lane does NOT rotate the stream (#2669)', async () => {
|
|
550
|
+
// Pre-#2669 a parseMode mismatch (text→html) finalized the old stream
|
|
551
|
+
// and created a fresh one. There is now ONE mode: a non-text format on
|
|
552
|
+
// an existing rich stream reuses it (edit in place). Only a literal
|
|
553
|
+
// 'text' stream differs, and that distinction is fixed at creation.
|
|
598
554
|
const state = makeState()
|
|
599
|
-
const deps = makeDeps(bot, {
|
|
600
|
-
markdownToHtml: realMarkdownToHtml,
|
|
601
|
-
defaultFormat: 'text',
|
|
602
|
-
})
|
|
555
|
+
const deps = makeDeps(bot, { defaultFormat: 'markdown' })
|
|
603
556
|
|
|
604
|
-
// First call:
|
|
557
|
+
// First call: rich markdown.
|
|
605
558
|
const p1 = handleStreamReply(
|
|
606
|
-
{ chat_id: '1', text: '
|
|
559
|
+
{ chat_id: '1', text: 'plain start', format: 'markdown' },
|
|
607
560
|
state, deps,
|
|
608
561
|
)
|
|
609
562
|
await microtaskFlush()
|
|
610
563
|
await p1
|
|
611
|
-
expect(bot.api.
|
|
612
|
-
expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
|
|
564
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
613
565
|
|
|
614
566
|
vi.advanceTimersByTime(1000)
|
|
615
567
|
|
|
616
|
-
// Second call on the same
|
|
617
|
-
//
|
|
618
|
-
// and literal markdown converted to Telegram HTML tags.
|
|
568
|
+
// Second call on the same key with a different (still non-text) format:
|
|
569
|
+
// the stream is reused and the markdown ships raw, unescaped.
|
|
619
570
|
const p2 = handleStreamReply(
|
|
620
|
-
{ chat_id: '1', text: '**bold** and `code`', format: '
|
|
571
|
+
{ chat_id: '1', text: '**bold** and `code`', format: 'gfm' },
|
|
621
572
|
state, deps,
|
|
622
573
|
)
|
|
623
574
|
await microtaskFlush()
|
|
624
575
|
await p2
|
|
625
576
|
|
|
626
|
-
//
|
|
627
|
-
|
|
628
|
-
expect(bot.api.
|
|
629
|
-
|
|
630
|
-
expect(
|
|
631
|
-
// markdownToHtml renders `**bold**` → `<b>bold</b>` and
|
|
632
|
-
// `` `code` `` → `<code>code</code>`.
|
|
633
|
-
expect(secondSend[1]).toContain('<b>bold</b>')
|
|
634
|
-
expect(secondSend[1]).toContain('<code>code</code>')
|
|
635
|
-
expect(secondSend[1]).not.toContain('**')
|
|
636
|
-
expect(secondSend[1]).not.toMatch(/`code`/)
|
|
577
|
+
// No new send — edited in place.
|
|
578
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
579
|
+
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
580
|
+
// Raw markdown passed through verbatim — NOT converted to HTML.
|
|
581
|
+
expect(richEditMarkdown(bot)).toBe('**bold** and `code`')
|
|
637
582
|
})
|
|
638
583
|
|
|
639
|
-
// ─── Regression: PTY-tail duplicate message
|
|
640
|
-
// stream_reply did not add itself to suppressPtyPreview, so a PTY
|
|
641
|
-
// partial firing after a finalized stream (TUI capture of the same
|
|
642
|
-
// assistant text) created a duplicate message with the raw TUI text
|
|
643
|
-
// and visibly escaped HTML tags. See log sequence: msg 559 finalized,
|
|
644
|
-
// then msg 560 draft_send path=pty_preview with the same content.
|
|
645
|
-
// Now stream_reply claims the suppress slot on the first call.
|
|
584
|
+
// ─── Regression: PTY-tail duplicate message ──────────────────────────
|
|
646
585
|
|
|
647
586
|
it('adds sKey (without lane) to suppressPtyPreview on first call', async () => {
|
|
648
587
|
const state: StreamReplyState = {
|
|
@@ -673,18 +612,12 @@ describe('handleStreamReply', () => {
|
|
|
673
612
|
await microtaskFlush()
|
|
674
613
|
await pending
|
|
675
614
|
|
|
676
|
-
// The stream itself is keyed with the lane...
|
|
677
615
|
expect(state.activeDraftStreams.has('42:_:thinking')).toBe(true)
|
|
678
|
-
// ...but the PTY-suppression key is lane-less so the PTY handler
|
|
679
|
-
// (which has no concept of lanes) actually sees it as suppressed.
|
|
680
616
|
expect(state.suppressPtyPreview!.has('42:_')).toBe(true)
|
|
681
617
|
expect(state.suppressPtyPreview!.has('42:_:thinking')).toBe(false)
|
|
682
618
|
})
|
|
683
619
|
|
|
684
620
|
it('suppression survives done=true so late PTY partials are still dropped', async () => {
|
|
685
|
-
// This covers the exact production sequence from telegram-plugin.log:
|
|
686
|
-
// stream_reply done=true → draft_edit final → PTY partial arrives
|
|
687
|
-
// 500ms later with the TUI capture → must NOT create a new message.
|
|
688
621
|
const state: StreamReplyState = {
|
|
689
622
|
...makeState(),
|
|
690
623
|
suppressPtyPreview: new Set<string>(),
|
|
@@ -699,25 +632,15 @@ describe('handleStreamReply', () => {
|
|
|
699
632
|
await microtaskFlush()
|
|
700
633
|
await pending
|
|
701
634
|
|
|
702
|
-
// After done=true the stream is gone from activeDraftStreams...
|
|
703
635
|
expect(state.activeDraftStreams.has('42:_')).toBe(false)
|
|
704
|
-
// ...but the suppress slot must remain so a PTY partial landing
|
|
705
|
-
// AFTER finalize is dropped. server.ts clears this on turn_end.
|
|
706
636
|
expect(state.suppressPtyPreview!.has('42:_')).toBe(true)
|
|
707
637
|
})
|
|
708
638
|
|
|
709
639
|
it('end-to-end: PTY partial after stream_reply finalize is suppressed (no dup message)', async () => {
|
|
710
|
-
// Reproduces the production sequence:
|
|
711
|
-
// 1. stream_reply done=true for chat 42
|
|
712
|
-
// 2. PTY-tail fires with the TUI capture of the same assistant text
|
|
713
|
-
// 3. PTY handler sees suppress flag and drops the partial
|
|
714
|
-
// Before the fix, step 2 created a duplicate Telegram message with
|
|
715
|
-
// raw TUI text and visibly-escaped HTML tags (see log msg 559 → 560).
|
|
716
640
|
const activeDraftStreams = new Map<string, DraftStreamHandle>()
|
|
717
641
|
const suppressPtyPreview = new Set<string>()
|
|
718
642
|
const streamState: StreamReplyState = {
|
|
719
643
|
activeDraftStreams,
|
|
720
|
-
activeDraftParseModes: new Map(),
|
|
721
644
|
suppressPtyPreview,
|
|
722
645
|
}
|
|
723
646
|
const streamDeps = makeDeps(bot)
|
|
@@ -730,7 +653,8 @@ describe('handleStreamReply', () => {
|
|
|
730
653
|
)
|
|
731
654
|
await microtaskFlush()
|
|
732
655
|
await pending
|
|
733
|
-
const sendsAfterStream =
|
|
656
|
+
const sendsAfterStream =
|
|
657
|
+
bot.api.sendMessage.mock.calls.length + bot.api.sendRichMessage.mock.calls.length
|
|
734
658
|
|
|
735
659
|
// Step 2: PTY partial fires into the SHARED state — same Sets/Maps.
|
|
736
660
|
const ptyState: PtyHandlerState = {
|
|
@@ -742,19 +666,20 @@ describe('handleStreamReply', () => {
|
|
|
742
666
|
lastPtyPreviewByChat: new Map(),
|
|
743
667
|
}
|
|
744
668
|
const action = handlePtyPartialPure(
|
|
745
|
-
'TUI capture:
|
|
669
|
+
'TUI capture: final answer',
|
|
746
670
|
ptyState,
|
|
747
671
|
{ bot, renderText: (t) => t },
|
|
748
672
|
)
|
|
749
673
|
await microtaskFlush()
|
|
750
674
|
|
|
751
|
-
// Step 3: partial was dropped — no extra
|
|
675
|
+
// Step 3: partial was dropped — no extra send call of any kind.
|
|
752
676
|
expect(action).toBe('suppressed')
|
|
753
|
-
expect(
|
|
677
|
+
expect(
|
|
678
|
+
bot.api.sendMessage.mock.calls.length + bot.api.sendRichMessage.mock.calls.length,
|
|
679
|
+
).toBe(sendsAfterStream)
|
|
754
680
|
})
|
|
755
681
|
|
|
756
682
|
it('works without suppressPtyPreview (backwards compat)', async () => {
|
|
757
|
-
// Callers that don't thread the set through must still function.
|
|
758
683
|
const state = makeState() // no suppressPtyPreview
|
|
759
684
|
const deps = makeDeps(bot)
|
|
760
685
|
|
|
@@ -786,13 +711,6 @@ describe('handleStreamReply', () => {
|
|
|
786
711
|
})
|
|
787
712
|
|
|
788
713
|
describe('progressCardActive coexistence', () => {
|
|
789
|
-
// The progress card and the answer message live on different lanes
|
|
790
|
-
// (progress vs default) and render different content (tool structure
|
|
791
|
-
// vs model prose), so default-lane stream_reply(done=false) is
|
|
792
|
-
// accepted in checklist mode. The card is no longer treated as the
|
|
793
|
-
// sole mid-turn surface — it shows tool structure on its own lane
|
|
794
|
-
// while the model's progressive replies stream into the answer
|
|
795
|
-
// message. See #481.
|
|
796
714
|
it('accepts default-lane done=false when progress card is active (streams into answer)', async () => {
|
|
797
715
|
const state: StreamReplyState = {
|
|
798
716
|
...makeState(),
|
|
@@ -808,14 +726,9 @@ describe('handleStreamReply', () => {
|
|
|
808
726
|
await microtaskFlush()
|
|
809
727
|
const result = await pending
|
|
810
728
|
|
|
811
|
-
expect(bot.api.
|
|
812
|
-
expect(bot
|
|
729
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
730
|
+
expect(richSendMarkdown(bot)).toBe('working...')
|
|
813
731
|
expect(result.status).toBe('updated')
|
|
814
|
-
// PTY-preview slot is claimed because the model is now the
|
|
815
|
-
// answer-lane surface owner — late PTY partials should defer to
|
|
816
|
-
// the model's stream rather than racing it with a parallel edit.
|
|
817
|
-
// (Same suppression pattern as before — no longer for cleanup
|
|
818
|
-
// after a rejection, but for ownership during normal streaming.)
|
|
819
732
|
expect(state.suppressPtyPreview?.has('1:_')).toBe(true)
|
|
820
733
|
})
|
|
821
734
|
|
|
@@ -831,8 +744,8 @@ describe('handleStreamReply', () => {
|
|
|
831
744
|
await microtaskFlush()
|
|
832
745
|
const result = await pending
|
|
833
746
|
|
|
834
|
-
expect(bot.api.
|
|
835
|
-
expect(bot
|
|
747
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
748
|
+
expect(richSendMarkdown(bot)).toBe('final answer')
|
|
836
749
|
expect(result.status).toBe('finalized')
|
|
837
750
|
expect(result.messageId).toBe(500)
|
|
838
751
|
})
|
|
@@ -849,7 +762,7 @@ describe('handleStreamReply', () => {
|
|
|
849
762
|
await microtaskFlush()
|
|
850
763
|
await pending
|
|
851
764
|
|
|
852
|
-
expect(bot.api.
|
|
765
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
853
766
|
})
|
|
854
767
|
|
|
855
768
|
it('legacy behavior preserved when progressCardActive is false', async () => {
|
|
@@ -860,7 +773,7 @@ describe('handleStreamReply', () => {
|
|
|
860
773
|
await microtaskFlush()
|
|
861
774
|
await pending
|
|
862
775
|
|
|
863
|
-
expect(bot.api.
|
|
776
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
864
777
|
})
|
|
865
778
|
})
|
|
866
779
|
|
|
@@ -877,7 +790,7 @@ describe('handleStreamReply', () => {
|
|
|
877
790
|
await pending
|
|
878
791
|
|
|
879
792
|
expect(lookup).toHaveBeenCalledWith('1', null)
|
|
880
|
-
expect(bot.api.
|
|
793
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toEqual({
|
|
881
794
|
message_id: 4242,
|
|
882
795
|
})
|
|
883
796
|
})
|
|
@@ -897,9 +810,8 @@ describe('handleStreamReply', () => {
|
|
|
897
810
|
await microtaskFlush()
|
|
898
811
|
await pending
|
|
899
812
|
|
|
900
|
-
// Lookup is skipped entirely when reply_to is explicit.
|
|
901
813
|
expect(lookup).not.toHaveBeenCalled()
|
|
902
|
-
expect(bot.api.
|
|
814
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toEqual({
|
|
903
815
|
message_id: 777,
|
|
904
816
|
})
|
|
905
817
|
})
|
|
@@ -920,7 +832,7 @@ describe('handleStreamReply', () => {
|
|
|
920
832
|
await pending
|
|
921
833
|
|
|
922
834
|
expect(lookup).not.toHaveBeenCalled()
|
|
923
|
-
expect(bot.api.
|
|
835
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toBeUndefined()
|
|
924
836
|
})
|
|
925
837
|
|
|
926
838
|
it('no reply_parameters when history lookup returns null (empty history)', async () => {
|
|
@@ -935,7 +847,7 @@ describe('handleStreamReply', () => {
|
|
|
935
847
|
await pending
|
|
936
848
|
|
|
937
849
|
expect(lookup).toHaveBeenCalledTimes(1)
|
|
938
|
-
expect(bot.api.
|
|
850
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toBeUndefined()
|
|
939
851
|
})
|
|
940
852
|
|
|
941
853
|
it('no auto-quote when getLatestInboundMessageId dep is omitted (legacy callers)', async () => {
|
|
@@ -946,7 +858,7 @@ describe('handleStreamReply', () => {
|
|
|
946
858
|
await microtaskFlush()
|
|
947
859
|
await pending
|
|
948
860
|
|
|
949
|
-
expect(bot.api.
|
|
861
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toBeUndefined()
|
|
950
862
|
})
|
|
951
863
|
|
|
952
864
|
it('passes thread id to the lookup', async () => {
|
|
@@ -974,17 +886,14 @@ describe('handleStreamReply', () => {
|
|
|
974
886
|
)
|
|
975
887
|
const deps = makeDeps(bot, { getLatestInboundMessageId: lookup })
|
|
976
888
|
|
|
977
|
-
// First call → send with reply_parameters.
|
|
978
889
|
await handleStreamReply({ chat_id: '1', text: 'hi' }, state, deps)
|
|
979
890
|
await microtaskFlush()
|
|
980
891
|
|
|
981
|
-
// Second call on the same stream → edit. editMessageText must NOT
|
|
982
|
-
// receive reply_parameters (Telegram rejects it on edit).
|
|
983
892
|
vi.advanceTimersByTime(1000)
|
|
984
893
|
await handleStreamReply({ chat_id: '1', text: 'hi there' }, state, deps)
|
|
985
894
|
await microtaskFlush()
|
|
986
895
|
|
|
987
|
-
expect(bot.api.
|
|
896
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toEqual({
|
|
988
897
|
message_id: 4242,
|
|
989
898
|
})
|
|
990
899
|
expect(bot.api.editMessageText).toHaveBeenCalled()
|
|
@@ -994,7 +903,7 @@ describe('handleStreamReply', () => {
|
|
|
994
903
|
})
|
|
995
904
|
|
|
996
905
|
describe('reply_markup persistence', () => {
|
|
997
|
-
it('reply_markup in args is included in
|
|
906
|
+
it('reply_markup in args is included in sendRichMessage opts on stream creation', async () => {
|
|
998
907
|
const state = makeState()
|
|
999
908
|
const deps = makeDeps(bot)
|
|
1000
909
|
const keyboard = { inline_keyboard: [[{ text: 'Steer', callback_data: 'steer:1' }]] }
|
|
@@ -1007,10 +916,10 @@ describe('handleStreamReply', () => {
|
|
|
1007
916
|
await microtaskFlush()
|
|
1008
917
|
await pending
|
|
1009
918
|
|
|
1010
|
-
expect(bot.api.
|
|
919
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBe(keyboard)
|
|
1011
920
|
})
|
|
1012
921
|
|
|
1013
|
-
it('reply_markup
|
|
922
|
+
it('reply_markup rides on both the rich send AND the rich edit', async () => {
|
|
1014
923
|
const state = makeState()
|
|
1015
924
|
const deps = makeDeps(bot)
|
|
1016
925
|
const keyboard = { inline_keyboard: [[{ text: 'Steer', callback_data: 'steer:1' }]] }
|
|
@@ -1032,6 +941,7 @@ describe('handleStreamReply', () => {
|
|
|
1032
941
|
await microtaskFlush()
|
|
1033
942
|
await p2
|
|
1034
943
|
|
|
944
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBe(keyboard)
|
|
1035
945
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
1036
946
|
expect(bot.api.editMessageText.mock.calls[0][3]?.reply_markup).toBe(keyboard)
|
|
1037
947
|
})
|
|
@@ -1070,17 +980,12 @@ describe('handleStreamReply', () => {
|
|
|
1070
980
|
await microtaskFlush()
|
|
1071
981
|
await pending
|
|
1072
982
|
|
|
1073
|
-
expect(bot.api.
|
|
983
|
+
expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBeUndefined()
|
|
1074
984
|
})
|
|
1075
985
|
})
|
|
1076
986
|
|
|
1077
987
|
describe('lookupExistingMessageId hook (#626 — multiple status messages regression)', () => {
|
|
1078
|
-
it('reuses an externally-known messageId on stream creation — first emit edits, no
|
|
1079
|
-
// The pin manager already knows the anchor message id for this
|
|
1080
|
-
// turnKey from a previous emit cycle (e.g. before done=true wiped
|
|
1081
|
-
// activeDraftStreams[sKey]). The hook hands that id back; the
|
|
1082
|
-
// new stream initializes with it, so the FIRST update edits in
|
|
1083
|
-
// place. No fresh sendMessage = no extra "status message" lands.
|
|
988
|
+
it('reuses an externally-known messageId on stream creation — first emit edits, no send', async () => {
|
|
1084
989
|
const state = makeState()
|
|
1085
990
|
const deps = makeDeps(bot, {
|
|
1086
991
|
lookupExistingMessageId: ({ turnKey, lane }) => {
|
|
@@ -1097,6 +1002,7 @@ describe('handleStreamReply', () => {
|
|
|
1097
1002
|
await microtaskFlush()
|
|
1098
1003
|
const result = await pending
|
|
1099
1004
|
|
|
1005
|
+
expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
|
|
1100
1006
|
expect(bot.api.sendMessage).not.toHaveBeenCalled()
|
|
1101
1007
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
1102
1008
|
const [, id] = bot.api.editMessageText.mock.calls[0]
|
|
@@ -1104,9 +1010,7 @@ describe('handleStreamReply', () => {
|
|
|
1104
1010
|
expect(result.messageId).toBe(4242)
|
|
1105
1011
|
})
|
|
1106
1012
|
|
|
1107
|
-
it('hook returns null → falls through to legacy
|
|
1108
|
-
// Back-compat sanity: a hook that returns null on every call
|
|
1109
|
-
// produces identical behavior to omitting the hook entirely.
|
|
1013
|
+
it('hook returns null → falls through to legacy send path', async () => {
|
|
1110
1014
|
const state = makeState()
|
|
1111
1015
|
const deps = makeDeps(bot, {
|
|
1112
1016
|
lookupExistingMessageId: () => null,
|
|
@@ -1120,16 +1024,11 @@ describe('handleStreamReply', () => {
|
|
|
1120
1024
|
await microtaskFlush()
|
|
1121
1025
|
await pending
|
|
1122
1026
|
|
|
1123
|
-
expect(bot.api.
|
|
1027
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
1124
1028
|
expect(bot.api.editMessageText).not.toHaveBeenCalled()
|
|
1125
1029
|
})
|
|
1126
1030
|
|
|
1127
1031
|
it('hook NOT consulted when an active draft stream already exists for the lane+turn', async () => {
|
|
1128
|
-
// Lifecycle invariant: the hook only fires on stream creation.
|
|
1129
|
-
// If activeDraftStreams[sKey] is already populated (turn in
|
|
1130
|
-
// progress, no done=true yet), the existing stream handles
|
|
1131
|
-
// edits — the hook is never consulted, so it can't disturb the
|
|
1132
|
-
// running stream's state.
|
|
1133
1032
|
const state = makeState()
|
|
1134
1033
|
let lookupCalls = 0
|
|
1135
1034
|
const deps = makeDeps(bot, {
|
|
@@ -1139,8 +1038,6 @@ describe('handleStreamReply', () => {
|
|
|
1139
1038
|
},
|
|
1140
1039
|
})
|
|
1141
1040
|
|
|
1142
|
-
// First emit creates the stream (lookup IS called, returns
|
|
1143
|
-
// 9999 → first edit goes to 9999).
|
|
1144
1041
|
await handleStreamReply(
|
|
1145
1042
|
{ chat_id: '1', text: 'first', lane: 'progress', turnKey: 'turn-B' },
|
|
1146
1043
|
state,
|
|
@@ -1151,8 +1048,6 @@ describe('handleStreamReply', () => {
|
|
|
1151
1048
|
expect(lookupCalls).toBe(1)
|
|
1152
1049
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
1153
1050
|
|
|
1154
|
-
// Second emit on the same lane+turn reuses the existing stream
|
|
1155
|
-
// — the lookup is NOT called again. Edits still target 9999.
|
|
1156
1051
|
await handleStreamReply(
|
|
1157
1052
|
{ chat_id: '1', text: 'second', lane: 'progress', turnKey: 'turn-B' },
|
|
1158
1053
|
state,
|
|
@@ -1163,10 +1058,7 @@ describe('handleStreamReply', () => {
|
|
|
1163
1058
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(2)
|
|
1164
1059
|
})
|
|
1165
1060
|
|
|
1166
|
-
it('hook throws → error logged, handler falls through to fresh
|
|
1167
|
-
// Defensive contract: a buggy lookup must never break the
|
|
1168
|
-
// outbound path. Caller's writeError gets the diagnostic; the
|
|
1169
|
-
// emit lands as a fresh send.
|
|
1061
|
+
it('hook throws → error logged, handler falls through to fresh send', async () => {
|
|
1170
1062
|
const state = makeState()
|
|
1171
1063
|
const writeError = vi.fn()
|
|
1172
1064
|
const deps = makeDeps(bot, {
|
|
@@ -1183,44 +1075,28 @@ describe('handleStreamReply', () => {
|
|
|
1183
1075
|
)
|
|
1184
1076
|
await microtaskFlush()
|
|
1185
1077
|
|
|
1186
|
-
expect(bot.api.
|
|
1078
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
1187
1079
|
expect(writeError).toHaveBeenCalled()
|
|
1188
1080
|
const errLine = (writeError.mock.calls[0]?.[0] as string) ?? ''
|
|
1189
1081
|
expect(errLine).toContain('lookupExistingMessageId failed')
|
|
1190
1082
|
})
|
|
1191
1083
|
|
|
1192
|
-
it('full #626 lifecycle scenario — done=true →
|
|
1193
|
-
// The end-to-end repro of #626. Sequence:
|
|
1194
|
-
// 1. First progress-card emit (isFirstEmit=true) → fresh
|
|
1195
|
-
// sendMessage on the 'progress' lane for turn-A. Pin
|
|
1196
|
-
// manager records messageId=500.
|
|
1197
|
-
// 2. done=true emit (e.g. the parent turn_end fires before
|
|
1198
|
-
// sub-agents finish) → handler finalizes + DELETES
|
|
1199
|
-
// activeDraftStreams[sKey].
|
|
1200
|
-
// 3. A subsequent sub-agent event triggers a fresh progress-
|
|
1201
|
-
// card emit on the SAME turn-A. Without the hook, the
|
|
1202
|
-
// handler would create a new stream → fresh sendMessage →
|
|
1203
|
-
// a SECOND status message lands in the chat.
|
|
1204
|
-
// 4. With the hook returning the pin-manager's messageId 500,
|
|
1205
|
-
// the new stream initializes with 500. The next update
|
|
1206
|
-
// hits editMessageText against 500. Total Telegram surface
|
|
1207
|
-
// = ONE message.
|
|
1084
|
+
it('full #626 lifecycle scenario — done=true → cleared → next emit edits via hook (one anchor message total)', async () => {
|
|
1208
1085
|
const state = makeState()
|
|
1209
1086
|
const knownMessageId = { value: null as number | null }
|
|
1210
1087
|
const deps = makeDeps(bot, {
|
|
1211
1088
|
lookupExistingMessageId: () => knownMessageId.value,
|
|
1212
1089
|
})
|
|
1213
1090
|
|
|
1214
|
-
// 1. First emit, no known messageId yet → fresh
|
|
1091
|
+
// 1. First emit, no known messageId yet → fresh send
|
|
1215
1092
|
await handleStreamReply(
|
|
1216
1093
|
{ chat_id: '1', text: 'tool 1...', lane: 'progress', turnKey: 'turn-A' },
|
|
1217
1094
|
state,
|
|
1218
1095
|
deps,
|
|
1219
1096
|
)
|
|
1220
1097
|
await microtaskFlush()
|
|
1221
|
-
// The pin manager records id=500 (mock bot's first id).
|
|
1222
1098
|
knownMessageId.value = 500
|
|
1223
|
-
expect(bot.api.
|
|
1099
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
1224
1100
|
expect(bot.api.editMessageText).not.toHaveBeenCalled()
|
|
1225
1101
|
|
|
1226
1102
|
// 2. done=true → finalize + clear sKey
|
|
@@ -1233,8 +1109,7 @@ describe('handleStreamReply', () => {
|
|
|
1233
1109
|
await microtaskFlush()
|
|
1234
1110
|
expect(state.activeDraftStreams.size).toBe(0)
|
|
1235
1111
|
|
|
1236
|
-
// 3. Subsequent sub-agent emit on the SAME turn-A
|
|
1237
|
-
// the hook this would land as sendMessage #2 (the bug).
|
|
1112
|
+
// 3. Subsequent sub-agent emit on the SAME turn-A.
|
|
1238
1113
|
await handleStreamReply(
|
|
1239
1114
|
{ chat_id: '1', text: 'tool 1, tool 2 ✓, sub-agent...', lane: 'progress', turnKey: 'turn-A' },
|
|
1240
1115
|
state,
|
|
@@ -1242,10 +1117,8 @@ describe('handleStreamReply', () => {
|
|
|
1242
1117
|
)
|
|
1243
1118
|
await microtaskFlush()
|
|
1244
1119
|
|
|
1245
|
-
// Invariant: total fresh
|
|
1246
|
-
|
|
1247
|
-
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
|
|
1248
|
-
// The post-done emit was an edit against id 500.
|
|
1120
|
+
// Invariant: total fresh rich sends on this chat = 1.
|
|
1121
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
1249
1122
|
expect(bot.api.editMessageText.mock.calls.some((c) => c[1] === 500)).toBe(true)
|
|
1250
1123
|
})
|
|
1251
1124
|
})
|