switchroom 0.21.10 → 0.21.12

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 (40) hide show
  1. package/dist/cli/switchroom.js +143 -27
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +5 -2
  4. package/skills/dev-protocol/SKILL.md +14 -10
  5. package/skills/switchroom-release/SKILL.md +11 -5
  6. package/telegram-plugin/dist/gateway/gateway.js +385 -35
  7. package/telegram-plugin/format.ts +56 -0
  8. package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
  9. package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
  10. package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
  11. package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
  12. package/telegram-plugin/gateway/gateway.ts +61 -68
  13. package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
  14. package/telegram-plugin/gateway/outbound-send-path.ts +55 -1
  15. package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
  16. package/telegram-plugin/gateway/reply-route-log.ts +118 -0
  17. package/telegram-plugin/gateway/stream-render.ts +1 -1
  18. package/telegram-plugin/history.ts +21 -0
  19. package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
  20. package/telegram-plugin/render/html-fold.ts +22 -4
  21. package/telegram-plugin/render/parse.ts +9 -1
  22. package/telegram-plugin/render/render.ts +14 -13
  23. package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
  24. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
  25. package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
  26. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
  27. package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
  28. package/telegram-plugin/tests/history.test.ts +78 -0
  29. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
  30. package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
  31. package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +76 -3
  32. package/telegram-plugin/tests/send-reply-golden.test.ts +192 -3
  33. package/telegram-plugin/tests/status-pin.test.ts +2 -2
  34. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
  35. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
  36. package/telegram-plugin/tests/telegram-format.test.ts +52 -0
  37. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
  38. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
  39. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
  40. package/telegram-plugin/worker-activity-feed.ts +2 -2
