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
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
* - the `stream_reply` MCP case block (model-driven streaming)
|
|
7
7
|
* - `handlePtyPartial` (PTY-tail TUI extractor → live preview)
|
|
8
8
|
*
|
|
9
|
-
* Both paths do the same thing: given a chat/thread
|
|
10
|
-
*
|
|
11
|
-
* `edit` closure calls `bot.api.editMessageText`, both wrapped
|
|
12
|
-
* shared retry/429/not-modified policy (`robustApiCall`).
|
|
9
|
+
* Both paths do the same thing: given a chat/thread, create a draft stream
|
|
10
|
+
* whose `send` closure first calls `bot.api.sendRichMessage` and whose
|
|
11
|
+
* `edit` closure calls `bot.api.editMessageText({ markdown })`, both wrapped
|
|
12
|
+
* in the shared retry/429/not-modified policy (`robustApiCall`). Because
|
|
13
|
+
* send and edit share the same opts, the "edit-rich-if-sent-rich" invariant
|
|
14
|
+
* holds automatically (#2669). A `format:'text'` literal stream bypasses the
|
|
15
|
+
* rich parser entirely (plain `sendMessage` / plain-string `editMessageText`).
|
|
13
16
|
*
|
|
14
17
|
* This module exists primarily so that wiring can be exercised by
|
|
15
18
|
* integration tests against a mock bot.api, without having to mock the
|
|
@@ -17,32 +20,11 @@
|
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
22
|
import { createDraftStream, type DraftStreamHandle } from './draft-stream.js'
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Telegram returns `400 Bad Request: can't parse entities: …` when the
|
|
24
|
-
* body contains malformed HTML / MarkdownV2 / nested unbalanced tags.
|
|
25
|
-
* Detect that specific error class so we can fall back to plain text
|
|
26
|
-
* without confusing it with other 400s (rate-limit, message-not-found,
|
|
27
|
-
* thread-not-found, etc.).
|
|
28
|
-
*/
|
|
29
|
-
function isParseEntitiesError(err: unknown): boolean {
|
|
30
|
-
const msg =
|
|
31
|
-
typeof err === 'string'
|
|
32
|
-
? err
|
|
33
|
-
: err instanceof Error
|
|
34
|
-
? err.message
|
|
35
|
-
: typeof err === 'object' && err != null && 'description' in err
|
|
36
|
-
? typeof (err as { description: unknown }).description === 'string'
|
|
37
|
-
? (err as { description: string }).description
|
|
38
|
-
: ''
|
|
39
|
-
: ''
|
|
40
|
-
return /can't parse entities|can't find end of the entity|unsupported start tag|unmatched end tag/i.test(msg)
|
|
41
|
-
}
|
|
23
|
+
import { richMessage, isParseEntitiesError } from './rich-send.js'
|
|
42
24
|
|
|
43
25
|
/**
|
|
44
26
|
* Minimal bot.api surface the controller needs. Real callers pass grammy's
|
|
45
|
-
* `bot.api`; tests pass a mock with
|
|
27
|
+
* `bot.api`; tests pass a mock with these methods.
|
|
46
28
|
*/
|
|
47
29
|
export interface StreamBotApi {
|
|
48
30
|
sendMessage(
|
|
@@ -50,16 +32,20 @@ export interface StreamBotApi {
|
|
|
50
32
|
text: string,
|
|
51
33
|
opts: StreamSendOpts,
|
|
52
34
|
): Promise<{ message_id: number }>
|
|
35
|
+
sendRichMessage(
|
|
36
|
+
chat_id: string,
|
|
37
|
+
rich_message: { markdown: string },
|
|
38
|
+
opts: StreamSendOpts,
|
|
39
|
+
): Promise<{ message_id: number }>
|
|
53
40
|
editMessageText(
|
|
54
41
|
chat_id: string,
|
|
55
42
|
message_id: number,
|
|
56
|
-
text: string,
|
|
43
|
+
text: string | { markdown: string },
|
|
57
44
|
opts: StreamSendOpts,
|
|
58
45
|
): Promise<unknown>
|
|
59
46
|
}
|
|
60
47
|
|
|
61
48
|
export interface StreamSendOpts {
|
|
62
|
-
parse_mode?: 'HTML' | 'MarkdownV2'
|
|
63
49
|
message_thread_id?: number
|
|
64
50
|
link_preview_options?: { is_disabled: boolean }
|
|
65
51
|
/**
|
|
@@ -99,7 +85,13 @@ export interface StreamControllerConfig {
|
|
|
99
85
|
bot: { api: StreamBotApi }
|
|
100
86
|
chatId: string
|
|
101
87
|
threadId?: number
|
|
102
|
-
|
|
88
|
+
/**
|
|
89
|
+
* When true, this stream is a literal `format:'text'` send: the body
|
|
90
|
+
* bypasses the rich-markdown parser entirely (plain `sendMessage` /
|
|
91
|
+
* plain-string `editMessageText`). Default (false/undefined) → the
|
|
92
|
+
* rich-markdown path via `sendRichMessage` / `editMessageText({ markdown })`.
|
|
93
|
+
*/
|
|
94
|
+
literalText?: boolean
|
|
103
95
|
disableLinkPreview?: boolean
|
|
104
96
|
/**
|
|
105
97
|
* Optional quote-reply target. When set, the initial send attaches
|
|
@@ -186,7 +178,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
186
178
|
bot,
|
|
187
179
|
chatId,
|
|
188
180
|
threadId,
|
|
189
|
-
|
|
181
|
+
literalText = false,
|
|
190
182
|
disableLinkPreview = true,
|
|
191
183
|
throttleMs,
|
|
192
184
|
idleMs,
|
|
@@ -205,9 +197,9 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
205
197
|
|
|
206
198
|
// Base opts shared by send + edit. The initial send adds reply_parameters
|
|
207
199
|
// and protect_content on top (see below); edits must NOT carry those —
|
|
208
|
-
// Telegram's editMessageText rejects them.
|
|
200
|
+
// Telegram's editMessageText rejects them. Both send and edit share these
|
|
201
|
+
// opts, so a rich send is always followed by a rich edit (#2669 invariant).
|
|
209
202
|
const baseOpts: StreamSendOpts = {
|
|
210
|
-
...(parseMode ? { parse_mode: parseMode } : {}),
|
|
211
203
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
212
204
|
...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
|
|
213
205
|
...(replyMarkup != null ? { reply_markup: replyMarkup } : {}),
|
|
@@ -226,38 +218,44 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
226
218
|
...(cfg.disableNotification === true ? { disable_notification: true } : {}),
|
|
227
219
|
}
|
|
228
220
|
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
221
|
+
// Send the body via the rich-markdown path, unless this is a literal
|
|
222
|
+
// `format:'text'` stream (plain sendMessage, no rich wrapper).
|
|
223
|
+
// sendRichMessage does NOT accept link_preview_options (rich messages
|
|
224
|
+
// control previews via entity detection), so strip it for the rich path.
|
|
225
|
+
const doSend = (text: string, opts: StreamSendOpts) => {
|
|
226
|
+
if (literalText) return bot.api.sendMessage(chatId, text, opts)
|
|
227
|
+
const richOpts = { ...opts }
|
|
228
|
+
delete richOpts.link_preview_options
|
|
229
|
+
return bot.api.sendRichMessage(chatId, richMessage(text), richOpts)
|
|
230
|
+
}
|
|
231
|
+
const doEdit = (id: number, text: string, opts: StreamSendOpts) =>
|
|
232
|
+
bot.api.editMessageText(chatId, id, literalText ? text : richMessage(text), opts)
|
|
235
233
|
|
|
236
234
|
return createDraftStream(
|
|
237
235
|
async (text) => {
|
|
238
236
|
try {
|
|
239
237
|
const sent = await retry(
|
|
240
|
-
() =>
|
|
238
|
+
() => doSend(text, sendOpts),
|
|
241
239
|
{ threadId, chat_id: chatId },
|
|
242
240
|
)
|
|
243
241
|
onSend?.(sent.message_id, text.length)
|
|
244
242
|
return sent.message_id
|
|
245
243
|
} catch (err) {
|
|
246
|
-
if (
|
|
247
|
-
// First send rejected
|
|
248
|
-
// message_id to edit (the send 400'd before any
|
|
249
|
-
// created), so a single fresh send
|
|
250
|
-
//
|
|
251
|
-
//
|
|
244
|
+
if (!literalText && isParseEntitiesError(err)) {
|
|
245
|
+
// First send rejected because the markdown couldn't be parsed.
|
|
246
|
+
// There is no message_id to edit (the send 400'd before any
|
|
247
|
+
// message was created), so a single fresh send as PLAIN text
|
|
248
|
+
// (no rich wrapper, so the parser never runs) is the correct
|
|
249
|
+
// recovery — see issue #657. The raw markdown source is itself
|
|
250
|
+
// readable, so we send it verbatim.
|
|
252
251
|
warn?.(
|
|
253
|
-
`stream-controller:
|
|
252
|
+
`stream-controller: send parse-entities rejected — retrying once as plain text (${err instanceof Error ? err.message : String(err)})`,
|
|
254
253
|
)
|
|
255
|
-
const plainText = htmlToPlainText(text)
|
|
256
254
|
const sent = await retry(
|
|
257
|
-
() => bot.api.sendMessage(chatId,
|
|
255
|
+
() => bot.api.sendMessage(chatId, text, sendOpts),
|
|
258
256
|
{ threadId, chat_id: chatId },
|
|
259
257
|
)
|
|
260
|
-
onSend?.(sent.message_id,
|
|
258
|
+
onSend?.(sent.message_id, text.length)
|
|
261
259
|
return sent.message_id
|
|
262
260
|
}
|
|
263
261
|
throw err
|
|
@@ -266,27 +264,26 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
266
264
|
async (id, text) => {
|
|
267
265
|
try {
|
|
268
266
|
await retry(
|
|
269
|
-
() =>
|
|
267
|
+
() => doEdit(id, text, baseOpts),
|
|
270
268
|
{ threadId, chat_id: chatId },
|
|
271
269
|
)
|
|
272
270
|
onEdit?.(id, text.length)
|
|
273
271
|
} catch (err) {
|
|
274
|
-
if (
|
|
275
|
-
// Edit rejected
|
|
276
|
-
// message. The whole point of issue #657 is that the
|
|
277
|
-
// implementation sent a duplicate
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
272
|
+
if (!literalText && isParseEntitiesError(err)) {
|
|
273
|
+
// Edit rejected because the markdown couldn't be parsed — DO NOT
|
|
274
|
+
// send a fresh message. The whole point of issue #657 is that the
|
|
275
|
+
// previous implementation sent a duplicate message every time a
|
|
276
|
+
// parse rejection fired. Retry the edit on the SAME message_id as
|
|
277
|
+
// PLAIN text (no rich wrapper, so the parser never runs). The raw
|
|
278
|
+
// markdown source is itself readable, so we send it verbatim.
|
|
281
279
|
warn?.(
|
|
282
|
-
`stream-controller:
|
|
280
|
+
`stream-controller: edit parse-entities rejected — retrying same id=${id} as plain text (${err instanceof Error ? err.message : String(err)})`,
|
|
283
281
|
)
|
|
284
|
-
const plainText = htmlToPlainText(text)
|
|
285
282
|
await retry(
|
|
286
|
-
() => bot.api.editMessageText(chatId, id,
|
|
283
|
+
() => bot.api.editMessageText(chatId, id, text, baseOpts),
|
|
287
284
|
{ threadId, chat_id: chatId },
|
|
288
285
|
)
|
|
289
|
-
onEdit?.(id,
|
|
286
|
+
onEdit?.(id, text.length)
|
|
290
287
|
return
|
|
291
288
|
}
|
|
292
289
|
throw err
|
|
@@ -22,28 +22,28 @@ import {
|
|
|
22
22
|
type StreamBotApi,
|
|
23
23
|
type RetryPolicy,
|
|
24
24
|
} from './stream-controller.js'
|
|
25
|
-
import {
|
|
25
|
+
import { RICH_MESSAGE_MAX_CHARS } from './format.js'
|
|
26
26
|
import { chatKey, chatKeyWithSuffix } from './gateway/chat-key.js'
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Builds the inline status-accent header line for `reply` / `stream_reply`.
|
|
30
30
|
*
|
|
31
|
-
* Returns a string to prepend to the
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* Returns a string to prepend to the message body, including a trailing
|
|
32
|
+
* blank line so the header is visually separated. Returns an empty string
|
|
33
|
+
* when accent is undefined or unrecognised (silent ignore) so calls without
|
|
34
|
+
* `accent` produce identical output to today.
|
|
35
35
|
*
|
|
36
|
-
* The header
|
|
37
|
-
* (
|
|
36
|
+
* The header is GFM markdown — every outbound goes through the rich-message
|
|
37
|
+
* path (#2669), so `_italic_` / `**bold**` render correctly.
|
|
38
38
|
*/
|
|
39
39
|
export function buildAccentHeader(accent: string | undefined): string {
|
|
40
40
|
switch (accent) {
|
|
41
41
|
case 'in-progress':
|
|
42
|
-
return '🔵
|
|
42
|
+
return '🔵 _In progress…_\n\n'
|
|
43
43
|
case 'done':
|
|
44
|
-
return '✅
|
|
44
|
+
return '✅ **Done**\n\n'
|
|
45
45
|
case 'issue':
|
|
46
|
-
return '⚠️
|
|
46
|
+
return '⚠️ **Issue**\n\n'
|
|
47
47
|
default:
|
|
48
48
|
return ''
|
|
49
49
|
}
|
|
@@ -118,9 +118,9 @@ export interface StreamReplyArgs {
|
|
|
118
118
|
* Optional status accent prepended as a leading header line (issue #320
|
|
119
119
|
* fallback for missing Telegram quote-bar color API).
|
|
120
120
|
*
|
|
121
|
-
* - `'in-progress'` → `🔵
|
|
122
|
-
* - `'done'` → `✅
|
|
123
|
-
* - `'issue'` → `⚠️
|
|
121
|
+
* - `'in-progress'` → `🔵 _In progress…_\n\n`
|
|
122
|
+
* - `'done'` → `✅ **Done**\n\n`
|
|
123
|
+
* - `'issue'` → `⚠️ **Issue**\n\n`
|
|
124
124
|
*
|
|
125
125
|
* Unrecognised values are silently ignored. Omit for plain reply (default
|
|
126
126
|
* behavior — identical to today's output).
|
|
@@ -130,21 +130,6 @@ export interface StreamReplyArgs {
|
|
|
130
130
|
|
|
131
131
|
export interface StreamReplyState {
|
|
132
132
|
activeDraftStreams: Map<string, DraftStreamHandle>
|
|
133
|
-
/**
|
|
134
|
-
* Tracks the parseMode each active stream was created with, keyed the
|
|
135
|
-
* same way as `activeDraftStreams`. Used by `handleStreamReply` to
|
|
136
|
-
* detect when a subsequent call's resolved parseMode differs from the
|
|
137
|
-
* one baked into the existing stream controller — in that case the
|
|
138
|
-
* stale stream is finalized + discarded and a fresh one is created
|
|
139
|
-
* with the new parseMode (see bug 1: PTY-tail creates an activity-lane
|
|
140
|
-
* stream with parseMode=undefined; a later explicit stream_reply on
|
|
141
|
-
* the same key with format:'html' would otherwise inherit undefined
|
|
142
|
-
* and send literal markdown).
|
|
143
|
-
*
|
|
144
|
-
* Optional for backwards compatibility with external callers that
|
|
145
|
-
* construct a StreamReplyState without it.
|
|
146
|
-
*/
|
|
147
|
-
activeDraftParseModes?: Map<string, 'HTML' | 'MarkdownV2' | undefined>
|
|
148
133
|
/**
|
|
149
134
|
* Chats whose PTY preview is claimed by an in-flight reply/stream_reply
|
|
150
135
|
* handler. PTY-tail partials for these keys are dropped to avoid
|
|
@@ -165,12 +150,14 @@ export interface StreamReplyState {
|
|
|
165
150
|
export interface StreamReplyDeps {
|
|
166
151
|
bot: { api: StreamBotApi }
|
|
167
152
|
retry?: RetryPolicy
|
|
168
|
-
/** Markdown → HTML renderer (used when format === 'html'). */
|
|
169
|
-
markdownToHtml: (text: string) => string
|
|
170
|
-
/** MarkdownV2 escaper (used when format === 'markdownv2'). */
|
|
171
|
-
escapeMarkdownV2: (text: string) => string
|
|
172
153
|
/** Whitespace repair applied to the raw caller text. */
|
|
173
154
|
repairEscapedWhitespace: (text: string) => string
|
|
155
|
+
/**
|
|
156
|
+
* Promote lone prose paragraph breaks into GFM hard breaks so the rich
|
|
157
|
+
* path doesn't collapse them. Optional for backward compat; when omitted,
|
|
158
|
+
* the raw (repaired) text is sent unchanged.
|
|
159
|
+
*/
|
|
160
|
+
normalizeParagraphBreaks?: (text: string) => string
|
|
174
161
|
/** Validates the chat id against the access list. Throws on deny. */
|
|
175
162
|
assertAllowedChat: (chatId: string) => void
|
|
176
163
|
/** Resolves the effective thread id (explicit, last-inbound, or undefined). */
|
|
@@ -185,7 +172,8 @@ export interface StreamReplyDeps {
|
|
|
185
172
|
getLatestInboundMessageId?: (chatId: string, threadId: number | null) => number | null
|
|
186
173
|
/** Config: disable link previews. Default true. */
|
|
187
174
|
disableLinkPreview: boolean
|
|
188
|
-
/** Config: fallback
|
|
175
|
+
/** Config: fallback format when args.format is omitted. Anything other
|
|
176
|
+
* than the literal `'text'` is treated as the rich-markdown path (#2669). */
|
|
189
177
|
defaultFormat: string
|
|
190
178
|
/** Observability: per-call event. */
|
|
191
179
|
logStreamingEvent: (ev: {
|
|
@@ -320,7 +308,9 @@ export async function handleStreamReply(
|
|
|
320
308
|
deps: StreamReplyDeps,
|
|
321
309
|
): Promise<StreamReplyResult> {
|
|
322
310
|
const chat_id = args.chat_id
|
|
323
|
-
const rawText = deps.
|
|
311
|
+
const rawText = deps.normalizeParagraphBreaks
|
|
312
|
+
? deps.normalizeParagraphBreaks(deps.repairEscapedWhitespace(args.text))
|
|
313
|
+
: deps.repairEscapedWhitespace(args.text)
|
|
324
314
|
const done = Boolean(args.done)
|
|
325
315
|
const format = args.format ?? deps.defaultFormat
|
|
326
316
|
if (done) {
|
|
@@ -341,53 +331,36 @@ export async function handleStreamReply(
|
|
|
341
331
|
// the progressive-streaming contract documented in
|
|
342
332
|
// profiles/default/CLAUDE.md. See #481.
|
|
343
333
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// Telegram's `400 Bad Request: can't parse entities` (issue #657).
|
|
352
|
-
effectiveText = sanitizeTelegramHtml(deps.markdownToHtml(rawText))
|
|
353
|
-
} else if (format === 'markdownv2') {
|
|
354
|
-
parseMode = 'MarkdownV2'
|
|
355
|
-
effectiveText = deps.escapeMarkdownV2(rawText)
|
|
356
|
-
} else {
|
|
357
|
-
parseMode = undefined
|
|
358
|
-
effectiveText = rawText
|
|
359
|
-
}
|
|
334
|
+
// Single rich-markdown path (#2669). The only fork is the literal
|
|
335
|
+
// `format:'text'` send (plain string, no markdown parsing); everything
|
|
336
|
+
// else ships the raw GFM markdown via the rich-message path. No
|
|
337
|
+
// markdown→HTML / MarkdownV2 rendering happens here anymore — the raw
|
|
338
|
+
// text IS the wire payload.
|
|
339
|
+
const literalText = format === 'text'
|
|
340
|
+
let effectiveText: string = rawText
|
|
360
341
|
|
|
361
|
-
// Inline status-accent header (issue #320 fallback). Prepended
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// Unrecognised values are silently ignored (empty string returned).
|
|
342
|
+
// Inline status-accent header (issue #320 fallback). Prepended so it
|
|
343
|
+
// leads the body. Since stream_reply callers pass the full text snapshot
|
|
344
|
+
// each call, the header is prepended on every call that supplies
|
|
345
|
+
// `accent` — keeping the rendered message consistent across edits.
|
|
346
|
+
// Callers that don't want the header on a subsequent edit simply omit
|
|
347
|
+
// `accent`. Unrecognised values are silently ignored (empty string).
|
|
368
348
|
if (args.accent != null) {
|
|
369
349
|
const accentHeader = buildAccentHeader(args.accent)
|
|
370
350
|
if (accentHeader.length > 0) {
|
|
371
351
|
effectiveText = accentHeader + effectiveText
|
|
372
|
-
// Re-sanitize after prepending the HTML header so the combined
|
|
373
|
-
// body is still guaranteed parse-mode=HTML safe.
|
|
374
|
-
if (parseMode === 'HTML') {
|
|
375
|
-
effectiveText = sanitizeTelegramHtml(effectiveText)
|
|
376
|
-
}
|
|
377
352
|
}
|
|
378
353
|
}
|
|
379
354
|
|
|
380
355
|
// Over-limit pre-check. Throws BEFORE touching stream state so that
|
|
381
|
-
// (a) a first call over
|
|
382
|
-
// half-initialized stream, and (b) a mid-stream update over
|
|
356
|
+
// (a) a first call over the cap fails cleanly instead of creating a
|
|
357
|
+
// half-initialized stream, and (b) a mid-stream update over the cap
|
|
383
358
|
// fails loudly instead of setting the internal `stopped=true` flag
|
|
384
359
|
// and silently dropping all subsequent text. Either way the caller
|
|
385
360
|
// sees isError:true and can fall back to `reply`, which chunks.
|
|
386
|
-
|
|
387
|
-
// what actually goes to Telegram's 4096-char wire limit.
|
|
388
|
-
if (effectiveText.length > 4096) {
|
|
361
|
+
if (effectiveText.length > RICH_MESSAGE_MAX_CHARS) {
|
|
389
362
|
throw new Error(
|
|
390
|
-
`stream_reply rejected: text exceeds Telegram's
|
|
363
|
+
`stream_reply rejected: text exceeds Telegram's ${RICH_MESSAGE_MAX_CHARS}-char rich-message limit ` +
|
|
391
364
|
`(length=${effectiveText.length}, format=${format}). stream_reply does not ` +
|
|
392
365
|
`auto-chunk — split the text or use \`reply\`, which chunks.`,
|
|
393
366
|
)
|
|
@@ -401,30 +374,10 @@ export async function handleStreamReply(
|
|
|
401
374
|
// targets. Cleared on turn_end by server.ts.
|
|
402
375
|
state.suppressPtyPreview?.add(streamKey(chat_id, threadId))
|
|
403
376
|
let stream = state.activeDraftStreams.get(sKey)
|
|
404
|
-
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// explicit stream_reply with format:'html'), reusing it would send
|
|
409
|
-
// literal markdown. Finalize + discard the stale stream so the block
|
|
410
|
-
// below creates a fresh one with the correct parseMode.
|
|
411
|
-
if (stream != null && state.activeDraftParseModes != null) {
|
|
412
|
-
const existingParseMode = state.activeDraftParseModes.get(sKey)
|
|
413
|
-
if (existingParseMode !== parseMode) {
|
|
414
|
-
try {
|
|
415
|
-
await stream.finalize()
|
|
416
|
-
} catch (err) {
|
|
417
|
-
// Best-effort: the in-flight edit may 429 or race. Surface to
|
|
418
|
-
// stderr so the orphaned message id isn't invisible.
|
|
419
|
-
deps.writeError(
|
|
420
|
-
`telegram channel: stream_reply parseMode-rotation finalize failed: ${err}\n`,
|
|
421
|
-
)
|
|
422
|
-
}
|
|
423
|
-
state.activeDraftStreams.delete(sKey)
|
|
424
|
-
state.activeDraftParseModes.delete(sKey)
|
|
425
|
-
stream = undefined
|
|
426
|
-
}
|
|
427
|
-
}
|
|
377
|
+
// #2669: there is now a single rendering mode (rich markdown), so the
|
|
378
|
+
// legacy parseMode-rotation that finalized + recreated a stream whose
|
|
379
|
+
// baked parseMode no longer matched is gone — every stream renders the
|
|
380
|
+
// same way and can be reused as-is.
|
|
428
381
|
|
|
429
382
|
const streamExisted = stream != null
|
|
430
383
|
|
|
@@ -487,7 +440,7 @@ export async function handleStreamReply(
|
|
|
487
440
|
bot: deps.bot,
|
|
488
441
|
chatId: chat_id,
|
|
489
442
|
threadId,
|
|
490
|
-
|
|
443
|
+
literalText,
|
|
491
444
|
disableLinkPreview: deps.disableLinkPreview,
|
|
492
445
|
// Pass undefined when caller didn't override, so draft-stream's
|
|
493
446
|
// DM/group throttle defaults apply (400 ms DMs, 1000 ms groups).
|
|
@@ -531,7 +484,6 @@ export async function handleStreamReply(
|
|
|
531
484
|
},
|
|
532
485
|
})
|
|
533
486
|
state.activeDraftStreams.set(sKey, stream)
|
|
534
|
-
state.activeDraftParseModes?.set(sKey, parseMode)
|
|
535
487
|
}
|
|
536
488
|
|
|
537
489
|
await stream.update(effectiveText)
|
|
@@ -539,7 +491,6 @@ export async function handleStreamReply(
|
|
|
539
491
|
if (done) {
|
|
540
492
|
await stream.finalize()
|
|
541
493
|
state.activeDraftStreams.delete(sKey)
|
|
542
|
-
state.activeDraftParseModes?.delete(sKey)
|
|
543
494
|
// #1713: stream_reply done=true is a NON-EVENT for the status
|
|
544
495
|
// reaction. The reaction reflects current turn activity, not
|
|
545
496
|
// delivery state — only the `turn_end` IPC handler finalizes (👍).
|
|
@@ -549,15 +500,15 @@ export async function handleStreamReply(
|
|
|
549
500
|
// the #1713 issue body for the rationale.
|
|
550
501
|
|
|
551
502
|
// Hard-fail surface: if the stream finalized without ever assigning
|
|
552
|
-
// a message id, the initial send never landed (
|
|
503
|
+
// a message id, the initial send never landed (over-cap text hits
|
|
553
504
|
// draft-stream's length guard and silently stops). Throw so the MCP
|
|
554
505
|
// caller sees isError:true instead of a misleading "finalized
|
|
555
506
|
// (id: pending)". The caller can fall back to `reply`, which chunks.
|
|
556
507
|
if (stream.getMessageId() == null) {
|
|
557
508
|
throw new Error(
|
|
558
509
|
`stream_reply finalized without sending any message (length=${rawText.length}, ` +
|
|
559
|
-
`max
|
|
560
|
-
`auto-chunk. Split the text or use \`reply\` (which chunks).`,
|
|
510
|
+
`max=${RICH_MESSAGE_MAX_CHARS}). Telegram's rich-message limit is ${RICH_MESSAGE_MAX_CHARS} chars and ` +
|
|
511
|
+
`stream_reply does not auto-chunk. Split the text or use \`reply\` (which chunks).`,
|
|
561
512
|
)
|
|
562
513
|
}
|
|
563
514
|
|
|
@@ -44,7 +44,7 @@ import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptL
|
|
|
44
44
|
import { sanitiseToolArg } from './fleet-state.js'
|
|
45
45
|
import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
|
|
46
46
|
import { REPLY_TOOLS, isDraftOfReply } from './narrative-dedup.js'
|
|
47
|
-
import {
|
|
47
|
+
import { truncate } from './card-format.js'
|
|
48
48
|
import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents } from './registry/subagents-schema.js'
|
|
49
49
|
import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
|
|
50
50
|
|
|
@@ -1439,7 +1439,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1439
1439
|
if (idleMs >= threshold) {
|
|
1440
1440
|
entry.stallNotified = true
|
|
1441
1441
|
entry.stalledAt = n
|
|
1442
|
-
const desc =
|
|
1442
|
+
const desc = truncate(entry.description, 80)
|
|
1443
1443
|
const idleSec = Math.floor(idleMs / 1000)
|
|
1444
1444
|
log?.(`subagent-watcher: stall detected for ${entry.agentId} (idle ${idleSec}s): ${desc}`)
|
|
1445
1445
|
// Bug 3 fix (#333): persist the stall into the registry DB.
|
|
@@ -166,13 +166,16 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
|
|
|
166
166
|
stream.update(prose)
|
|
167
167
|
const msgId = await stream.materialize()
|
|
168
168
|
|
|
169
|
-
// materialize() sends a fresh message — prose is not a silent marker
|
|
169
|
+
// materialize() sends a fresh message — prose is not a silent marker.
|
|
170
|
+
// Post-#2669 the body is the RAW transcript markdown and there is no
|
|
171
|
+
// parse_mode (the gateway wrapper ships it via the rich-message path).
|
|
170
172
|
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
171
173
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
172
174
|
'chat45',
|
|
173
175
|
prose,
|
|
174
|
-
expect.objectContaining({
|
|
176
|
+
expect.objectContaining({ link_preview_options: { is_disabled: true } }),
|
|
175
177
|
)
|
|
178
|
+
expect(sendMessage.mock.calls[0][2]).not.toHaveProperty('parse_mode')
|
|
176
179
|
expect(typeof msgId).toBe('number')
|
|
177
180
|
})
|
|
178
181
|
|