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.
- package/dist/cli/switchroom.js +143 -27
- package/dist/host-control/main.js +1 -1
- package/package.json +5 -2
- package/skills/dev-protocol/SKILL.md +14 -10
- package/skills/switchroom-release/SKILL.md +11 -5
- package/telegram-plugin/dist/gateway/gateway.js +385 -35
- package/telegram-plugin/format.ts +56 -0
- 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 +55 -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/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 +22 -4
- package/telegram-plugin/render/parse.ts +9 -1
- 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 +76 -3
- package/telegram-plugin/tests/send-reply-golden.test.ts +192 -3
- 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/turn-supersede-finalizes-prior-card.test.ts +1 -1
- package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
- 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 = '-
|
|
435
|
-
seedTurn(turnKey, '-
|
|
436
|
-
writeTurnActiveMarker(turnKey, '-
|
|
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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
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 `<c>`, 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
|
-
|
|
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 {
|
|
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
|
})
|