switchroom 0.13.23 → 0.13.25

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.
@@ -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
- // Docker reachability guard (#926). The gateway runs INSIDE the agent
10085
- // container, which has the switchroom CLI baked in but no docker
10086
- // binary and no /var/run/docker.sock mount. So `switchroom update`'s
10087
- // pull-images and recreate-containers steps would fail with
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
- // BYPASSED when hostd is on (#1175 Phase 2 RFC C): hostd runs on the
10091
- // host with the docker socket mounted, so the in-container docker
10092
- // dependency goes away. Skip the guard so /update apply can dispatch
10093
- // through hostd. When hostd is NOT in play, keep the guard so the
10094
- // operator gets a clean explanation instead of an opaque exit-127.
10095
- if (!hostdWillBeUsed(getMyAgentName()) && !isDockerReachable()) {
10096
- await switchroomReply(
10097
- ctx,
10098
- `❌ <b>/update apply</b> needs docker access from inside the agent ` +
10099
- `container, but it's not available (no <code>docker</code> binary on ` +
10100
- `PATH, no <code>/var/run/docker.sock</code> mount).\n\n` +
10101
- `On docker installs, either run <code>switchroom update</code> from ` +
10102
- `the host shell, or enable <code>host_control.enabled</code> in ` +
10103
- `<code>switchroom.yaml</code> and <code>switchroom hostd install</code> ` +
10104
- `so this verb dispatches through the host-side daemon.`,
10105
- { html: true },
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 are plain text (no parseMode). The suffix is appended to the
49
- * model's authored text; on subsequent edits the prior suffix is
50
- * stripped before re-appending so the message never accumulates
51
- * duplicate suffixes.
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: { messageId: number; text: string },
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:_'
@@ -32,8 +32,8 @@ describe('markdownToHtml', () => {
32
32
  expect(markdownToHtml('Hello _world_')).toContain('<i>world</i>')
33
33
  })
34
34
 
35
- test('converts emoji-leading _📥 queued as a new task_', () => {
36
- expect(markdownToHtml('_📥 queued as a new task_')).toContain('<i>📥 queued as a new task</i>')
35
+ test('converts emoji-leading _📥 Queued as a new task_', () => {
36
+ expect(markdownToHtml('_📥 Queued as a new task_')).toContain('<i>📥 Queued as a new task</i>')
37
37
  })
38
38
 
39
39
  test('converts emoji-trailing _steer on the prior task 🔁_', () => {
@@ -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.skip("uat: ! interrupt marker", () => {
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 () => {
@@ -45,7 +45,7 @@ describe("uat: rapid follow-ups — steering vs queued classification", () => {
45
45
 
46
46
  // The agent should reply mentioning md5 AND surface the italic
47
47
  // classification line per the prompt
48
- // ("_↪️ treating as steer on the prior task_" or similar).
48
+ // ("_↪️ Treating as steer on the prior task_" or similar).
49
49
  // We match either explicit-steer narration OR the steer emoji
50
50
  // (`↪️`) to allow for natural-language variation while still
51
51
  // failing if no narration appears (the previous version of