@@ -1263,6 +1263,20 @@ export function getRecentOutboundCount(
1263
1263
  * - explicit number → only that thread (precise for supergroups with topics)
1264
1264
  * - explicit null → only chat-root (non-thread) messages
1265
1265
  *
1266
+ * `untilMs` semantics — the optional UPPER bound (#4681):
1267
+ * - omitted → open-ended forward, the original behaviour. Correct for a caller
1268
+ * whose SCOPE is already tied to the obligation (its own topic): anything
1269
+ * substantive delivered there since it opened is plausibly its answer.
1270
+ * - a number → only rows at or before it. Required by any caller whose scope is
1271
+ * licensed by a SEPARATE, time-bounded piece of evidence — the obligation
1272
+ * escalate branch's reroute fallback, where an `EXPLICIT_OVERRIDDEN` record
1273
+ * licenses a look in ANOTHER topic. There an open-ended forward query lets
1274
+ * unrelated traffic in that topic, arriving arbitrarily later, be mistaken
1275
+ * for the answer that routing produced. See `escalation-staleness.ts`.
1276
+ * Applied at SECOND granularity like the lower bound, rounded UP (ceil) so
1277
+ * rounding can only ever ADMIT a borderline row, never drop a legitimate one —
1278
+ * the false-negative-safe direction for this predicate.
1279
+ *
1266
1280
  * Falls back to false (safe: never suppresses escalation) if history is not yet
1267
1281
  * initialised or the query fails.
1268
1282
  */
@@ -1271,6 +1285,7 @@ export function hasOutboundDeliveredSince(
1271
1285
  sinceMs: number,
1272
1286
  threadId?: number | null,
1273
1287
  minChars = 200,
1288
+ untilMs?: number,
1274
1289
  ): boolean {
1275
1290
  try {
1276
1291
  const cutoffSec = Math.floor(sinceMs / 1000)
@@ -1287,6 +1302,12 @@ export function hasOutboundDeliveredSince(
1287
1302
  // edits / typing indicators are structurally excluded.
1288
1303
  let sql =
1289
1304
  "SELECT 1 FROM messages WHERE chat_id = ? AND role = 'assistant' AND ts >= ? AND LENGTH(text) >= ?"
1305
+ if (untilMs != null && Number.isFinite(untilMs)) {
1306
+ // Ceil: `ts` is whole seconds, so a bound of e.g. 18_500 ms past the
1307
+ // second boundary must still admit the row written in that second.
1308
+ sql += ' AND ts <= ?'
1309
+ params.push(Math.ceil(untilMs / 1000))
1310
+ }
1290
1311
  if (threadId !== undefined) {
1291
1312
  if (threadId === null) {
1292
1313
  sql += ' AND thread_id IS NULL'
@@ -431,9 +431,9 @@ function seedTurn(turnKey: string, chatId = '12345', threadId: string | null = n
431
431
  describe('Bug 5 — parent_turn_key stamped from the turn-active marker', () => {
432
432
  it('stamps parent_turn_key = marker.turnKey when a turn is active', () => {
433
433
  // Supergroup forum-topic turn_key (chat:thread:startedAt).
434
- const turnKey = '-1003831053471:4:1780370238492'
435
- seedTurn(turnKey, '-1003831053471', '4')
436
- writeTurnActiveMarker(turnKey, '-1003831053471', '4')
434
+ const turnKey = '-1001234567890:4:1780370238492'
435
+ seedTurn(turnKey, '-1001234567890', '4')
436
+ writeTurnActiveMarker(turnKey, '-1001234567890', '4')
437
437
 
438
438
  const event = {
439
439
  session_id: 'sess-turnkey',
@@ -57,10 +57,28 @@
57
57
  // angle bracket without tripping `unsupported start tag`.
58
58
  //
59
59
  // ── The invariant ─────────────────────────────────────────────────────────
60
- // CONTENT is never lost. Only MARKUP is dropped, and only where dropping it
61
- // loses no reader-visible text: a matched pair, a comment, a void tag.
62
- // Anything not demonstrably markup survives to the wire as escaped literal
63
- // text. (Known residual, NOT introduced by this module and not fixed here: a
60
+ // Only MARKUP is dropped. A token that is not demonstrably markup — an
61
+ // UNMATCHED open/close marker, which is overwhelmingly the shape prose
62
+ // actually takes — always survives to the wire as escaped literal text. That
63
+ // case is protected absolutely. The three shapes classified as markup, and so
64
+ // dropped, are a matched pair, a comment, and a void tag.
65
+ //
66
+ // The invariant is NOT "content is never lost", and must not be read that
67
+ // way. MATCHING is a syntactic discriminator, not a semantic one, so a
68
+ // BALANCED pair of angle brackets in prose is indistinguishable from real
69
+ // markup and is dropped with it:
70
+ // "Use <key> then later </key> done." -> "Use then later done."
71
+ // "Wrap it in <b> and close with </b>." -> "Wrap it in ** and close
72
+ // with **."
73
+ // — author-visible prose is deleted in both, and the second additionally
74
+ // re-wraps the surviving text in the construct the pair named. This is the
75
+ // accepted, deliberate price of the matching discriminator, NOT a defect to
76
+ // fix by weakening it: the alternative (classifying by tag NAME) is what
77
+ // silently deleted every `<service>/<key>` and `<agent>` in this project's own
78
+ // agent output — far more common, and far more damaging, than a balanced pair
79
+ // of brackets in prose.
80
+ //
81
+ // (Second known residual, NOT introduced by this module and not fixed here: a
64
82
  // `<` in prose that mdast hands over as a TEXT node rather than an `html` node
65
83
  // — the decoded `&lt;c&gt;`, or `response in <1s` — never reaches this module
66
84
  // and still ships unescaped. That is a `plain`-node / `escapeMarkdown`
@@ -114,6 +114,7 @@ import {
114
114
  type HtmlFoldTarget,
115
115
  type HtmlTagInfo,
116
116
  } from "./html-fold.js";
117
+ import { codeFenceFor } from "../format.js";
117
118
 
118
119
  /** Copy UTF-16 offsets off an mdast node. Falls back to 0-length when a
119
120
  * synthesized node lacks a position (from-markdown always sets one, but the
@@ -684,7 +685,14 @@ function buildPreBlock(
684
685
  if (ctx.noBreaks === true) {
685
686
  return [{ type: "code", text: text.replace(/\s*\r?\n\s*/g, " "), ...span }];
686
687
  }
687
- const fence = "```";
688
+ // The fence ships as a `raw` node — verbatim wire passthrough, so nothing
689
+ // downstream will widen it for us. A hardcoded ``` around a body carrying
690
+ // its own ``` earns `can't find end of Pre entity` and a plain-text resend
691
+ // of the whole message. Width comes from the same shared rule
692
+ // `renderCodeBlock` uses (`codeFenceFor`, format.ts): `buildPreBlock`
693
+ // returns `Inline[]` and so cannot route through `renderCodeBlock` (which
694
+ // takes a `Block`), but the width rule is SHARED, not copied.
695
+ const fence = codeFenceFor(text);
688
696
  return [
689
697
  mkSeparator(ctx, span.start, span.start),
690
698
  {
@@ -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
  })