switchroom 0.21.9 → 0.21.11

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.
Files changed (46) hide show
  1. package/dist/cli/switchroom.js +143 -27
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +3 -2
  4. package/telegram-plugin/dist/gateway/gateway.js +1152 -296
  5. package/telegram-plugin/format.ts +74 -1
  6. package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
  7. package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
  8. package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
  9. package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
  10. package/telegram-plugin/gateway/gateway.ts +61 -68
  11. package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
  12. package/telegram-plugin/gateway/outbound-send-path.ts +62 -1
  13. package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
  14. package/telegram-plugin/gateway/reply-route-log.ts +118 -0
  15. package/telegram-plugin/gateway/speech-capture.ts +158 -0
  16. package/telegram-plugin/gateway/stream-render.ts +1 -1
  17. package/telegram-plugin/history.ts +21 -0
  18. package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
  19. package/telegram-plugin/render/html-fold.ts +372 -0
  20. package/telegram-plugin/render/parse.ts +578 -29
  21. package/telegram-plugin/render/render.ts +14 -13
  22. package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
  23. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
  24. package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
  25. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
  26. package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
  27. package/telegram-plugin/tests/history.test.ts +78 -0
  28. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
  29. package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
  30. package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +326 -0
  31. package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
  32. package/telegram-plugin/tests/render/parse.test.ts +9 -5
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +290 -3
  34. package/telegram-plugin/tests/speech-capture.test.ts +296 -0
  35. package/telegram-plugin/tests/status-pin.test.ts +2 -2
  36. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
  37. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
  38. package/telegram-plugin/tests/telegram-format.test.ts +52 -0
  39. package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
  40. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
  41. package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
  42. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
  43. package/telegram-plugin/tts-normalize.ts +47 -9
  44. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
  45. package/telegram-plugin/voice-normalize-text.ts +48 -9
  46. package/telegram-plugin/worker-activity-feed.ts +2 -2
@@ -32,7 +32,13 @@
32
32
  // equivalent entities rather than accidentally re-triggering formatting or
33
33
  // breaking out of a code span.
34
34
 
