switchroom 0.16.23 → 0.16.24
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 +671 -430
- package/dist/host-control/main.js +156 -156
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +5 -5
- package/telegram-plugin/answer-stream.ts +13 -43
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1855 -2077
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +101 -662
- 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/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 +751 -624
- 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 +28 -0
- 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/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 +36 -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 +15 -7
- package/telegram-plugin/rich-send.ts +57 -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 +23 -6
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +40 -97
- 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/issues-card.test.ts +15 -12
- 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/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-title.test.ts +45 -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
|
@@ -31,10 +31,15 @@ export interface BannerBotApi {
|
|
|
31
31
|
text: string,
|
|
32
32
|
opts?: Record<string, unknown>,
|
|
33
33
|
): Promise<{ message_id: number }>;
|
|
34
|
+
sendRichMessage(
|
|
35
|
+
chat_id: string | number,
|
|
36
|
+
rich_message: { markdown: string },
|
|
37
|
+
opts?: Record<string, unknown>,
|
|
38
|
+
): Promise<{ message_id: number }>;
|
|
34
39
|
editMessageText(
|
|
35
40
|
chat_id: string | number,
|
|
36
41
|
message_id: number,
|
|
37
|
-
text: string,
|
|
42
|
+
text: string | { markdown: string },
|
|
38
43
|
opts?: Record<string, unknown>,
|
|
39
44
|
): Promise<unknown>;
|
|
40
45
|
pinChatMessage(
|
|
@@ -107,9 +112,8 @@ export async function refreshBanner(
|
|
|
107
112
|
if (action.kind === 'pin') {
|
|
108
113
|
let sent: { message_id: number };
|
|
109
114
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
link_preview_options: { is_disabled: true },
|
|
115
|
+
// sendRichMessage doesn't accept link_preview_options — omit it.
|
|
116
|
+
sent = await args.bot.api.sendRichMessage(args.ownerChatId, { markdown: action.text }, {
|
|
113
117
|
// OAuth slot banner is a status notice — silence the open ping.
|
|
114
118
|
// (the pin below is already silent; the edit path doesn't ping.)
|
|
115
119
|
disable_notification: true,
|
|
@@ -135,9 +139,8 @@ export async function refreshBanner(
|
|
|
135
139
|
await args.bot.api.editMessageText(
|
|
136
140
|
args.ownerChatId,
|
|
137
141
|
action.messageId,
|
|
138
|
-
action.text,
|
|
142
|
+
{ markdown: action.text },
|
|
139
143
|
{
|
|
140
|
-
parse_mode: 'HTML',
|
|
141
144
|
link_preview_options: { is_disabled: true },
|
|
142
145
|
},
|
|
143
146
|
);
|
|
@@ -72,15 +72,12 @@ export function formatBannerHtml(
|
|
|
72
72
|
defaultSlot: string,
|
|
73
73
|
): string {
|
|
74
74
|
return [
|
|
75
|
-
`📌
|
|
76
|
-
|
|
75
|
+
`📌 **${escapeMarkdown(agentName)}** is running on slot \`${currentSlot}\``,
|
|
76
|
+
`_(failover from \`${defaultSlot}\`)_`,
|
|
77
77
|
].join(' ');
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.replace(/</g, '<')
|
|
84
|
-
.replace(/>/g, '>')
|
|
85
|
-
.replace(/"/g, '"');
|
|
80
|
+
/** Local GFM-markdown escaper (keeps this module dependency-free). */
|
|
81
|
+
function escapeMarkdown(text: string): string {
|
|
82
|
+
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1');
|
|
86
83
|
}
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* These helpers are pure so server.ts stays testable without standing up
|
|
10
10
|
* grammy.
|
|
11
11
|
*/
|
|
12
|
-
import { escapeHtml } from './format.js'
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
14
|
* Detect and strip the `/queue ` or `/q ` prefix.
|
|
@@ -181,6 +180,3 @@ export function buildChannelMetaAttributes(opts: ChannelMetaAttributeOptions): s
|
|
|
181
180
|
}
|
|
182
181
|
return parts.length === 0 ? '' : ' ' + parts.join(' ')
|
|
183
182
|
}
|
|
184
|
-
|
|
185
|
-
/** Re-export so callers that want HTML-escape for body content don't need a second import. */
|
|
186
|
-
export { escapeHtml }
|
|
@@ -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,10 +150,6 @@ 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
|
|
174
155
|
/** Validates the chat id against the access list. Throws on deny. */
|
|
@@ -185,7 +166,8 @@ export interface StreamReplyDeps {
|
|
|
185
166
|
getLatestInboundMessageId?: (chatId: string, threadId: number | null) => number | null
|
|
186
167
|
/** Config: disable link previews. Default true. */
|
|
187
168
|
disableLinkPreview: boolean
|
|
188
|
-
/** Config: fallback
|
|
169
|
+
/** Config: fallback format when args.format is omitted. Anything other
|
|
170
|
+
* than the literal `'text'` is treated as the rich-markdown path (#2669). */
|
|
189
171
|
defaultFormat: string
|
|
190
172
|
/** Observability: per-call event. */
|
|
191
173
|
logStreamingEvent: (ev: {
|
|
@@ -341,53 +323,36 @@ export async function handleStreamReply(
|
|
|
341
323
|
// the progressive-streaming contract documented in
|
|
342
324
|
// profiles/default/CLAUDE.md. See #481.
|
|
343
325
|
|
|
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
|
-
}
|
|
326
|
+
// Single rich-markdown path (#2669). The only fork is the literal
|
|
327
|
+
// `format:'text'` send (plain string, no markdown parsing); everything
|
|
328
|
+
// else ships the raw GFM markdown via the rich-message path. No
|
|
329
|
+
// markdown→HTML / MarkdownV2 rendering happens here anymore — the raw
|
|
330
|
+
// text IS the wire payload.
|
|
331
|
+
const literalText = format === 'text'
|
|
332
|
+
let effectiveText: string = rawText
|
|
360
333
|
|
|
361
|
-
// Inline status-accent header (issue #320 fallback). Prepended
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// Unrecognised values are silently ignored (empty string returned).
|
|
334
|
+
// Inline status-accent header (issue #320 fallback). Prepended so it
|
|
335
|
+
// leads the body. Since stream_reply callers pass the full text snapshot
|
|
336
|
+
// each call, the header is prepended on every call that supplies
|
|
337
|
+
// `accent` — keeping the rendered message consistent across edits.
|
|
338
|
+
// Callers that don't want the header on a subsequent edit simply omit
|
|
339
|
+
// `accent`. Unrecognised values are silently ignored (empty string).
|
|
368
340
|
if (args.accent != null) {
|
|
369
341
|
const accentHeader = buildAccentHeader(args.accent)
|
|
370
342
|
if (accentHeader.length > 0) {
|
|
371
343
|
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
344
|
}
|
|
378
345
|
}
|
|
379
346
|
|
|
380
347
|
// 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
|
|
348
|
+
// (a) a first call over the cap fails cleanly instead of creating a
|
|
349
|
+
// half-initialized stream, and (b) a mid-stream update over the cap
|
|
383
350
|
// fails loudly instead of setting the internal `stopped=true` flag
|
|
384
351
|
// and silently dropping all subsequent text. Either way the caller
|
|
385
352
|
// 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) {
|
|
353
|
+
if (effectiveText.length > RICH_MESSAGE_MAX_CHARS) {
|
|
389
354
|
throw new Error(
|
|
390
|
-
`stream_reply rejected: text exceeds Telegram's
|
|
355
|
+
`stream_reply rejected: text exceeds Telegram's ${RICH_MESSAGE_MAX_CHARS}-char rich-message limit ` +
|
|
391
356
|
`(length=${effectiveText.length}, format=${format}). stream_reply does not ` +
|
|
392
357
|
`auto-chunk — split the text or use \`reply\`, which chunks.`,
|
|
393
358
|
)
|
|
@@ -401,30 +366,10 @@ export async function handleStreamReply(
|
|
|
401
366
|
// targets. Cleared on turn_end by server.ts.
|
|
402
367
|
state.suppressPtyPreview?.add(streamKey(chat_id, threadId))
|
|
403
368
|
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
|
-
}
|
|
369
|
+
// #2669: there is now a single rendering mode (rich markdown), so the
|
|
370
|
+
// legacy parseMode-rotation that finalized + recreated a stream whose
|
|
371
|
+
// baked parseMode no longer matched is gone — every stream renders the
|
|
372
|
+
// same way and can be reused as-is.
|
|
428
373
|
|
|
429
374
|
const streamExisted = stream != null
|
|
430
375
|
|
|
@@ -487,7 +432,7 @@ export async function handleStreamReply(
|
|
|
487
432
|
bot: deps.bot,
|
|
488
433
|
chatId: chat_id,
|
|
489
434
|
threadId,
|
|
490
|
-
|
|
435
|
+
literalText,
|
|
491
436
|
disableLinkPreview: deps.disableLinkPreview,
|
|
492
437
|
// Pass undefined when caller didn't override, so draft-stream's
|
|
493
438
|
// DM/group throttle defaults apply (400 ms DMs, 1000 ms groups).
|
|
@@ -531,7 +476,6 @@ export async function handleStreamReply(
|
|
|
531
476
|
},
|
|
532
477
|
})
|
|
533
478
|
state.activeDraftStreams.set(sKey, stream)
|
|
534
|
-
state.activeDraftParseModes?.set(sKey, parseMode)
|
|
535
479
|
}
|
|
536
480
|
|
|
537
481
|
await stream.update(effectiveText)
|
|
@@ -539,7 +483,6 @@ export async function handleStreamReply(
|
|
|
539
483
|
if (done) {
|
|
540
484
|
await stream.finalize()
|
|
541
485
|
state.activeDraftStreams.delete(sKey)
|
|
542
|
-
state.activeDraftParseModes?.delete(sKey)
|
|
543
486
|
// #1713: stream_reply done=true is a NON-EVENT for the status
|
|
544
487
|
// reaction. The reaction reflects current turn activity, not
|
|
545
488
|
// delivery state — only the `turn_end` IPC handler finalizes (👍).
|
|
@@ -549,15 +492,15 @@ export async function handleStreamReply(
|
|
|
549
492
|
// the #1713 issue body for the rationale.
|
|
550
493
|
|
|
551
494
|
// Hard-fail surface: if the stream finalized without ever assigning
|
|
552
|
-
// a message id, the initial send never landed (
|
|
495
|
+
// a message id, the initial send never landed (over-cap text hits
|
|
553
496
|
// draft-stream's length guard and silently stops). Throw so the MCP
|
|
554
497
|
// caller sees isError:true instead of a misleading "finalized
|
|
555
498
|
// (id: pending)". The caller can fall back to `reply`, which chunks.
|
|
556
499
|
if (stream.getMessageId() == null) {
|
|
557
500
|
throw new Error(
|
|
558
501
|
`stream_reply finalized without sending any message (length=${rawText.length}, ` +
|
|
559
|
-
`max
|
|
560
|
-
`auto-chunk. Split the text or use \`reply\` (which chunks).`,
|
|
502
|
+
`max=${RICH_MESSAGE_MAX_CHARS}). Telegram's rich-message limit is ${RICH_MESSAGE_MAX_CHARS} chars and ` +
|
|
503
|
+
`stream_reply does not auto-chunk. Split the text or use \`reply\` (which chunks).`,
|
|
561
504
|
)
|
|
562
505
|
}
|
|
563
506
|
|
|
@@ -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
|
|