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.
- package/dist/cli/switchroom.js +143 -27
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +1152 -296
- package/telegram-plugin/format.ts +74 -1
- package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
- package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
- package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
- package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
- package/telegram-plugin/gateway/gateway.ts +61 -68
- package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +62 -1
- package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
- package/telegram-plugin/gateway/reply-route-log.ts +118 -0
- package/telegram-plugin/gateway/speech-capture.ts +158 -0
- package/telegram-plugin/gateway/stream-render.ts +1 -1
- package/telegram-plugin/history.ts +21 -0
- package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
- package/telegram-plugin/render/html-fold.ts +372 -0
- package/telegram-plugin/render/parse.ts +578 -29
- package/telegram-plugin/render/render.ts +14 -13
- package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
- package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
- package/telegram-plugin/tests/history.test.ts +78 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
- package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
- package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +326 -0
- package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
- package/telegram-plugin/tests/render/parse.test.ts +9 -5
- package/telegram-plugin/tests/send-reply-golden.test.ts +290 -3
- package/telegram-plugin/tests/speech-capture.test.ts +296 -0
- package/telegram-plugin/tests/status-pin.test.ts +2 -2
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
- package/telegram-plugin/tests/telegram-format.test.ts +52 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
- package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
- package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
- package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
- package/telegram-plugin/tts-normalize.ts +47 -9
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
- package/telegram-plugin/voice-normalize-text.ts +48 -9
- 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 {
|
|
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
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
const
|
|
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
|
|
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
|
|
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: '
|
|
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('
|
|
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('
|
|
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
|
})
|