35
- import { escapeMarkdown, codeSpanSafe, escapeLinkHref, RICH_MESSAGE_MAX_CHARS } from "../format.js";
35
+ import {
36
+ escapeMarkdown,
37
+ codeSpanSafe,
38
+ codeFenceFor,
39
+ escapeLinkHref,
40
+ RICH_MESSAGE_MAX_CHARS,
41
+ } from "../format.js";
36
42
  import type {
37
43
  Block,
38
44
  BlockquoteNode,
@@ -165,15 +171,11 @@ function renderBlockquote(node: BlockquoteNode): string {
165
171
 
166
172
  function renderCodeBlock(node: CodeBlockNode): string {
167
173
  const lang = node.language ?? "";
168
- // Fenced code content is verbatim per the formatting guide; the only
169
- // hazard is an embedded ``` closing the fence early. Widen the fence to a
170
- // run of backticks one longer than the longest run already present in the
171
- // content, mirroring the guide's `preBlock` defusal strategy.
172
- const longestRun = Math.max(
173
- 0,
174
- ...(node.text.match(/`+/g) ?? []).map((run) => run.length),
175
- );
176
- const fence = "`".repeat(Math.max(3, longestRun + 1));
174
+ // Fenced code content is verbatim per the formatting guide; the only hazard
175
+ // is an embedded ``` closing the fence early. Width comes from the shared
176
+ // rule (`codeFenceFor`, format.ts) — one backtick longer than the longest
177
+ // run in the body — which the `<pre>` fold and `degradeToCodeFence` use too.
178
+ const fence = codeFenceFor(node.text);
177
179
  return `${fence}${lang}\n${node.text}\n${fence}`;
178
180
  }
179
181
 
@@ -415,11 +417,10 @@ function tableDegradationReason(node: TableNode): string | null {
415
417
 
416
418
  /** Wrap a block's raw source text in a widened preformatted code fence — the
417
419
  * degraded rendering for a malformed table (content verbatim, no broken
418
- * markup). Fence-widening mirrors `renderCodeBlock`. */
420
+ * markup). Fence-widening is the shared `codeFenceFor` rule. */
419
421
  function degradeToCodeFence(source: string, node: Block): string {
420
422
  const raw = source.slice(node.start, node.end);
421
- const longestRun = Math.max(0, ...(raw.match(/`+/g) ?? []).map((run) => run.length));
422
- const fence = "`".repeat(Math.max(3, longestRun + 1));
423
+ const fence = codeFenceFor(raw);
423
424
  return `${fence}\n${raw}\n${fence}`;
424
425
  }
425
426
 
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The override RECORD is a side effect, and only the real gateway body performs
3
+ * it. This file is the mechanism that proves it still does.
4
+ *
5
+ * WHY A SEPARATE FILE FROM THE DRIFT GUARD. `escalation-staleness.test.ts`
6
+ * ("the recorded override and the EXPLICIT_OVERRIDDEN marker cannot drift
7
+ * apart") re-spells `resolveAnswerThreadWithLog`'s body in the test and calls
8
+ * `createAnswerRouteOverrides().note(...)` on its own instance. That proves the
9
+ * two PREDICATES agree; it cannot prove the gateway CALLS either one. Delete
10
+ * `answerRouteOverrides.note({...})` from `gateway.ts` and every one of those 64
11
+ * cases stays green, the whole vitest suite stays green, and `tsc --noEmit`
12
+ * exits 0 — while the escalation sweep silently loses the only evidence that
13
+ * ties "topic A's answer landed in B" to "an answer was delivered at all", and
14
+ * resumes nagging on top of answers the user already received.
15
+ *
16
+ * That is not hypothetical: the call site has conflicted twice already. On main,
17
+ * #4680 extracted the surrounding block into the pure `formatReplyRouteLog` and
18
+ * dropped the `note()` call in the process. The next person to resolve that
19
+ * conflict in main's favour reverts this PR's entire premise with a green tree.
20
+ * Round 3 caught it by hand — this file replaces that prompt discipline with a
21
+ * deterministic red.
22
+ *
23
+ * HOW. It calls the REAL `resolveAnswerThreadWithLog` from `gateway.ts` (exported
24
+ * as a test seam; no production caller) and asserts on the PROCESS-WIDE
25
+ * `answerRouteOverrides` singleton that obligation escalation reads. Nothing here
26
+ * re-derives the override predicate: the only thing asserted is that driving the
27
+ * real router mutated the real registry.
28
+ *
29
+ * Note for future readers: gateway.ts IS importable from a test runner. Several
30
+ * older comments in this directory claim otherwise ("the gateway IIFE is too
31
+ * entangled to instantiate in-process"); that was true before the P0e import
32
+ * cleanup and is stale now — the boot work is `isGatewayMain`-gated, so a plain
33
+ * `import` is inert.
34
+ */
35
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
36
+ import { resolveAnswerThreadWithLog } from '../gateway/gateway.js'
37
+ import { answerRouteOverrides } from '../gateway/answer-route-overrides.js'
38
+ import type { CurrentTurn } from '../gateway/gateway.js'
39
+
40
+ const CHAT = '-100999'
41
+ /** The topic the model addressed its reply to (marko, 2026-08-10 06:36). */
42
+ const MODEL_TOPIC = 4
43
+ /** The topic the framework's authority actually routed it into. */
44
+ const ROUTED_TOPIC = 635
45
+
46
+ const turn = (chatId: string, threadId: number | undefined): CurrentTurn =>
47
+ ({
48
+ turnId: 't1',
49
+ sessionChatId: chatId,
50
+ sessionThreadId: threadId,
51
+ replyCalled: false,
52
+ }) as unknown as CurrentTurn
53
+
54
+ describe('the gateway router RECORDS the override it performs (side-effect pin)', () => {
55
+ beforeEach(() => {
56
+ // Module singleton — without this a neighbouring file's records leak in and
57
+ // a deleted `note()` could still look satisfied.
58
+ answerRouteOverrides.clear()
59
+ // The router writes its `reply-route` line to stderr; keep the run quiet.
60
+ vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as never)
61
+ })
62
+
63
+ it('a model-steered reply overridden by a same-chat anchor lands in the registry ' +
64
+ 'obligation escalation reads', () => {
65
+ expect(answerRouteOverrides.routedOverridesSince(CHAT, MODEL_TOPIC, 0)).toEqual([])
66
+
67
+ const routed = resolveAnswerThreadWithLog(
68
+ CHAT,
69
+ MODEL_TOPIC,
70
+ turn(CHAT, ROUTED_TOPIC),
71
+ 'echo',
72
+ null,
73
+ 'reply',
74
+ )
75
+
76
+ // Precondition: this really is the override shape, not an agreeing route.
77
+ expect(routed).toBe(ROUTED_TOPIC)
78
+ expect(routed).not.toBe(MODEL_TOPIC)
79
+
80
+ // THE PIN. Empty here means `answerRouteOverrides.note(...)` no longer runs
81
+ // in `resolveAnswerThreadWithLog` — the escalation sweep has lost its only
82
+ // per-turn evidence and will nag on top of delivered answers.
83
+ const recorded = answerRouteOverrides.routedOverridesSince(CHAT, MODEL_TOPIC, 0)
84
+ expect(recorded).toHaveLength(1)
85
+ expect(recorded[0]!.routedThreadId).toBe(ROUTED_TOPIC)
86
+ expect(recorded[0]!.atMs).toBeGreaterThan(0)
87
+ })
88
+
89
+ it('records under the topic the MODEL named, not the one it routed to — the ' +
90
+ 'consumer keys its lookup by the intended topic', () => {
91
+ resolveAnswerThreadWithLog(CHAT, MODEL_TOPIC, turn(CHAT, ROUTED_TOPIC), 'echo', null, 'reply')
92
+ expect(answerRouteOverrides.routedOverridesSince(CHAT, ROUTED_TOPIC, 0)).toEqual([])
93
+ expect(answerRouteOverrides.routedOverridesSince(CHAT, MODEL_TOPIC, 0)).toHaveLength(1)
94
+ })
95
+
96
+ it('NEGATIVE CONTROL: a route that overrode nothing records nothing (so the ' +
97
+ 'pin above cannot be satisfied by an unconditional write)', () => {
98
+ // Anchor agrees with the model's topic — no override, nothing to record.
99
+ const routed = resolveAnswerThreadWithLog(
100
+ CHAT,
101
+ MODEL_TOPIC,
102
+ turn(CHAT, MODEL_TOPIC),
103
+ 'echo',
104
+ null,
105
+ 'reply',
106
+ )
107
+ expect(routed).toBe(MODEL_TOPIC)
108
+ expect(answerRouteOverrides.routedOverridesSince(CHAT, MODEL_TOPIC, 0)).toEqual([])
109
+ expect(answerRouteOverrides.size()).toBe(0)
110
+ })
111
+ })
@@ -49,10 +49,10 @@ describe('(a) a forwarded plain-text message records a history row with forwarde
49
49
  const forwardOrigin = parseForwardOrigin({
50
50
  type: 'user',
51
51
  date: 1_700_000_000,
52
- sender_user: { id: 424242, is_bot: false, first_name: 'Ken', last_name: 'Thompson' },
52
+ sender_user: { id: 424242, is_bot: false, first_name: 'Alice', last_name: 'Example' },
53
53
  })
54
54
  expect(forwardOrigin).toBeDefined()
55
- expect(forwardOrigin!.name).toBe('Ken Thompson')
55
+ expect(forwardOrigin!.name).toBe('Alice Example')
56
56
 
57
57
  recordInbound({
58
58
  chat_id,
@@ -74,7 +74,7 @@ describe('(a) a forwarded plain-text message records a history row with forwarde
74
74
  // forwarded provenance.
75
75
  expect(row.role).toBe('user')
76
76
  expect(row.text).toBe('here is the brief I forwarded')
77
- expect(row.forwarded_from).toBe('Ken Thompson')
77
+ expect(row.forwarded_from).toBe('Alice Example')
78
78
  expect(row.forwarded_from_type).toBe('user')
79
79
  expect(row.forwarded_from_id).toBe('424242')
80
80
  })