switchroom 0.16.29 → 0.16.46
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/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -65,12 +65,31 @@ const dispatchCallsites = LINES.flatMap((line, i) =>
|
|
|
65
65
|
const isSilentNoCardVerdict = (idx: number): boolean =>
|
|
66
66
|
LINES.slice(Math.max(0, idx - 3), idx + 1).some((l) => /no-card-verdict/.test(l))
|
|
67
67
|
|
|
68
|
+
// A CARD-FOLDED-RESUME path (the "Allow once" tap, card-ux fix 3) does NOT
|
|
69
|
+
// post a separate `postPermissionResumeMessage()` — the agent-voiced "got it,
|
|
70
|
+
// continuing: <work>" line is folded INTO the same card edit (below the ✅
|
|
71
|
+
// label) via `finalizeCallback`, collapsing the old split second message. The
|
|
72
|
+
// turn still resumes (`dispatchPermissionVerdict` + `resumeReactionAfterVerdict`
|
|
73
|
+
// both fire) and the operator still sees the continuation — it just rides the
|
|
74
|
+
// tapped card instead of fanning out to every card target. Such callsites carry
|
|
75
|
+
// the `card-folded-resume` sentinel within the RESUME_WINDOW below the dispatch
|
|
76
|
+
// and are exempt ONLY from the post-message pairing (still required to flip the
|
|
77
|
+
// glyph). The invariant the guard protects (a verdict that un-parks a card must
|
|
78
|
+
// visibly resume it) still holds — the resume is in the card body.
|
|
79
|
+
const isCardFoldedResume = (idx: number): boolean =>
|
|
80
|
+
LINES.slice(idx, idx + POST_WINDOW + 1).some((l) => /card-folded-resume/.test(l))
|
|
81
|
+
|
|
68
82
|
// How far below the dispatch the resume call is allowed to live. The
|
|
69
83
|
// widest real gap today is ~9 lines (the slash-command path); 15 gives
|
|
70
84
|
// refactor headroom without letting an unrelated resume "cover" a
|
|
71
85
|
// dispatch from a different block.
|
|
72
86
|
const RESUME_WINDOW = 15
|
|
73
87
|
|
|
88
|
+
// postPermissionResumeMessage rides ~1–2 lines after resumeReactionAfterVerdict
|
|
89
|
+
// on every path, so it can sit a touch further from the dispatch than the
|
|
90
|
+
// resume call — give it a little more headroom.
|
|
91
|
+
const POST_WINDOW = 20
|
|
92
|
+
|
|
74
93
|
describe('permission verdict → resume reaction wiring', () => {
|
|
75
94
|
it('there is at least one verdict-dispatch path to guard', () => {
|
|
76
95
|
expect(dispatchCallsites.length).toBeGreaterThan(0)
|
|
@@ -102,15 +121,13 @@ describe('permission verdict → resume reaction wiring', () => {
|
|
|
102
121
|
)
|
|
103
122
|
})
|
|
104
123
|
|
|
105
|
-
|
|
106
|
-
// on every path, so it can sit a touch further from the dispatch than the
|
|
107
|
-
// resume call — give it a little more headroom.
|
|
108
|
-
const POST_WINDOW = 20
|
|
109
|
-
|
|
110
|
-
it('every dispatchPermissionVerdict() callsite posts the agent-voiced resume message via postPermissionResumeMessage()', () => {
|
|
124
|
+
it('every dispatchPermissionVerdict() callsite posts the agent-voiced resume message via postPermissionResumeMessage() or folds it into the card', () => {
|
|
111
125
|
const unpaired: number[] = []
|
|
112
126
|
for (const idx of dispatchCallsites) {
|
|
113
127
|
if (isSilentNoCardVerdict(idx)) continue
|
|
128
|
+
// Exempt: card-ux fix 3 folds the resume line into the card edit
|
|
129
|
+
// instead of a separate message (still visibly resumes the operator).
|
|
130
|
+
if (isCardFoldedResume(idx)) continue
|
|
114
131
|
const window = LINES.slice(idx, idx + POST_WINDOW + 1).join('\n')
|
|
115
132
|
if (!/\bpostPermissionResumeMessage\s*\(/.test(window)) {
|
|
116
133
|
unpaired.push(idx + 1)
|
|
@@ -92,11 +92,18 @@ describe('formatQuotaBlock', () => {
|
|
|
92
92
|
now,
|
|
93
93
|
)
|
|
94
94
|
expect(block).toContain('**Claude plan quota**')
|
|
95
|
-
expect(block).toContain('**5h window** 29
|
|
96
|
-
expect(block).toContain('**7d window** 33
|
|
95
|
+
expect(block).toContain('**5h window** `29%` · `resets in 2h 30m`')
|
|
96
|
+
expect(block).toContain('**7d window** `33%` · `resets in 3d`')
|
|
97
97
|
expect(block).toContain('Binding window: five hour')
|
|
98
98
|
// overage=allowed should not be surfaced
|
|
99
99
|
expect(block).not.toContain('Overage:')
|
|
100
|
+
// Both windows: the **bold label** stays bold AND the percentage +
|
|
101
|
+
// reset ETA are each wrapped in a `code` span. Locks the nit-fix that
|
|
102
|
+
// the value moved into backticks without dropping the label markup.
|
|
103
|
+
const fiveLine = block.split('\n').find((l) => l.includes('5h window'))!
|
|
104
|
+
const sevenLine = block.split('\n').find((l) => l.includes('7d window'))!
|
|
105
|
+
expect(fiveLine).toMatch(/^\*\*5h window\*\*\s+`\d+%` · `resets in .+?`$/)
|
|
106
|
+
expect(sevenLine).toMatch(/^\*\*7d window\*\*\s+`\d+%` · `resets in .+?`$/)
|
|
100
107
|
})
|
|
101
108
|
|
|
102
109
|
it('surfaces overage status when not allowed', () => {
|
|
@@ -249,11 +249,11 @@ describe("evaluateQuotaWatchAccount — message content", () => {
|
|
|
249
249
|
expect(d.message).toContain("30%");
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
-
it("throttling message
|
|
253
|
-
// The label is interpolated
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
252
|
+
it("throttling message renders the account label literally in a code span (#2695)", () => {
|
|
253
|
+
// The label is interpolated INSIDE a `code span`, where content is literal
|
|
254
|
+
// and backslash escaping is WRONG (#2695 regression: escapeMarkdown here
|
|
255
|
+
// emitted a visible `ev\_il@…`). The correct rendering is via codeSpanSafe,
|
|
256
|
+
// so an underscore label appears verbatim with no stray backslash.
|
|
257
257
|
const d = evaluateQuotaWatchAccount({
|
|
258
258
|
agentName: "lawgpt",
|
|
259
259
|
snap: makeSnap("ev_il@example.com", makeQuota(85, 40)),
|
|
@@ -262,8 +262,8 @@ describe("evaluateQuotaWatchAccount — message content", () => {
|
|
|
262
262
|
});
|
|
263
263
|
expect(d.kind).toBe("notify");
|
|
264
264
|
if (d.kind !== "notify") return;
|
|
265
|
-
|
|
266
|
-
expect(d.message).toContain("ev\\_il@example.com");
|
|
265
|
+
expect(d.message).toContain("`ev_il@example.com`");
|
|
266
|
+
expect(d.message).not.toContain("ev\\_il@example.com");
|
|
267
267
|
});
|
|
268
268
|
|
|
269
269
|
it("throttling message for active account mentions /auth use", () => {
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rich-markdown-oracle — an INDEPENDENT CommonMark/GFM validator + entity
|
|
3
|
+
* extractor for the Bot API 10.1 rich-message path (`sendRichMessage`,
|
|
4
|
+
* `editMessageText({ markdown })`).
|
|
5
|
+
*
|
|
6
|
+
* ── Why this exists ────────────────────────────────────────────────────────
|
|
7
|
+
* Every outbound message now ships as raw GFM markdown through the rich path
|
|
8
|
+
* (see `format.ts` / `rich-send.ts`). A malformed body makes Telegram throw a
|
|
9
|
+
* 400 ("can't parse entities" / "can't find end of the entity" — see
|
|
10
|
+
* `isParseEntitiesError` in `rich-send.ts`), which drops or corrupts the
|
|
11
|
+
* message. Our existing format tests string-compare the text we *emit*; they
|
|
12
|
+
* do NOT verify that the emitted markdown is well-formed enough that Telegram
|
|
13
|
+
* won't reject it, nor that it parses into the entity structure we intended.
|
|
14
|
+
*
|
|
15
|
+
* This oracle closes that gap with TWO signals:
|
|
16
|
+
* (a) PARSE-ACCEPT: `validateRichMarkdown(md)` returns [] when the body is
|
|
17
|
+
* well-formed on every axis whose violation actually produces a rich-path
|
|
18
|
+
* 400 or a corrupt render; otherwise it returns a list of concrete
|
|
19
|
+
* problems. A test asserting `[]` fails loudly on output Telegram would
|
|
20
|
+
* reject.
|
|
21
|
+
* (b) STRUCTURE: `parseRichEntities(md)` extracts the entity spans (bold,
|
|
22
|
+
* italic, code, pre/fence, link, strikethrough) so a test can assert the
|
|
23
|
+
* body parses into the intended shape.
|
|
24
|
+
*
|
|
25
|
+
* ── The non-circularity guarantee (READ THIS) ──────────────────────────────
|
|
26
|
+
* The oracle is a HAND-WRITTEN tokenizer that follows the CommonMark/GFM
|
|
27
|
+
* grammar and Telegram's documented entity-closure rules. It shares NO code
|
|
28
|
+
* with `format.ts` — it does not import, echo, or re-derive the formatter's
|
|
29
|
+
* output. It is a second, independent implementation, so asserting the
|
|
30
|
+
* formatter's output against it is a genuine cross-check, not a formatter
|
|
31
|
+
* comparing itself to itself.
|
|
32
|
+
*
|
|
33
|
+
* ── Scope boundary (documented, deliberate) ────────────────────────────────
|
|
34
|
+
* A byte-exact port of the full CommonMark reference parser is ~3000 lines and
|
|
35
|
+
* not worth hand-rolling reliably. So:
|
|
36
|
+
* - Signal (a) is RIGOROUS for the constructs whose malformedness is what
|
|
37
|
+
* actually 400s / corrupts on the rich path: unbalanced fenced-code
|
|
38
|
+
* delimiters, unterminated inline code spans, malformed / unclosed link
|
|
39
|
+
* syntax, and structurally-incomplete table rows. These are precisely the
|
|
40
|
+
* failure modes `format.ts`'s chunker + block-boundary passes are built to
|
|
41
|
+
* avoid, so they are the ones a regression test must guard.
|
|
42
|
+
* - Signal (b) covers the COMMON entity types (bold `**`/`__`, italic
|
|
43
|
+
* `*`/`_`, strikethrough `~~`, inline code, fenced code, links). It uses a
|
|
44
|
+
* simplified-but-faithful delimiter model. It deliberately does NOT model
|
|
45
|
+
* every CommonMark emphasis edge case (intraword `_`, triple-run
|
|
46
|
+
* `***bold italic***` nesting split points) — those are called out inline
|
|
47
|
+
* and the fixtures avoid depending on them. When in doubt the extractor is
|
|
48
|
+
* CONSERVATIVE: it reports a span only when the open/close is unambiguous.
|
|
49
|
+
*
|
|
50
|
+
* CommonMark itself never "fails to parse" (every string is valid CommonMark),
|
|
51
|
+
* so a naive "does it parse" check would be vacuous. That is exactly why signal
|
|
52
|
+
* (a) is defined as *structural well-formedness of the emitted constructs*
|
|
53
|
+
* rather than "did a CommonMark parser throw" — it is the real predictor of a
|
|
54
|
+
* rich-path 400.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Entity model
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
export type EntityType =
|
|
62
|
+
| 'bold'
|
|
63
|
+
| 'italic'
|
|
64
|
+
| 'strikethrough'
|
|
65
|
+
| 'code' // inline code span
|
|
66
|
+
| 'pre' // fenced code block
|
|
67
|
+
| 'link'
|
|
68
|
+
|
|
69
|
+
export interface RichEntity {
|
|
70
|
+
readonly type: EntityType
|
|
71
|
+
/** The rendered (delimiter-stripped) inner text of the entity. */
|
|
72
|
+
readonly text: string
|
|
73
|
+
/** For a link, the destination URL. */
|
|
74
|
+
readonly url?: string
|
|
75
|
+
/** For a fenced block, the info string (language), if any. */
|
|
76
|
+
readonly lang?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ParseIssue {
|
|
80
|
+
readonly kind:
|
|
81
|
+
| 'unbalanced-fence'
|
|
82
|
+
| 'unterminated-code-span'
|
|
83
|
+
| 'malformed-link'
|
|
84
|
+
| 'incomplete-table-row'
|
|
85
|
+
| 'unbalanced-emphasis'
|
|
86
|
+
readonly detail: string
|
|
87
|
+
/** 1-based line number where the problem was detected, when known. */
|
|
88
|
+
readonly line?: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Fence / code-region masking (independent re-implementation — NOT imported
|
|
93
|
+
// from format.ts, on purpose). We mask fenced blocks and inline code so the
|
|
94
|
+
// emphasis/link scanners never see their interior, matching how a CommonMark
|
|
95
|
+
// parser treats code as verbatim.
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
interface Fence {
|
|
99
|
+
readonly lang: string
|
|
100
|
+
readonly body: string
|
|
101
|
+
readonly startLine: number
|
|
102
|
+
readonly closed: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Split the document into fenced-code regions and everything else, tracking
|
|
107
|
+
* whether each fence is CLOSED. A fence opens on a line whose first non-space
|
|
108
|
+
* run is ``` (or longer) or ~~~; it closes on a later line whose fence marker
|
|
109
|
+
* is the same char and at least as long, with no trailing info string.
|
|
110
|
+
*
|
|
111
|
+
* This is the CommonMark fenced-code rule, scoped to the two markers Telegram
|
|
112
|
+
* emits (``` predominantly). An unclosed fence is the single most common cause
|
|
113
|
+
* of a corrupt rich render (it swallows the rest of the message), so we track
|
|
114
|
+
* `closed` explicitly for signal (a).
|
|
115
|
+
*/
|
|
116
|
+
export function scanFences(md: string): {
|
|
117
|
+
fences: Fence[]
|
|
118
|
+
/** Line indices (0-based) that belong to a fence (open, body, close). */
|
|
119
|
+
fenceLineSet: Set<number>
|
|
120
|
+
} {
|
|
121
|
+
const lines = md.split('\n')
|
|
122
|
+
const fences: Fence[] = []
|
|
123
|
+
const fenceLineSet = new Set<number>()
|
|
124
|
+
let i = 0
|
|
125
|
+
const openRe = /^(\s*)(`{3,}|~{3,})\s*([^\n`]*)$/
|
|
126
|
+
while (i < lines.length) {
|
|
127
|
+
const m = lines[i].match(openRe)
|
|
128
|
+
if (m == null) {
|
|
129
|
+
i++
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
const indent = m[1].length
|
|
133
|
+
const marker = m[2]
|
|
134
|
+
const fenceChar = marker[0]
|
|
135
|
+
const lang = m[3].trim()
|
|
136
|
+
const startLine = i
|
|
137
|
+
fenceLineSet.add(i)
|
|
138
|
+
const bodyLines: string[] = []
|
|
139
|
+
let j = i + 1
|
|
140
|
+
let closed = false
|
|
141
|
+
// Close marker: same char, length >= open marker, only whitespace after.
|
|
142
|
+
const closeRe = new RegExp(`^\\s*${fenceChar === '`' ? '`' : '~'}{${marker.length},}\\s*$`)
|
|
143
|
+
for (; j < lines.length; j++) {
|
|
144
|
+
if (closeRe.test(lines[j])) {
|
|
145
|
+
fenceLineSet.add(j)
|
|
146
|
+
closed = true
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
bodyLines.push(lines[j].slice(indent))
|
|
150
|
+
fenceLineSet.add(j)
|
|
151
|
+
}
|
|
152
|
+
fences.push({ lang, body: bodyLines.join('\n'), startLine, closed })
|
|
153
|
+
i = closed ? j + 1 : j
|
|
154
|
+
}
|
|
155
|
+
return { fences, fenceLineSet }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Replace fenced regions with blank lines (preserving line count) so the
|
|
160
|
+
* inline scanners never see fence interiors. Returns the masked doc plus the
|
|
161
|
+
* fence list.
|
|
162
|
+
*/
|
|
163
|
+
function maskFences(md: string): { masked: string; fences: Fence[]; fenceLineSet: Set<number> } {
|
|
164
|
+
const { fences, fenceLineSet } = scanFences(md)
|
|
165
|
+
const lines = md.split('\n')
|
|
166
|
+
const masked = lines.map((l, idx) => (fenceLineSet.has(idx) ? '' : l)).join('\n')
|
|
167
|
+
return { masked, fences, fenceLineSet }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Extract inline code spans from a single line (fence interiors already
|
|
172
|
+
* masked). CommonMark inline code is a run of N backticks, closed by the next
|
|
173
|
+
* run of exactly N backticks. We support the common single- and double-tick
|
|
174
|
+
* forms our formatter emits. Returns the spans and the line with each span
|
|
175
|
+
* blanked (same length) so downstream emphasis scanning skips code content.
|
|
176
|
+
*/
|
|
177
|
+
function extractInlineCode(line: string): { spans: string[]; blanked: string; unterminated: boolean } {
|
|
178
|
+
const spans: string[] = []
|
|
179
|
+
let out = ''
|
|
180
|
+
let i = 0
|
|
181
|
+
let unterminated = false
|
|
182
|
+
while (i < line.length) {
|
|
183
|
+
if (line[i] === '`') {
|
|
184
|
+
// Measure opening run length.
|
|
185
|
+
let n = 0
|
|
186
|
+
while (line[i + n] === '`') n++
|
|
187
|
+
const openEnd = i + n
|
|
188
|
+
// Find a closing run of EXACTLY n backticks.
|
|
189
|
+
let k = openEnd
|
|
190
|
+
let closeStart = -1
|
|
191
|
+
while (k < line.length) {
|
|
192
|
+
if (line[k] === '`') {
|
|
193
|
+
let m = 0
|
|
194
|
+
while (line[k + m] === '`') m++
|
|
195
|
+
if (m === n) {
|
|
196
|
+
closeStart = k
|
|
197
|
+
break
|
|
198
|
+
}
|
|
199
|
+
k += m
|
|
200
|
+
} else {
|
|
201
|
+
k++
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (closeStart === -1) {
|
|
205
|
+
// No matching close on this line → unterminated inline code span.
|
|
206
|
+
unterminated = true
|
|
207
|
+
out += line.slice(i)
|
|
208
|
+
break
|
|
209
|
+
}
|
|
210
|
+
const inner = line.slice(openEnd, closeStart)
|
|
211
|
+
spans.push(inner)
|
|
212
|
+
// Blank out the whole span (ticks + inner) with spaces of equal length.
|
|
213
|
+
out += ' '.repeat(closeStart + n - i)
|
|
214
|
+
i = closeStart + n
|
|
215
|
+
} else {
|
|
216
|
+
out += line[i]
|
|
217
|
+
i++
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { spans, blanked: out, unterminated }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Signal (a): PARSE-ACCEPT validation
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Validate that `md` is well-formed enough that Telegram's rich-message path
|
|
229
|
+
* would NOT 400 on it. Returns an empty array on success, else a list of the
|
|
230
|
+
* concrete problems found. See the module doc for the exact scope of "well
|
|
231
|
+
* formed" and why it is the right predictor of a rich-path reject.
|
|
232
|
+
*/
|
|
233
|
+
export function validateRichMarkdown(md: string): ParseIssue[] {
|
|
234
|
+
const issues: ParseIssue[] = []
|
|
235
|
+
const { masked, fences } = maskFences(md)
|
|
236
|
+
|
|
237
|
+
// --- Unbalanced fence: an unclosed fenced block corrupts the render. -----
|
|
238
|
+
for (const f of fences) {
|
|
239
|
+
if (!f.closed) {
|
|
240
|
+
issues.push({
|
|
241
|
+
kind: 'unbalanced-fence',
|
|
242
|
+
detail: `fenced code block opened at line ${f.startLine + 1} is never closed`,
|
|
243
|
+
line: f.startLine + 1,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Defensive: an ODD number of lone ``` fence lines (that scanFences somehow
|
|
248
|
+
// did not pair) is also an imbalance. scanFences already pairs greedily, so
|
|
249
|
+
// this catches only truly stray markers.
|
|
250
|
+
const strayFenceLines = (md.match(/^\s*(`{3,}|~{3,})\s*$/gm) ?? []).length
|
|
251
|
+
const openWithInfo = (md.match(/^\s*(`{3,}|~{3,})[^\n`~]+$/gm) ?? []).length
|
|
252
|
+
if ((strayFenceLines + openWithInfo) % 2 !== 0 && fences.every((f) => f.closed)) {
|
|
253
|
+
issues.push({
|
|
254
|
+
kind: 'unbalanced-fence',
|
|
255
|
+
detail: `odd number of fence delimiter lines (${strayFenceLines + openWithInfo})`,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const maskedLines = masked.split('\n')
|
|
260
|
+
maskedLines.forEach((rawLine, idx) => {
|
|
261
|
+
const { blanked, unterminated } = extractInlineCode(rawLine)
|
|
262
|
+
if (unterminated) {
|
|
263
|
+
issues.push({
|
|
264
|
+
kind: 'unterminated-code-span',
|
|
265
|
+
detail: `inline code span opened but not closed on line ${idx + 1}`,
|
|
266
|
+
line: idx + 1,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
// --- Malformed link: a `[label](` that never closes its `)` ------------
|
|
270
|
+
// Only flag an OPENED inline-link that fails to close — a lone `[` used as
|
|
271
|
+
// literal text (no following `(`) is valid CommonMark and renders fine.
|
|
272
|
+
checkLinks(blanked, idx + 1, issues)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// --- Incomplete table row: a GFM table row must be a complete `| … |`. ---
|
|
276
|
+
checkTableRows(md, issues)
|
|
277
|
+
|
|
278
|
+
// --- Emphasis balance (common-case): odd count of unescaped `**` runs etc.
|
|
279
|
+
checkEmphasisBalance(masked, issues)
|
|
280
|
+
|
|
281
|
+
return issues
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Flag an inline-link open `[...](` whose `(...)` destination never closes on
|
|
286
|
+
* the same line. A closed `[a](b)` is fine; a bare `[a]` with no `(` is fine
|
|
287
|
+
* (literal text). Nested `()` inside the destination are balanced-counted.
|
|
288
|
+
*/
|
|
289
|
+
function checkLinks(line: string, lineNo: number, issues: ParseIssue[]): void {
|
|
290
|
+
const re = /\[[^\]]*\]\(/g
|
|
291
|
+
let m: RegExpExecArray | null
|
|
292
|
+
while ((m = re.exec(line)) != null) {
|
|
293
|
+
// Start scanning the destination just after `](`.
|
|
294
|
+
let depth = 1
|
|
295
|
+
let k = m.index + m[0].length
|
|
296
|
+
let closed = false
|
|
297
|
+
for (; k < line.length; k++) {
|
|
298
|
+
if (line[k] === '(') depth++
|
|
299
|
+
else if (line[k] === ')') {
|
|
300
|
+
depth--
|
|
301
|
+
if (depth === 0) {
|
|
302
|
+
closed = true
|
|
303
|
+
break
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (!closed) {
|
|
308
|
+
issues.push({
|
|
309
|
+
kind: 'malformed-link',
|
|
310
|
+
detail: `inline link opened at line ${lineNo} col ${m.index + 1} has an unclosed destination`,
|
|
311
|
+
line: lineNo,
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A GFM table is a header row, a delimiter row (`| --- | --- |`), then body
|
|
319
|
+
* rows. Every row that participates must be a complete pipe row. We flag a
|
|
320
|
+
* delimiter row that is NOT flanked by a complete header above and at least a
|
|
321
|
+
* well-formed body/structure — i.e. the specific corruption `format.ts`'s
|
|
322
|
+
* chunker guards against (a bisected half-row). A row is "complete" when, after
|
|
323
|
+
* trimming, it starts and ends with `|` OR is a headerless GFM row with a
|
|
324
|
+
* matching delimiter. We keep this conservative: only flag a row that opens
|
|
325
|
+
* with `|` but does not close with `|` (a clearly bisected row).
|
|
326
|
+
*/
|
|
327
|
+
function checkTableRows(md: string, issues: ParseIssue[]): void {
|
|
328
|
+
const { fenceLineSet } = maskFences(md)
|
|
329
|
+
const lines = md.split('\n')
|
|
330
|
+
lines.forEach((line, idx) => {
|
|
331
|
+
if (fenceLineSet.has(idx)) return
|
|
332
|
+
const t = line.trim()
|
|
333
|
+
if (t.length === 0) return
|
|
334
|
+
const looksLikeRow = t.startsWith('|')
|
|
335
|
+
if (!looksLikeRow) return
|
|
336
|
+
// A leading-pipe row must also close with a pipe to be a complete row.
|
|
337
|
+
if (!t.endsWith('|')) {
|
|
338
|
+
issues.push({
|
|
339
|
+
kind: 'incomplete-table-row',
|
|
340
|
+
detail: `table row opens with '|' but does not close with '|' on line ${idx + 1}`,
|
|
341
|
+
line: idx + 1,
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Common-case emphasis balance. Counts unescaped `**` and `__` (bold) runs and
|
|
349
|
+
* standalone `*`/`_` (italic) and `~~` (strikethrough) on the fence+code-masked
|
|
350
|
+
* text; an odd count means an unterminated emphasis entity — the thing that
|
|
351
|
+
* yields "can't find end of the entity" on the rich path.
|
|
352
|
+
*
|
|
353
|
+
* NOTE (scope): this is a COUNT check, not a full delimiter-run resolver. It
|
|
354
|
+
* intentionally does not model CommonMark's left/right-flanking rules, so it
|
|
355
|
+
* can miss an exotic imbalance or (rarely) over-flag a legitimately
|
|
356
|
+
* asymmetric-but-valid construct. The torture-set fixtures avoid such exotic
|
|
357
|
+
* cases so this check stays a reliable regression signal. Escaped markers
|
|
358
|
+
* (`\*`) and code (already masked) are excluded.
|
|
359
|
+
*/
|
|
360
|
+
function checkEmphasisBalance(masked: string, issues: ParseIssue[]): void {
|
|
361
|
+
// Strip inline code per-line first (reuse extractInlineCode's blanking).
|
|
362
|
+
const codeStripped = masked
|
|
363
|
+
.split('\n')
|
|
364
|
+
.map((l) => extractInlineCode(l).blanked)
|
|
365
|
+
.join('\n')
|
|
366
|
+
// Remove escaped markers so `\*` doesn't count.
|
|
367
|
+
const noEsc = codeStripped.replace(/\\[*_~`\\]/g, '')
|
|
368
|
+
|
|
369
|
+
// Bold `**` and `__`: count runs.
|
|
370
|
+
const doubleStar = (noEsc.match(/\*\*/g) ?? []).length
|
|
371
|
+
if (doubleStar % 2 !== 0) {
|
|
372
|
+
issues.push({ kind: 'unbalanced-emphasis', detail: `odd number of '**' bold delimiters (${doubleStar})` })
|
|
373
|
+
}
|
|
374
|
+
const strike = (noEsc.match(/~~/g) ?? []).length
|
|
375
|
+
if (strike % 2 !== 0) {
|
|
376
|
+
issues.push({ kind: 'unbalanced-emphasis', detail: `odd number of '~~' strikethrough delimiters (${strike})` })
|
|
377
|
+
}
|
|
378
|
+
// Single `*` italic: count SINGLE stars that are not part of a `**` pair.
|
|
379
|
+
// Remove `**` pairs first, then count leftover lone `*`.
|
|
380
|
+
const noBold = noEsc.replace(/\*\*/g, '')
|
|
381
|
+
const loneStar = (noBold.match(/\*/g) ?? []).length
|
|
382
|
+
if (loneStar % 2 !== 0) {
|
|
383
|
+
issues.push({ kind: 'unbalanced-emphasis', detail: `odd number of lone '*' italic delimiters (${loneStar})` })
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Signal (b): STRUCTURE extraction
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Parse the common entity structure out of a rich-markdown body. Fenced blocks
|
|
393
|
+
* are extracted first (as `pre`), then inline code, links, bold, italic and
|
|
394
|
+
* strikethrough from the remaining text. See the module doc for the scope of
|
|
395
|
+
* "common entities". Order of returned entities is document order per type is
|
|
396
|
+
* NOT guaranteed across types; assert on membership, not absolute index, in
|
|
397
|
+
* tests (helpers below make that easy).
|
|
398
|
+
*/
|
|
399
|
+
export function parseRichEntities(md: string): RichEntity[] {
|
|
400
|
+
const entities: RichEntity[] = []
|
|
401
|
+
const { masked, fences } = maskFences(md)
|
|
402
|
+
|
|
403
|
+
for (const f of fences) {
|
|
404
|
+
entities.push({ type: 'pre', text: f.body, lang: f.lang.length > 0 ? f.lang : undefined })
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Inline code + links + emphasis on the masked (fence-free) text, line-wise.
|
|
408
|
+
for (const rawLine of masked.split('\n')) {
|
|
409
|
+
const { spans, blanked } = extractInlineCode(rawLine)
|
|
410
|
+
for (const s of spans) entities.push({ type: 'code', text: s })
|
|
411
|
+
|
|
412
|
+
// Links: [label](url). Extract before emphasis so a bracketed label with
|
|
413
|
+
// emphasis inside is handled as a link (label text preserved verbatim).
|
|
414
|
+
let line = blanked
|
|
415
|
+
const linkRe = /\[([^\]]*)\]\(([^)]*)\)/g
|
|
416
|
+
let lm: RegExpExecArray | null
|
|
417
|
+
const linkBlank: string[] = []
|
|
418
|
+
while ((lm = linkRe.exec(line)) != null) {
|
|
419
|
+
entities.push({ type: 'link', text: lm[1], url: lm[2] })
|
|
420
|
+
linkBlank.push(lm[0])
|
|
421
|
+
}
|
|
422
|
+
for (const lb of linkBlank) line = line.replace(lb, ' '.repeat(lb.length))
|
|
423
|
+
|
|
424
|
+
// Strikethrough ~~...~~
|
|
425
|
+
line = collect(line, /~~([^~]+)~~/g, (mtext) => entities.push({ type: 'strikethrough', text: mtext }))
|
|
426
|
+
// Bold **...** (and __...__)
|
|
427
|
+
line = collect(line, /\*\*([^*]+)\*\*/g, (mtext) => entities.push({ type: 'bold', text: mtext }))
|
|
428
|
+
line = collect(line, /__([^_]+)__/g, (mtext) => entities.push({ type: 'bold', text: mtext }))
|
|
429
|
+
// Italic *...* / _..._ (bold already removed above)
|
|
430
|
+
line = collect(line, /\*([^*]+)\*/g, (mtext) => entities.push({ type: 'italic', text: mtext }))
|
|
431
|
+
line = collect(line, /(?<![A-Za-z0-9])_([^_]+)_(?![A-Za-z0-9])/g, (mtext) =>
|
|
432
|
+
entities.push({ type: 'italic', text: mtext }),
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return entities
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Apply `re` globally, invoke `cb` with capture group 1, blank the match. */
|
|
440
|
+
function collect(line: string, re: RegExp, cb: (inner: string) => void): string {
|
|
441
|
+
let out = line
|
|
442
|
+
let m: RegExpExecArray | null
|
|
443
|
+
const toBlank: string[] = []
|
|
444
|
+
while ((m = re.exec(line)) != null) {
|
|
445
|
+
cb(m[1])
|
|
446
|
+
toBlank.push(m[0])
|
|
447
|
+
}
|
|
448
|
+
for (const b of toBlank) out = out.replace(b, ' '.repeat(b.length))
|
|
449
|
+
return out
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// Test-facing convenience helpers
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
/** True when the body is parse-accept clean (signal a). */
|
|
457
|
+
export function isRichMarkdownValid(md: string): boolean {
|
|
458
|
+
return validateRichMarkdown(md).length === 0
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** All entities of a given type, in document order. */
|
|
462
|
+
export function entitiesOfType(md: string, type: EntityType): RichEntity[] {
|
|
463
|
+
return parseRichEntities(md).filter((e) => e.type === type)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** The set of rendered inner texts for a given entity type. */
|
|
467
|
+
export function entityTexts(md: string, type: EntityType): string[] {
|
|
468
|
+
return entitiesOfType(md, type).map((e) => e.text)
|
|
469
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2726 — structural wiring guard for the rollout status IPC verbs. The
|
|
3
|
+
* gateway handlers live in the createIpcServer({...}) IIFE block, which can't
|
|
4
|
+
* be imported in a unit test (same constraint as the send_outbound /
|
|
5
|
+
* inject_inbound wiring tests), so the load-bearing invariants are pinned by
|
|
6
|
+
* reading the source:
|
|
7
|
+
* 1. ipc-server routes rollout_status_post → onRolloutStatusPost and
|
|
8
|
+
* rollout_status_edit → onRolloutStatusEdit, with validator arms.
|
|
9
|
+
* 2. the POST handler fences agent (agentName === self), posts to the
|
|
10
|
+
* OPERATOR chat (allowFrom[0]) as an ORDINARY message (no pin, no
|
|
11
|
+
* inline_keyboard card), and replies with the message_id.
|
|
12
|
+
* 3. the EDIT handler edits in place via the wrapped retry path
|
|
13
|
+
* (swallowingApiCall) — fire-and-forget, never a raw bot.api call.
|
|
14
|
+
* 4. NEITHER handler pins a message — this is in-chat narration, not the
|
|
15
|
+
* retired pinned card (chat-is-the-single-source-of-truth).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from "vitest";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
|
|
20
|
+
const gw = readFileSync(new URL("../gateway/gateway.ts", import.meta.url), "utf8");
|
|
21
|
+
const ipcServer = readFileSync(new URL("../gateway/ipc-server.ts", import.meta.url), "utf8");
|
|
22
|
+
|
|
23
|
+
describe("rollout_status_* — ipc-server routing", () => {
|
|
24
|
+
it("validates + dispatches rollout_status_post", () => {
|
|
25
|
+
expect(ipcServer).toMatch(/case "rollout_status_post": \{/); // validator arm
|
|
26
|
+
expect(ipcServer).toMatch(/onRolloutStatusPost\(client, msg as RolloutStatusPostMessage\)/);
|
|
27
|
+
});
|
|
28
|
+
it("validates + dispatches rollout_status_edit", () => {
|
|
29
|
+
expect(ipcServer).toMatch(/case "rollout_status_edit": \{/);
|
|
30
|
+
expect(ipcServer).toMatch(/onRolloutStatusEdit\(client, msg as RolloutStatusEditMessage\)/);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("onRolloutStatusPost — gateway handler invariants", () => {
|
|
35
|
+
const start = gw.indexOf("async onRolloutStatusPost(client: IpcClient, msg: RolloutStatusPostMessage)");
|
|
36
|
+
const next = gw.indexOf("onRolloutStatusEdit(", start);
|
|
37
|
+
const handler = gw.slice(start, next > start ? next : start + 2600);
|
|
38
|
+
|
|
39
|
+
it("the handler exists", () => {
|
|
40
|
+
expect(start).toBeGreaterThan(0);
|
|
41
|
+
});
|
|
42
|
+
it("fences the agent (agentName must match this gateway's own)", () => {
|
|
43
|
+
expect(handler).toMatch(/msg\.agentName !== self/);
|
|
44
|
+
});
|
|
45
|
+
it("posts to the OPERATOR chat (allowFrom[0])", () => {
|
|
46
|
+
expect(handler).toMatch(/loadAccess\(\)\.allowFrom\[0\]/);
|
|
47
|
+
});
|
|
48
|
+
it("posts through the wrapped retry path, NOT a raw bot.api", () => {
|
|
49
|
+
expect(handler).toMatch(/robustApiCall\(/);
|
|
50
|
+
expect(handler).toMatch(/bot\.api\.sendRichMessage\(operator/);
|
|
51
|
+
});
|
|
52
|
+
it("replies with the message_id so hostd can EDIT it later", () => {
|
|
53
|
+
expect(handler).toMatch(/rollout_status_posted/);
|
|
54
|
+
expect(handler).toMatch(/messageId/);
|
|
55
|
+
});
|
|
56
|
+
it("does NOT pin the message and renders NO inline-keyboard card (narration, not the retired card)", () => {
|
|
57
|
+
expect(handler).not.toMatch(/pinChatMessage/);
|
|
58
|
+
expect(handler).not.toMatch(/reply_markup/);
|
|
59
|
+
expect(handler).not.toMatch(/InlineKeyboard/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("onRolloutStatusEdit — gateway handler invariants", () => {
|
|
64
|
+
const start = gw.indexOf("onRolloutStatusEdit(_client: IpcClient, msg: RolloutStatusEditMessage)");
|
|
65
|
+
const next = gw.indexOf("onInjectInbound(", start);
|
|
66
|
+
const handler = gw.slice(start, next > start ? next : start + 2000);
|
|
67
|
+
|
|
68
|
+
it("the handler exists", () => {
|
|
69
|
+
expect(start).toBeGreaterThan(0);
|
|
70
|
+
});
|
|
71
|
+
it("fences the agent", () => {
|
|
72
|
+
expect(handler).toMatch(/msg\.agentName !== self/);
|
|
73
|
+
});
|
|
74
|
+
it("edits in place via the wrapped retry path (fire-and-forget)", () => {
|
|
75
|
+
expect(handler).toMatch(/swallowingApiCall\(/);
|
|
76
|
+
expect(handler).toMatch(/bot\.api\.editMessageText\(operator, msg\.messageId/);
|
|
77
|
+
});
|
|
78
|
+
it("does NOT pin (edits an ordinary message)", () => {
|
|
79
|
+
expect(handler).not.toMatch(/pinChatMessage/);
|
|
80
|
+
});
|
|
81
|
+
});
|