switchroom 0.13.22 → 0.13.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/drive-write-pretool.mjs +10 -10
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +802 -746
- package/dist/host-control/main.js +99 -99
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +153 -102
- package/package.json +1 -1
- package/telegram-plugin/answer-stream.ts +39 -14
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +231 -207
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +79 -22
- package/telegram-plugin/pending-work-progress.ts +33 -5
- package/telegram-plugin/tests/answer-stream.test.ts +110 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +56 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +1 -1
|
@@ -251,6 +251,7 @@ import {
|
|
|
251
251
|
tryHostdDispatch,
|
|
252
252
|
hostdRequestId,
|
|
253
253
|
hostdWillBeUsed,
|
|
254
|
+
isHostdEnabled,
|
|
254
255
|
pollHostdStatus,
|
|
255
256
|
hostdGetStatusOnce,
|
|
256
257
|
warnLegacySpawnIfHostdDisabled,
|
|
@@ -3303,12 +3304,19 @@ silencePoke.startTimer({
|
|
|
3303
3304
|
// `SWITCHROOM_DISABLE_PENDING_PROGRESS=1`.
|
|
3304
3305
|
pendingProgress.startTimer({
|
|
3305
3306
|
editMessage: async (ctx) => {
|
|
3307
|
+
// #1698: preserve the anchor's original parse_mode. Without this
|
|
3308
|
+
// the edit goes out as plain text, and any <b>/<code>/<a> tag in
|
|
3309
|
+
// anchorOriginalText (the model authored HTML via the reply tool,
|
|
3310
|
+
// which defaults to format='html') re-renders as literal text the
|
|
3311
|
+
// moment the first "still working (Nm)" tick fires.
|
|
3312
|
+
const editOpts = ctx.parseMode != null ? { parse_mode: ctx.parseMode } : undefined
|
|
3306
3313
|
await swallowingApiCall(
|
|
3307
3314
|
() =>
|
|
3308
3315
|
lockedBot.api.editMessageText(
|
|
3309
3316
|
ctx.chatId,
|
|
3310
3317
|
ctx.messageId,
|
|
3311
3318
|
ctx.newText,
|
|
3319
|
+
editOpts,
|
|
3312
3320
|
),
|
|
3313
3321
|
{
|
|
3314
3322
|
chat_id: ctx.chatId,
|
|
@@ -4567,6 +4575,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4567
4575
|
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
4568
4576
|
messageId: decision.messageId,
|
|
4569
4577
|
text: decision.mergedText,
|
|
4578
|
+
parseMode,
|
|
4570
4579
|
})
|
|
4571
4580
|
if (HISTORY_ENABLED) {
|
|
4572
4581
|
try {
|
|
@@ -4759,6 +4768,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4759
4768
|
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
4760
4769
|
messageId: anchorMsgId,
|
|
4761
4770
|
text: chunks[chunks.length - 1],
|
|
4771
|
+
parseMode,
|
|
4762
4772
|
})
|
|
4763
4773
|
}
|
|
4764
4774
|
}
|
|
@@ -5138,9 +5148,21 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5138
5148
|
// that follows. Capture it so if this turn ends with a pending
|
|
5139
5149
|
// async dispatch, the framework edits THIS message in place at
|
|
5140
5150
|
// intervals.
|
|
5151
|
+
//
|
|
5152
|
+
// #1698 — capture the parse_mode the stream-reply-handler used so
|
|
5153
|
+
// the cross-turn edit tick reuses it. Mirrors the format→parseMode
|
|
5154
|
+
// logic at stream-reply-handler.ts:355-368. Without this, the first
|
|
5155
|
+
// "still working (Nm)" tick edits HTML content as plain text and
|
|
5156
|
+
// <b>/<code>/<a> render as literal tags.
|
|
5157
|
+
const streamFormat = (args.format as string | undefined) ?? (access.parseMode ?? 'html')
|
|
5158
|
+
const streamParseMode: 'HTML' | 'MarkdownV2' | undefined =
|
|
5159
|
+
streamFormat === 'html' ? 'HTML'
|
|
5160
|
+
: streamFormat === 'markdownv2' ? 'MarkdownV2'
|
|
5161
|
+
: undefined
|
|
5141
5162
|
pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
|
|
5142
5163
|
messageId: result.messageId,
|
|
5143
5164
|
text: args.text as string,
|
|
5165
|
+
parseMode: streamParseMode,
|
|
5144
5166
|
})
|
|
5145
5167
|
}
|
|
5146
5168
|
// #1664 — mark the turn's final answer as delivered. For stream_reply a
|
|
@@ -10081,29 +10103,64 @@ bot.command('update', async ctx => {
|
|
|
10081
10103
|
return
|
|
10082
10104
|
}
|
|
10083
10105
|
}
|
|
10084
|
-
//
|
|
10085
|
-
// container, which has the switchroom CLI baked
|
|
10086
|
-
// binary and no /var/run/docker.sock mount. So
|
|
10087
|
-
// pull-images and recreate-containers steps
|
|
10088
|
-
// "docker: command not found"
|
|
10106
|
+
// Pre-dispatch availability gate (#926 / #1469 / #1470). The gateway
|
|
10107
|
+
// runs INSIDE the agent container, which has the switchroom CLI baked
|
|
10108
|
+
// in but no docker binary and no /var/run/docker.sock mount. So
|
|
10109
|
+
// `switchroom update`'s pull-images and recreate-containers steps
|
|
10110
|
+
// would fail with "docker: command not found" — UNLESS hostd is in
|
|
10111
|
+
// play (#1175 Phase 2 RFC C), in which case hostd runs on the host
|
|
10112
|
+
// with the docker socket mounted and the in-container docker
|
|
10113
|
+
// dependency goes away.
|
|
10089
10114
|
//
|
|
10090
|
-
//
|
|
10091
|
-
//
|
|
10092
|
-
//
|
|
10093
|
-
//
|
|
10094
|
-
//
|
|
10095
|
-
|
|
10096
|
-
|
|
10097
|
-
|
|
10098
|
-
|
|
10099
|
-
|
|
10100
|
-
|
|
10101
|
-
|
|
10102
|
-
|
|
10103
|
-
|
|
10104
|
-
|
|
10105
|
-
|
|
10106
|
-
|
|
10115
|
+
// Three states to distinguish here, so the operator gets a message
|
|
10116
|
+
// pointing at the right remediation:
|
|
10117
|
+
//
|
|
10118
|
+
// - hostd ready (socket bound): proceed — no gate.
|
|
10119
|
+
// - hostd configured-on but socket missing (#1470): tell the
|
|
10120
|
+
// operator to run `switchroom hostd install`. Don't conflate
|
|
10121
|
+
// with "enable host_control" — it's already on.
|
|
10122
|
+
// - hostd off AND no in-container docker (#1469): fall through
|
|
10123
|
+
// to spawn-detached would fail late; explain the choice between
|
|
10124
|
+
// host CLI and enabling hostd.
|
|
10125
|
+
const myAgentName = getMyAgentName()
|
|
10126
|
+
const hostdReady = hostdWillBeUsed(myAgentName)
|
|
10127
|
+
if (!hostdReady && !isDockerReachable()) {
|
|
10128
|
+
if (isHostdEnabled()) {
|
|
10129
|
+
// hostd is configured on, but the per-agent socket isn't bound —
|
|
10130
|
+
// hostd hasn't been installed yet, or its daemon is down.
|
|
10131
|
+
await switchroomReply(
|
|
10132
|
+
ctx,
|
|
10133
|
+
`❌ <b>/update apply</b> needs <code>hostd</code>, but its socket ` +
|
|
10134
|
+
`for agent <code>${escapeHtmlForTg(myAgentName)}</code> isn't ` +
|
|
10135
|
+
`bound and in-container docker access isn't available either.\n\n` +
|
|
10136
|
+
`<code>host_control.enabled</code> is on in <code>switchroom.yaml</code>, ` +
|
|
10137
|
+
`so hostd is the expected dispatch path — but no socket at ` +
|
|
10138
|
+
`<code>/run/switchroom/hostd/${escapeHtmlForTg(myAgentName)}/sock</code>. ` +
|
|
10139
|
+
`On a fresh docker install this usually means hostd hasn't been ` +
|
|
10140
|
+
`installed yet.\n\n` +
|
|
10141
|
+
`Run <code>switchroom hostd install</code> on the host to install + ` +
|
|
10142
|
+
`start the daemon, then retry. Send <code>/upgradestatus</code> to ` +
|
|
10143
|
+
`re-check the daemon state from here.`,
|
|
10144
|
+
{ html: true },
|
|
10145
|
+
)
|
|
10146
|
+
} else {
|
|
10147
|
+
// host_control explicitly off + no in-container docker: nothing
|
|
10148
|
+
// can drive the apply from inside the container. Operator has to
|
|
10149
|
+
// pick one of: host CLI, or enable hostd.
|
|
10150
|
+
await switchroomReply(
|
|
10151
|
+
ctx,
|
|
10152
|
+
`❌ <b>/update apply</b> needs docker access from inside the agent ` +
|
|
10153
|
+
`container, but it's not available (no <code>docker</code> binary on ` +
|
|
10154
|
+
`PATH, no <code>/var/run/docker.sock</code> mount) and ` +
|
|
10155
|
+
`<code>host_control.enabled</code> is off.\n\n` +
|
|
10156
|
+
`Either run <code>switchroom update</code> from the host shell, or ` +
|
|
10157
|
+
`set <code>host_control.enabled: true</code> in ` +
|
|
10158
|
+
`<code>switchroom.yaml</code> and run ` +
|
|
10159
|
+
`<code>switchroom hostd install</code> on the host so this verb ` +
|
|
10160
|
+
`can dispatch through the host-side daemon.`,
|
|
10161
|
+
{ html: true },
|
|
10162
|
+
)
|
|
10163
|
+
}
|
|
10107
10164
|
return
|
|
10108
10165
|
}
|
|
10109
10166
|
// Debounce vs concurrent self-restart commands (/restart, /new, /reset
|
|
@@ -45,10 +45,14 @@
|
|
|
45
45
|
* interval is short (5s) but edits are spaced at EDIT_INTERVAL_MS so
|
|
46
46
|
* the Telegram bot.api editMessageText rate stays well under limits.
|
|
47
47
|
*
|
|
48
|
-
* Edits
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
* Edits preserve the anchor's original `parse_mode` (issue #1698). The
|
|
49
|
+
* anchor was sent through the reply tool, which defaults to HTML; an
|
|
50
|
+
* earlier version of this module dropped parse_mode on edit, which made
|
|
51
|
+
* the next "still working (Nm)" tick re-render `<b>` / `<code>` tags as
|
|
52
|
+
* literal text. The suffix itself is plain text (no `<`/`>`/`&`) so it
|
|
53
|
+
* is safe under any parse_mode. On subsequent edits the prior suffix is
|
|
54
|
+
* stripped before re-appending so the message never accumulates duplicate
|
|
55
|
+
* suffixes.
|
|
52
56
|
*
|
|
53
57
|
* Kill switch: `SWITCHROOM_DISABLE_PENDING_PROGRESS=1` disables the
|
|
54
58
|
* whole subsystem. The conversational-pacing prompt is unaffected.
|
|
@@ -76,6 +80,10 @@ export interface PendingProgressEditCtx {
|
|
|
76
80
|
threadId: number | null
|
|
77
81
|
messageId: number
|
|
78
82
|
newText: string
|
|
83
|
+
/** Telegram parse_mode the original anchor was sent with (#1698).
|
|
84
|
+
* The edit must use the same mode or pre-rendered HTML / MarkdownV2
|
|
85
|
+
* tags in `anchorOriginalText` re-render as literal text. */
|
|
86
|
+
parseMode: 'HTML' | 'MarkdownV2' | undefined
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
/**
|
|
@@ -116,6 +124,11 @@ interface State {
|
|
|
116
124
|
/** The captured anchor text — what the model wrote, *minus* any
|
|
117
125
|
* prior pending-progress suffix. Used as the base for every edit. */
|
|
118
126
|
anchorOriginalText: string
|
|
127
|
+
/** parse_mode the anchor was originally sent with. Edits must
|
|
128
|
+
* reuse this or the rendered HTML / MarkdownV2 tags in
|
|
129
|
+
* anchorOriginalText render as literal text on the next tick
|
|
130
|
+
* (issue #1698). */
|
|
131
|
+
anchorParseMode: 'HTML' | 'MarkdownV2' | undefined
|
|
119
132
|
/** Wall-clock ms when the cross-turn ambient state was *activated*
|
|
120
133
|
* (at turn_end with pending+anchor). null before activation. */
|
|
121
134
|
activatedAt: number | null
|
|
@@ -144,6 +157,7 @@ function ensure(key: string): State {
|
|
|
144
157
|
pending: false,
|
|
145
158
|
anchorMessageId: null,
|
|
146
159
|
anchorOriginalText: '',
|
|
160
|
+
anchorParseMode: undefined,
|
|
147
161
|
activatedAt: null,
|
|
148
162
|
lastEditAt: null,
|
|
149
163
|
}
|
|
@@ -181,6 +195,7 @@ export function startTurn(key: string): void {
|
|
|
181
195
|
s.pending = false
|
|
182
196
|
s.anchorMessageId = null
|
|
183
197
|
s.anchorOriginalText = ''
|
|
198
|
+
s.anchorParseMode = undefined
|
|
184
199
|
}
|
|
185
200
|
|
|
186
201
|
/**
|
|
@@ -202,12 +217,24 @@ export function noteAsyncDispatch(key: string): void {
|
|
|
202
217
|
*/
|
|
203
218
|
export function noteOutbound(
|
|
204
219
|
key: string,
|
|
205
|
-
opts: {
|
|
220
|
+
opts: {
|
|
221
|
+
messageId: number
|
|
222
|
+
text: string
|
|
223
|
+
/** parse_mode the anchor was sent with. Captured so the
|
|
224
|
+
* cross-turn edit tick can reuse it (#1698). Undefined or
|
|
225
|
+
* omitted means the original send had no parse_mode (plain
|
|
226
|
+
* text). Production callers MUST pass this — every reply path
|
|
227
|
+
* knows its own parse_mode. Defaulted to undefined only so test
|
|
228
|
+
* fixtures don't have to thread it through where they're
|
|
229
|
+
* asserting other behaviour. */
|
|
230
|
+
parseMode?: 'HTML' | 'MarkdownV2' | undefined
|
|
231
|
+
},
|
|
206
232
|
): void {
|
|
207
233
|
if (!enabled()) return
|
|
208
234
|
const s = ensure(key)
|
|
209
235
|
s.anchorMessageId = opts.messageId
|
|
210
236
|
s.anchorOriginalText = opts.text.replace(SUFFIX_RE, '')
|
|
237
|
+
s.anchorParseMode = opts.parseMode
|
|
211
238
|
}
|
|
212
239
|
|
|
213
240
|
/**
|
|
@@ -331,6 +358,7 @@ function tick(now: number): void {
|
|
|
331
358
|
threadId,
|
|
332
359
|
messageId: s.anchorMessageId,
|
|
333
360
|
newText,
|
|
361
|
+
parseMode: s.anchorParseMode,
|
|
334
362
|
}
|
|
335
363
|
// Fire-and-forget so a slow edit doesn't block the tick loop.
|
|
336
364
|
// Errors are logged but never bubble (a 429 / "message not modified"
|
|
@@ -417,6 +417,116 @@ describe('answer-stream — stop() cancels pending throttled edits', () => {
|
|
|
417
417
|
})
|
|
418
418
|
})
|
|
419
419
|
|
|
420
|
+
// ─── #1704 regression — clear the sendMessageDraft on every terminal path ──
|
|
421
|
+
//
|
|
422
|
+
// In DMs the answer-stream uses sendMessageDraft, which renders inside the
|
|
423
|
+
// user's compose box. Telegram Desktop blocks the user from typing while
|
|
424
|
+
// the bot's draft is live — so stop() / retract() / materialize() must
|
|
425
|
+
// all clear the draft. Without these tests the bug class slips back in
|
|
426
|
+
// the next time someone tweaks the lifecycle.
|
|
427
|
+
|
|
428
|
+
describe('answer-stream — clears sendMessageDraft on terminal paths (#1704)', () => {
|
|
429
|
+
it('stop() clears the draft when draft transport was in use', async () => {
|
|
430
|
+
const sendMessage = makeSendMessage()
|
|
431
|
+
const editMessageText = makeEditMessageText()
|
|
432
|
+
const sendMessageDraft = makeSendMessageDraft()
|
|
433
|
+
const stream = createAnswerStream({
|
|
434
|
+
chatId: 'chat1',
|
|
435
|
+
isPrivateChat: true,
|
|
436
|
+
throttleMs: 250,
|
|
437
|
+
sendMessage,
|
|
438
|
+
editMessageText,
|
|
439
|
+
sendMessageDraft,
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
stream.update('mid-turn thought')
|
|
443
|
+
await flushMicrotasks()
|
|
444
|
+
expect(sendMessageDraft).toHaveBeenCalledTimes(1)
|
|
445
|
+
|
|
446
|
+
stream.stop()
|
|
447
|
+
// stop() is sync but the clear fires fire-and-forget — drain microtasks.
|
|
448
|
+
await flushMicrotasks()
|
|
449
|
+
|
|
450
|
+
// A second draft call must have landed with empty text, clearing the
|
|
451
|
+
// compose-box preview. The draft id matches the in-flight stream's.
|
|
452
|
+
const draftId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
|
|
453
|
+
expect(sendMessageDraft).toHaveBeenCalledWith('chat1', draftId, '', undefined)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('retract() clears the draft when draft transport was in use', async () => {
|
|
457
|
+
const sendMessage = makeSendMessage()
|
|
458
|
+
const editMessageText = makeEditMessageText()
|
|
459
|
+
const sendMessageDraft = makeSendMessageDraft()
|
|
460
|
+
const stream = createAnswerStream({
|
|
461
|
+
chatId: 'chat1',
|
|
462
|
+
isPrivateChat: true,
|
|
463
|
+
throttleMs: 250,
|
|
464
|
+
sendMessage,
|
|
465
|
+
editMessageText,
|
|
466
|
+
sendMessageDraft,
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
stream.update('mid-turn thought')
|
|
470
|
+
await flushMicrotasks()
|
|
471
|
+
expect(sendMessageDraft).toHaveBeenCalledTimes(1)
|
|
472
|
+
|
|
473
|
+
await stream.retract()
|
|
474
|
+
|
|
475
|
+
const draftId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
|
|
476
|
+
expect(sendMessageDraft).toHaveBeenCalledWith('chat1', draftId, '', undefined)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('stop() is a no-op on the draft API when message transport was in use', async () => {
|
|
480
|
+
const sendMessage = makeSendMessage()
|
|
481
|
+
const editMessageText = makeEditMessageText()
|
|
482
|
+
const sendMessageDraft = makeSendMessageDraft()
|
|
483
|
+
const stream = createAnswerStream({
|
|
484
|
+
chatId: 'chat1',
|
|
485
|
+
isPrivateChat: false, // forces message transport
|
|
486
|
+
minInitialChars: 0,
|
|
487
|
+
throttleMs: 250,
|
|
488
|
+
sendMessage,
|
|
489
|
+
editMessageText,
|
|
490
|
+
sendMessageDraft,
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
stream.update('mid-turn thought')
|
|
494
|
+
await flushMicrotasks()
|
|
495
|
+
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
496
|
+
expect(sendMessageDraft).not.toHaveBeenCalled()
|
|
497
|
+
|
|
498
|
+
stream.stop()
|
|
499
|
+
await flushMicrotasks()
|
|
500
|
+
|
|
501
|
+
// Never touched the draft API at all.
|
|
502
|
+
expect(sendMessageDraft).not.toHaveBeenCalled()
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('forwards message_thread_id to the draft-clear call', async () => {
|
|
506
|
+
const sendMessage = makeSendMessage()
|
|
507
|
+
const editMessageText = makeEditMessageText()
|
|
508
|
+
const sendMessageDraft = makeSendMessageDraft()
|
|
509
|
+
const stream = createAnswerStream({
|
|
510
|
+
chatId: 'chat1',
|
|
511
|
+
isPrivateChat: true,
|
|
512
|
+
threadId: 42,
|
|
513
|
+
throttleMs: 250,
|
|
514
|
+
sendMessage,
|
|
515
|
+
editMessageText,
|
|
516
|
+
sendMessageDraft,
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
stream.update('mid-turn thought')
|
|
520
|
+
await flushMicrotasks()
|
|
521
|
+
|
|
522
|
+
await stream.retract()
|
|
523
|
+
|
|
524
|
+
const lastCall = sendMessageDraft.mock.calls[sendMessageDraft.mock.calls.length - 1] as unknown as [string, number, string, { message_thread_id?: number } | undefined]
|
|
525
|
+
expect(lastCall[2]).toBe('')
|
|
526
|
+
expect(lastCall[3]).toEqual({ message_thread_id: 42 })
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
|
|
420
530
|
describe('answer-stream — empty / whitespace-only text is a no-op', () => {
|
|
421
531
|
it('update("") does not trigger any transport call', async () => {
|
|
422
532
|
const sendMessage = makeSendMessage()
|
|
@@ -318,6 +318,62 @@ describe('pending-work-progress', () => {
|
|
|
318
318
|
expect(cap.edits).toHaveLength(0)
|
|
319
319
|
})
|
|
320
320
|
|
|
321
|
+
// ─── #1698 regression — preserve parse_mode on the cross-turn edit ───
|
|
322
|
+
it("preserves the anchor's parse_mode on every edit (#1698)", async () => {
|
|
323
|
+
const cap = setup()
|
|
324
|
+
startTurn(KEY)
|
|
325
|
+
noteAsyncDispatch(KEY)
|
|
326
|
+
// Anchor was sent through the reply tool with format='html', so
|
|
327
|
+
// the captured text is already rendered Telegram HTML.
|
|
328
|
+
noteOutbound(KEY, {
|
|
329
|
+
messageId: 100,
|
|
330
|
+
text: '<b>Worker back.</b> Both blockers fixed.',
|
|
331
|
+
parseMode: 'HTML',
|
|
332
|
+
})
|
|
333
|
+
cap.now = 0
|
|
334
|
+
noteTurnEnd(KEY)
|
|
335
|
+
cap.now = EDIT_INTERVAL_MS
|
|
336
|
+
__tickForTests(cap.now)
|
|
337
|
+
await flush()
|
|
338
|
+
expect(cap.edits).toHaveLength(1)
|
|
339
|
+
expect(cap.edits[0].parseMode).toBe('HTML')
|
|
340
|
+
expect(cap.edits[0].newText).toBe(
|
|
341
|
+
'<b>Worker back.</b> Both blockers fixed.\n\n— still working (1m)',
|
|
342
|
+
)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('passes undefined parseMode through when the anchor was plain text', async () => {
|
|
346
|
+
const cap = setup()
|
|
347
|
+
startTurn(KEY)
|
|
348
|
+
noteAsyncDispatch(KEY)
|
|
349
|
+
// format: 'text' path — anchor was sent without parse_mode.
|
|
350
|
+
noteOutbound(KEY, {
|
|
351
|
+
messageId: 100,
|
|
352
|
+
text: 'plain text reply',
|
|
353
|
+
parseMode: undefined,
|
|
354
|
+
})
|
|
355
|
+
noteTurnEnd(KEY)
|
|
356
|
+
cap.now = EDIT_INTERVAL_MS
|
|
357
|
+
__tickForTests(cap.now)
|
|
358
|
+
await flush()
|
|
359
|
+
expect(cap.edits[0].parseMode).toBeUndefined()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('defaults parseMode to undefined when caller omits it (test ergonomics)', async () => {
|
|
363
|
+
const cap = setup()
|
|
364
|
+
startTurn(KEY)
|
|
365
|
+
noteAsyncDispatch(KEY)
|
|
366
|
+
// Callsite that hasn't been updated for the new field — must not
|
|
367
|
+
// typecheck-fail nor crash. The edit goes out parse_mode-less,
|
|
368
|
+
// matching the pre-#1698 behaviour for legacy callers.
|
|
369
|
+
noteOutbound(KEY, { messageId: 100, text: 'wd' })
|
|
370
|
+
noteTurnEnd(KEY)
|
|
371
|
+
cap.now = EDIT_INTERVAL_MS
|
|
372
|
+
__tickForTests(cap.now)
|
|
373
|
+
await flush()
|
|
374
|
+
expect(cap.edits[0].parseMode).toBeUndefined()
|
|
375
|
+
})
|
|
376
|
+
|
|
321
377
|
it('multiple chats — independent state', async () => {
|
|
322
378
|
const cap = setup()
|
|
323
379
|
const KEY_A = 'A:_'
|
|
@@ -31,7 +31,7 @@ const INTERRUPT = "! actually just reply with the single word 'hello'";
|
|
|
31
31
|
// a JTBD-floor invariant and shouldn't gate every PR that touches
|
|
32
32
|
// telegram-plugin/. Unskip once the underlying behaviour has been
|
|
33
33
|
// audited end-to-end via `bun run test:uat`.
|
|
34
|
-
describe
|
|
34
|
+
describe("uat: ! interrupt marker", () => {
|
|
35
35
|
it(
|
|
36
36
|
"user fires !-interrupt mid-turn → agent picks up new task, drops old",
|
|
37
37
|
async () => {
|