switchroom 0.16.38 → 0.16.47
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 +8 -2
- package/dist/auth-broker/index.js +7 -1
- package/dist/cli/notion-write-pretool.mjs +7 -1
- package/dist/cli/switchroom.js +1259 -375
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +116 -84
- package/dist/vault/approvals/kernel-server.js +8 -2
- package/dist/vault/broker/server.js +8 -2
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +2 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +20 -2
- package/telegram-plugin/dist/gateway/gateway.js +2197 -964
- package/telegram-plugin/dist/server.js +20 -2
- package/telegram-plugin/format.ts +305 -31
- package/telegram-plugin/gateway/gateway.ts +310 -70
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/stream-reply-handler.ts +19 -1
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/card-format.test.ts +28 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -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/model-command.test.ts +213 -47
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/telegram-format.test.ts +33 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +20 -108
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +472 -22
- 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-ondemand.ts +161 -10
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/worker-activity-feed.ts +9 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
projectTranscriptLine,
|
|
4
|
+
projectSubagentLine,
|
|
5
|
+
assistantLineCarriesAnswerSurface,
|
|
6
|
+
type SessionEvent,
|
|
7
|
+
} from '../session-tail.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* CONTRACT / CANARY: the Claude Code transcript event shapes the liveness
|
|
11
|
+
* surface depends on.
|
|
12
|
+
*
|
|
13
|
+
* switchroom has NO stream-json path — the status/liveness surface is derived
|
|
14
|
+
* entirely by scraping Claude Code's on-disk per-session JSONL transcript
|
|
15
|
+
* (see session-tail.ts header). Those shapes are UNDOCUMENTED and
|
|
16
|
+
* version-fragile: a 2.1.x release changed a single logical assistant message
|
|
17
|
+
* from "one JSONL line carrying all content blocks in source order" to
|
|
18
|
+
* "multiple JSONL lines sharing one message.id, one content-block per line,
|
|
19
|
+
* with the terminal stop_reason stamped on EVERY split line". That silently
|
|
20
|
+
* dropped background sub-agent handbacks (the terminal fired on the leading
|
|
21
|
+
* thinking split line before the answer text line was projected).
|
|
22
|
+
*
|
|
23
|
+
* This file PINS every wire shape the projector consumes, using fixtures
|
|
24
|
+
* captured verbatim from a live 2.1.199 transcript. If an upstream release
|
|
25
|
+
* changes the shape such that the projector stops emitting the expected
|
|
26
|
+
* SessionEvents, THIS TEST FAILS LOUDLY at CI — instead of the card silently
|
|
27
|
+
* blanking in production. When it fails: re-capture the fixtures from a real
|
|
28
|
+
* transcript, confirm the projector still emits the same SessionEvents, and
|
|
29
|
+
* update the fixtures + this contract deliberately.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const kinds = (evs: SessionEvent[]): string[] => evs.map((e) => e.kind)
|
|
33
|
+
|
|
34
|
+
describe('Claude Code event-stream contract (canary)', () => {
|
|
35
|
+
it('queue-operation enqueue carries the channel meta the gateway keys currentTurn on', () => {
|
|
36
|
+
// enqueue is the ONLY event that carries chatId — losing it kills the
|
|
37
|
+
// whole per-turn liveness surface (progress card, draft-mirror, floor).
|
|
38
|
+
const line = JSON.stringify({
|
|
39
|
+
type: 'queue-operation',
|
|
40
|
+
operation: 'enqueue',
|
|
41
|
+
content:
|
|
42
|
+
'<channel source="telegram" chat_id="12345" message_id="678" message_thread_id="9">hi</channel>',
|
|
43
|
+
})
|
|
44
|
+
expect(projectTranscriptLine(line)).toEqual([
|
|
45
|
+
{
|
|
46
|
+
kind: 'enqueue',
|
|
47
|
+
chatId: '12345',
|
|
48
|
+
messageId: '678',
|
|
49
|
+
threadId: '9',
|
|
50
|
+
rawContent:
|
|
51
|
+
'<channel source="telegram" chat_id="12345" message_id="678" message_thread_id="9">hi</channel>',
|
|
52
|
+
},
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('assistant thinking block projects to a thinking event (phase-distinct signal)', () => {
|
|
57
|
+
// 2.1.x thinking block shape: { type, thinking, signature }.
|
|
58
|
+
const line = JSON.stringify({
|
|
59
|
+
type: 'assistant',
|
|
60
|
+
message: {
|
|
61
|
+
id: 'msg_a',
|
|
62
|
+
stop_reason: 'tool_use',
|
|
63
|
+
stop_details: { type: 'tool_use' }, // new field in 2.1.x — must be ignored gracefully
|
|
64
|
+
content: [{ type: 'thinking', thinking: 'let me look', signature: 'sig' }],
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
expect(kinds(projectTranscriptLine(line))).toEqual(['thinking'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('assistant tool_use block still carries {id, name, input}', () => {
|
|
71
|
+
const line = JSON.stringify({
|
|
72
|
+
type: 'assistant',
|
|
73
|
+
message: {
|
|
74
|
+
id: 'msg_b',
|
|
75
|
+
stop_reason: 'tool_use',
|
|
76
|
+
content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }],
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
expect(projectTranscriptLine(line)).toEqual([
|
|
80
|
+
{ kind: 'tool_use', toolName: 'Bash', toolUseId: 'toolu_1', input: { command: 'ls' } },
|
|
81
|
+
])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('user tool_result block still carries tool_use_id + is_error', () => {
|
|
85
|
+
const line = JSON.stringify({
|
|
86
|
+
type: 'user',
|
|
87
|
+
message: {
|
|
88
|
+
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', is_error: true, content: 'boom' }],
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
const evs = projectTranscriptLine(line)
|
|
92
|
+
expect(evs).toHaveLength(1)
|
|
93
|
+
expect(evs[0]).toMatchObject({ kind: 'tool_result', toolUseId: 'toolu_1', isError: true })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('system/turn_duration remains the main-agent terminal', () => {
|
|
97
|
+
const line = JSON.stringify({ type: 'system', subtype: 'turn_duration', durationMs: 4200 })
|
|
98
|
+
expect(projectTranscriptLine(line)).toEqual([{ kind: 'turn_end', durationMs: 4200 }])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('SPLIT assistant message: each content-block is its own line sharing message.id', () => {
|
|
102
|
+
// The 2.1.x split shape, captured verbatim. text and tool_use are NEVER
|
|
103
|
+
// co-located anymore; each block is a separate line. The projector must
|
|
104
|
+
// still emit each block's event exactly once, in order, across lines.
|
|
105
|
+
const thinking = JSON.stringify({
|
|
106
|
+
type: 'assistant',
|
|
107
|
+
message: { id: 'msg_split', stop_reason: 'tool_use', content: [{ type: 'thinking', thinking: 't', signature: 's' }] },
|
|
108
|
+
})
|
|
109
|
+
const tool = JSON.stringify({
|
|
110
|
+
type: 'assistant',
|
|
111
|
+
message: { id: 'msg_split', stop_reason: 'tool_use', content: [{ type: 'tool_use', id: 'toolu_9', name: 'Read', input: { file_path: '/x' } }] },
|
|
112
|
+
})
|
|
113
|
+
const all = [...projectTranscriptLine(thinking), ...projectTranscriptLine(tool)]
|
|
114
|
+
expect(kinds(all)).toEqual(['thinking', 'tool_use'])
|
|
115
|
+
// No duplicate emission of the tool_use across the split lines.
|
|
116
|
+
expect(all.filter((e) => e.kind === 'tool_use')).toHaveLength(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('CANARY: end_turn stamped on the thinking-only split line must NOT be a terminal', () => {
|
|
120
|
+
// The exact shape that dropped sub-agent handbacks: line 1 of a split
|
|
121
|
+
// terminal message is thinking-only but carries stop_reason end_turn.
|
|
122
|
+
expect(
|
|
123
|
+
assistantLineCarriesAnswerSurface([{ type: 'thinking', thinking: 't', signature: 's' }]),
|
|
124
|
+
).toBe(false)
|
|
125
|
+
// A content-bearing line (the real final answer) DOES carry the surface.
|
|
126
|
+
expect(assistantLineCarriesAnswerSurface([{ type: 'text', text: 'final answer' }])).toBe(true)
|
|
127
|
+
// Empty/whitespace text is not an answer surface.
|
|
128
|
+
expect(assistantLineCarriesAnswerSurface([{ type: 'text', text: ' ' }])).toBe(false)
|
|
129
|
+
|
|
130
|
+
const st = { hasEmittedStart: true }
|
|
131
|
+
const thinkingEndTurn = JSON.stringify({
|
|
132
|
+
type: 'assistant',
|
|
133
|
+
message: { id: 'msg_t', stop_reason: 'end_turn', content: [{ type: 'thinking', thinking: 't', signature: 's' }] },
|
|
134
|
+
})
|
|
135
|
+
expect(
|
|
136
|
+
projectSubagentLine(thinkingEndTurn, 'agentX', st).some((e) => e.kind === 'sub_agent_turn_end'),
|
|
137
|
+
).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('sub-agent kickoff: first user message string prompt fires sub_agent_started', () => {
|
|
141
|
+
const st = { hasEmittedStart: false }
|
|
142
|
+
const line = JSON.stringify({
|
|
143
|
+
type: 'user',
|
|
144
|
+
isSidechain: true,
|
|
145
|
+
message: { role: 'user', content: 'Review PR #123 and report back.' },
|
|
146
|
+
})
|
|
147
|
+
expect(projectSubagentLine(line, 'agentX', st)).toEqual([
|
|
148
|
+
{ kind: 'sub_agent_started', agentId: 'agentX', firstPromptText: 'Review PR #123 and report back.' },
|
|
149
|
+
])
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the fleet-wide consistent-formatting bundle:
|
|
3
|
+
*
|
|
4
|
+
* 1. addParagraphSpacers extension — a visible U+00A0 spacer line at EVERY
|
|
5
|
+
* block transition (paragraph→list, list→paragraph, heading→anything,
|
|
6
|
+
* blockquote/table boundaries), never inside a list/table interior.
|
|
7
|
+
* 2. normalizePunctuation — em/en dashes → comma/hyphen, leading `•`/`·`
|
|
8
|
+
* list markers → `- `, on code-masked text, idempotent.
|
|
9
|
+
* 3. stripExcessBold — over-bold tripwire: >30% bold or fully-bolded
|
|
10
|
+
* paragraphs/lists lose their bold markers; short messages exempt.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect } from 'vitest'
|
|
13
|
+
import {
|
|
14
|
+
addParagraphSpacers,
|
|
15
|
+
normalizeParagraphBreaks,
|
|
16
|
+
normalizePunctuation,
|
|
17
|
+
stripExcessBold,
|
|
18
|
+
splitMarkdownChunks,
|
|
19
|
+
PARAGRAPH_SPACER,
|
|
20
|
+
} from '../format.js'
|
|
21
|
+
|
|
22
|
+
const SP = PARAGRAPH_SPACER // U+00A0
|
|
23
|
+
|
|
24
|
+
describe('addParagraphSpacers — uniform block spacing', () => {
|
|
25
|
+
test('still spaces prose→prose (existing behaviour)', () => {
|
|
26
|
+
const out = addParagraphSpacers('Alpha.\n\nBravo.')
|
|
27
|
+
expect(out).toBe(`Alpha.\n\n${SP}\n\nBravo.`)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('spaces paragraph→list transition', () => {
|
|
31
|
+
const out = addParagraphSpacers('Intro.\n\n- one\n- two')
|
|
32
|
+
expect(out).toBe(`Intro.\n\n${SP}\n\n- one\n- two`)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('spaces list→paragraph transition', () => {
|
|
36
|
+
const out = addParagraphSpacers('- one\n- two\n\nOutro.')
|
|
37
|
+
expect(out).toBe(`- one\n- two\n\n${SP}\n\nOutro.`)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('spaces heading→anything', () => {
|
|
41
|
+
expect(addParagraphSpacers('# Title\n\nBody.')).toBe(`# Title\n\n${SP}\n\nBody.`)
|
|
42
|
+
expect(addParagraphSpacers('# Title\n\n- a\n- b')).toBe(`# Title\n\n${SP}\n\n- a\n- b`)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('spaces blockquote and table boundaries', () => {
|
|
46
|
+
expect(addParagraphSpacers('> quoted\n\nProse.')).toBe(`> quoted\n\n${SP}\n\nProse.`)
|
|
47
|
+
const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
|
|
48
|
+
expect(addParagraphSpacers(`Prose.\n\n${table}`)).toBe(`Prose.\n\n${SP}\n\n${table}`)
|
|
49
|
+
expect(addParagraphSpacers(`${table}\n\nProse.`)).toBe(`${table}\n\n${SP}\n\nProse.`)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('does NOT space between items of the same loose list', () => {
|
|
53
|
+
const input = '- one\n\n- two\n\n- three'
|
|
54
|
+
expect(addParagraphSpacers(input)).toBe(input)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('does NOT space inside a tight list or table interior (single \\n)', () => {
|
|
58
|
+
const list = 'Intro.\n\n- a\n- b\n- c'
|
|
59
|
+
expect(addParagraphSpacers(list)).toBe(`Intro.\n\n${SP}\n\n- a\n- b\n- c`)
|
|
60
|
+
const table = '| a |\n| --- |\n| 1 |\n| 2 |'
|
|
61
|
+
expect(addParagraphSpacers(table)).toBe(table)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('idempotent across every transition kind', () => {
|
|
65
|
+
const input = '# H\n\nProse one.\n\n- a\n- b\n\nProse two.\n\n> quote'
|
|
66
|
+
const once = addParagraphSpacers(input)
|
|
67
|
+
expect(addParagraphSpacers(once)).toBe(once)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('never touches code fences', () => {
|
|
71
|
+
const input = '```\nA\n\nB\n```\n\n```\nC\n```'
|
|
72
|
+
expect(addParagraphSpacers(input)).toBe(input)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('full pipeline: mixed prose+list+heading message gets uniform gaps', () => {
|
|
76
|
+
const raw = 'Summary line.\n- item one\n- item two\nClosing prose.'
|
|
77
|
+
const out = addParagraphSpacers(normalizeParagraphBreaks(raw))
|
|
78
|
+
// Every block transition carries exactly one visible spacer line.
|
|
79
|
+
expect(out).toBe(
|
|
80
|
+
`Summary line.\n\n${SP}\n\n- item one\n- item two\n\n${SP}\n\nClosing prose.`,
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('chunk-boundary interaction: a cut in a spacer gap strips the spacer', () => {
|
|
85
|
+
const a = 'A'.repeat(60)
|
|
86
|
+
const b = 'B'.repeat(60)
|
|
87
|
+
const text = `${a}\n\n${SP}\n\n${b}`
|
|
88
|
+
const chunks = splitMarkdownChunks(text, 80)
|
|
89
|
+
expect(chunks.length).toBe(2)
|
|
90
|
+
expect(chunks[0]).toBe(a)
|
|
91
|
+
expect(chunks[1]).toBe(b)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('normalizePunctuation', () => {
|
|
96
|
+
test('spaced em-dash → comma', () => {
|
|
97
|
+
expect(normalizePunctuation('voice came back — three PRs stacked')).toBe(
|
|
98
|
+
'voice came back, three PRs stacked',
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('spaced en-dash → comma', () => {
|
|
103
|
+
expect(normalizePunctuation('one thing – another thing')).toBe('one thing, another thing')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('bare em-dash between words → comma', () => {
|
|
107
|
+
expect(normalizePunctuation('word—word')).toBe('word, word')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('bare en-dash between words → hyphen (ranges survive as ASCII)', () => {
|
|
111
|
+
expect(normalizePunctuation('2019–2024')).toBe('2019-2024')
|
|
112
|
+
expect(normalizePunctuation('pre–war')).toBe('pre-war')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('digit-flanked spaced dash → hyphen range, not comma', () => {
|
|
116
|
+
expect(normalizePunctuation('3 – 5 days')).toBe('3-5 days')
|
|
117
|
+
expect(normalizePunctuation('10 — 20')).toBe('10-20')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('leading • and · bullets → `- ` (indent preserved)', () => {
|
|
121
|
+
expect(normalizePunctuation('• one\n• two')).toBe('- one\n- two')
|
|
122
|
+
expect(normalizePunctuation(' · indented')).toBe(' - indented')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('mid-line bullet glyph untouched', () => {
|
|
126
|
+
expect(normalizePunctuation('rated 4.5 • 120 reviews')).toBe('rated 4.5 • 120 reviews')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('never touches code spans or fences', () => {
|
|
130
|
+
const input = 'run `a — b` now\n\n```\nx — y\n• bullet\n```'
|
|
131
|
+
expect(normalizePunctuation(input)).toBe(input)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('idempotent', () => {
|
|
135
|
+
const once = normalizePunctuation('a — b\n• c\nd—e\n1–2')
|
|
136
|
+
expect(normalizePunctuation(once)).toBe(once)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('consecutive spaced dashes all normalize in ONE pass (#2755 finding 2)', () => {
|
|
140
|
+
expect(normalizePunctuation('a — b — c')).toBe('a, b, c')
|
|
141
|
+
expect(normalizePunctuation('one — two — three — four')).toBe('one, two, three, four')
|
|
142
|
+
expect(normalizePunctuation('x—y—z')).toBe('x, y, z')
|
|
143
|
+
expect(normalizePunctuation('1–2–3')).toBe('1-2-3')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('cross-path ordering: normalizePunctuation before scrubVoice yields the comma treatment', async () => {
|
|
147
|
+
// The stream path runs normalizePunctuation BEFORE scrubVoice, same as
|
|
148
|
+
// reply/edit — so a spaced em-dash gets the comma substitution on every
|
|
149
|
+
// path, and scrubVoice (period substitution) finds no dash left. Pins
|
|
150
|
+
// the #2755 finding-1 ordering contract.
|
|
151
|
+
const { scrubVoice } = await import('../text-voice-scrub.js')
|
|
152
|
+
const input = 'voice came back — three PRs stacked'
|
|
153
|
+
const normalized = normalizePunctuation(input)
|
|
154
|
+
const scrub = scrubVoice(normalized)
|
|
155
|
+
expect(normalized).toBe('voice came back, three PRs stacked')
|
|
156
|
+
expect(scrub.replaced).toBe(0)
|
|
157
|
+
expect(scrub.scrubbed).toBe(normalized)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('stripExcessBold', () => {
|
|
162
|
+
const filler =
|
|
163
|
+
'This is an ordinary paragraph of connected prose that provides enough plain ' +
|
|
164
|
+
'characters to clear the one-hundred character exemption comfortably.'
|
|
165
|
+
|
|
166
|
+
test('short messages (<100 chars) exempt even when fully bold', () => {
|
|
167
|
+
const input = '**Everything here is bold.**'
|
|
168
|
+
expect(stripExcessBold(input)).toBe(input)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('strips all bold when >30% of non-code chars are bold', () => {
|
|
172
|
+
const bold = '**' + 'B'.repeat(80) + '**'
|
|
173
|
+
const input = `${bold} plus a little plain text tail here.`
|
|
174
|
+
const out = stripExcessBold(input)
|
|
175
|
+
expect(out).not.toContain('**')
|
|
176
|
+
expect(out).toContain('B'.repeat(80))
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('keeps bold when clearly under threshold', () => {
|
|
180
|
+
const input = `${filler} The key fact is **42**.`
|
|
181
|
+
expect(stripExcessBold(input)).toBe(input)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('strips a fully-bolded multi-line paragraph, leaves others', () => {
|
|
185
|
+
const input = `${filler}\n\n**This whole paragraph is bold.**\n**Every single line of it.**`
|
|
186
|
+
const out = stripExcessBold(input)
|
|
187
|
+
expect(out).toContain('This whole paragraph is bold.')
|
|
188
|
+
expect(out).not.toContain('**This whole paragraph is bold.**')
|
|
189
|
+
expect(out.startsWith(filler)).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('single short fully-bolded line (pseudo-heading) survives', () => {
|
|
193
|
+
const input = `**Summary**\n\n${filler}`
|
|
194
|
+
expect(stripExcessBold(input)).toBe(input)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('strips a list whose EVERY item is fully bolded', () => {
|
|
198
|
+
const input = `${filler}\n\n- **alpha item**\n- **bravo item**\n- **charlie item**`
|
|
199
|
+
const out = stripExcessBold(input)
|
|
200
|
+
expect(out).toContain('- alpha item')
|
|
201
|
+
expect(out).not.toContain('**alpha item**')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('leaves a list where only some items are bolded', () => {
|
|
205
|
+
const input = `${filler}\n\n- **alpha item**\n- plain bravo\n- plain charlie`
|
|
206
|
+
expect(stripExcessBold(input)).toBe(input)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('code regions neither counted nor modified', () => {
|
|
210
|
+
const fence = '```\n**not really bold**\n' + 'x'.repeat(400) + '\n```'
|
|
211
|
+
const input = `${filler} Key: **fact**.\n\n${fence}`
|
|
212
|
+
const out = stripExcessBold(input)
|
|
213
|
+
expect(out).toContain('**not really bold**')
|
|
214
|
+
expect(out).toContain('**fact**')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('idempotent', () => {
|
|
218
|
+
const bold = '**' + 'B'.repeat(80) + '**'
|
|
219
|
+
const input = `${bold} plus a little plain text tail.`
|
|
220
|
+
const once = stripExcessBold(input)
|
|
221
|
+
expect(stripExcessBold(once)).toBe(once)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse-level formatting regression suite.
|
|
3
|
+
*
|
|
4
|
+
* GOAL: catch formatting regressions at the *parse* level, not the string
|
|
5
|
+
* level. For every torture-set fixture we run the raw intent through the SAME
|
|
6
|
+
* outbound transform pipeline the gateway uses, then assert TWO independent
|
|
7
|
+
* signals via `rich-markdown-oracle.ts` (an independent CommonMark/GFM
|
|
8
|
+
* validator — NOT the formatter echoing itself):
|
|
9
|
+
*
|
|
10
|
+
* (a) PARSE-ACCEPT — every emitted chunk is well-formed enough that
|
|
11
|
+
* Telegram's rich-message path would not 400 on it (balanced fences,
|
|
12
|
+
* terminated code spans, closed links, complete table rows, balanced
|
|
13
|
+
* emphasis). This is the signal that would have caught "we shipped
|
|
14
|
+
* markdown Telegram rejects."
|
|
15
|
+
*
|
|
16
|
+
* (b) STRUCTURE — the transformed body parses into the entity structure the
|
|
17
|
+
* fixture intended (bold/italic/code/pre/link at the right inner text).
|
|
18
|
+
*
|
|
19
|
+
* The pipeline mirrors `telegram-plugin/gateway/gateway.ts`:
|
|
20
|
+
* repairEscapedWhitespace -> normalizeParagraphBreaks -> addParagraphSpacers
|
|
21
|
+
* -> splitMarkdownChunks. (Card-surface fixtures also apply `normalizeDashes`
|
|
22
|
+
* first — that is where F1's voice scrub lives.)
|
|
23
|
+
*
|
|
24
|
+
* This suite is TEST-ONLY. It changes no production formatting behaviour. If a
|
|
25
|
+
* fixture ever fails signal (a), it means the formatter emits markdown Telegram
|
|
26
|
+
* would reject — a real regression, not a test to relax.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, test, expect } from 'vitest'
|
|
30
|
+
import {
|
|
31
|
+
repairEscapedWhitespace,
|
|
32
|
+
normalizeParagraphBreaks,
|
|
33
|
+
addParagraphSpacers,
|
|
34
|
+
splitMarkdownChunks,
|
|
35
|
+
RICH_MESSAGE_MAX_CHARS,
|
|
36
|
+
} from '../format.js'
|
|
37
|
+
import { normalizeDashes } from '../text-voice-scrub.js'
|
|
38
|
+
import {
|
|
39
|
+
validateRichMarkdown,
|
|
40
|
+
isRichMarkdownValid,
|
|
41
|
+
parseRichEntities,
|
|
42
|
+
entitiesOfType,
|
|
43
|
+
scanFences,
|
|
44
|
+
type RichEntity,
|
|
45
|
+
} from './rich-markdown-oracle.js'
|
|
46
|
+
import { TORTURE_SET, type TortureFixture, type ExpectEntity } from './formatting-torture-set.js'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The outbound transform, mirroring the gateway. `cardSurfaceScrub` prepends
|
|
50
|
+
* the voice-scrub pass (F1's surface). `cap` overrides the chunk cap so the
|
|
51
|
+
* F7 hard-slice path is reachable in a test without a 32k fixture.
|
|
52
|
+
*/
|
|
53
|
+
function transform(input: string, opts: { cardSurfaceScrub?: boolean; cap?: number } = {}): {
|
|
54
|
+
chunks: string[]
|
|
55
|
+
/** The full transformed body BEFORE chunking — used for structure assertions. */
|
|
56
|
+
body: string
|
|
57
|
+
} {
|
|
58
|
+
let text = input
|
|
59
|
+
if (opts.cardSurfaceScrub) text = normalizeDashes(text)
|
|
60
|
+
text = normalizeParagraphBreaks(repairEscapedWhitespace(text))
|
|
61
|
+
const body = addParagraphSpacers(text)
|
|
62
|
+
const chunks = splitMarkdownChunks(body, opts.cap ?? RICH_MESSAGE_MAX_CHARS)
|
|
63
|
+
return { chunks, body }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Assert an expected entity is present among `got` (membership, not index). */
|
|
67
|
+
function expectEntityPresent(got: ReadonlyArray<RichEntity>, want: ExpectEntity): void {
|
|
68
|
+
const match = got.find(
|
|
69
|
+
(e) =>
|
|
70
|
+
e.type === want.type &&
|
|
71
|
+
e.text === want.text &&
|
|
72
|
+
(want.url == null || e.url === want.url) &&
|
|
73
|
+
(want.lang == null || e.lang === want.lang),
|
|
74
|
+
)
|
|
75
|
+
expect(
|
|
76
|
+
match,
|
|
77
|
+
`expected a ${want.type} entity with text ${JSON.stringify(want.text)}` +
|
|
78
|
+
(want.url != null ? ` url ${want.url}` : '') +
|
|
79
|
+
(want.lang != null ? ` lang ${want.lang}` : '') +
|
|
80
|
+
`; got: ${JSON.stringify(got)}`,
|
|
81
|
+
).toBeDefined()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Oracle self-tests — prove the oracle is a real, non-vacuous validator.
|
|
86
|
+
// If the oracle accepted everything (signal a) or found nothing (signal b),
|
|
87
|
+
// the whole suite would be a rubber stamp. These guard against that.
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe('rich-markdown-oracle — self-tests (guards against a vacuous oracle)', () => {
|
|
91
|
+
test('parse-accept: rejects an UNCLOSED fenced code block', () => {
|
|
92
|
+
const bad = 'intro\n```bash\necho hi\n' // no closing ```
|
|
93
|
+
const issues = validateRichMarkdown(bad)
|
|
94
|
+
expect(issues.some((i) => i.kind === 'unbalanced-fence')).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('parse-accept: rejects an unterminated inline code span', () => {
|
|
98
|
+
const issues = validateRichMarkdown('here is `unclosed code')
|
|
99
|
+
expect(issues.some((i) => i.kind === 'unterminated-code-span')).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('parse-accept: rejects an unclosed inline link destination', () => {
|
|
103
|
+
const issues = validateRichMarkdown('see [label](https://example.com/path')
|
|
104
|
+
expect(issues.some((i) => i.kind === 'malformed-link')).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('parse-accept: rejects a bisected (half) table row', () => {
|
|
108
|
+
const issues = validateRichMarkdown('| a | b')
|
|
109
|
+
expect(issues.some((i) => i.kind === 'incomplete-table-row')).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('parse-accept: rejects an unbalanced ** bold delimiter', () => {
|
|
113
|
+
const issues = validateRichMarkdown('this is **bold with no close')
|
|
114
|
+
expect(issues.some((i) => i.kind === 'unbalanced-emphasis')).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('parse-accept: ACCEPTS well-formed markdown with mixed entities', () => {
|
|
118
|
+
const good =
|
|
119
|
+
'Intro **bold** and _italic_ and `code`.\n\n```js\nconst x = 1\n```\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nSee [x](https://e.com).'
|
|
120
|
+
expect(validateRichMarkdown(good)).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('structure: extracts bold/italic/code/pre/link at the right text', () => {
|
|
124
|
+
const md = 'A **b** and _i_ and `c`.\n```py\nprint(1)\n```\n[lab](https://u.co)'
|
|
125
|
+
const ents = parseRichEntities(md)
|
|
126
|
+
expect(ents).toContainEqual({ type: 'bold', text: 'b' })
|
|
127
|
+
expect(ents).toContainEqual({ type: 'italic', text: 'i' })
|
|
128
|
+
expect(ents).toContainEqual({ type: 'code', text: 'c' })
|
|
129
|
+
expect(ents).toContainEqual({ type: 'pre', text: 'print(1)', lang: 'py' })
|
|
130
|
+
expect(ents).toContainEqual({ type: 'link', text: 'lab', url: 'https://u.co' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('inline code interior is verbatim — reserved chars do not leak as entities', () => {
|
|
134
|
+
// `*not bold*` inside code must NOT produce a bold/italic entity.
|
|
135
|
+
const ents = parseRichEntities('literal `*x*` here')
|
|
136
|
+
expect(ents.filter((e) => e.type === 'bold' || e.type === 'italic')).toEqual([])
|
|
137
|
+
expect(ents).toContainEqual({ type: 'code', text: '*x*' })
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// The torture set — signal (a) + (b) for every fixture.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
describe('formatting torture-set — parse-accept + structure', () => {
|
|
146
|
+
// F7 is the deliberate degraded path: a >cap indivisible fenced block is
|
|
147
|
+
// hard-sliced, so its individual chunks are intentionally NOT balanced
|
|
148
|
+
// markdown. Handled separately below.
|
|
149
|
+
const standard = TORTURE_SET.filter((f) => f.name !== 'F7-over-cap-hard-slice')
|
|
150
|
+
|
|
151
|
+
for (const fx of standard) {
|
|
152
|
+
describe(fx.name, () => {
|
|
153
|
+
test(`(intent) ${fx.intent}`, () => {
|
|
154
|
+
// (a) PARSE-ACCEPT — every emitted chunk is well-formed.
|
|
155
|
+
const { chunks, body } = transform(fx.input, { cardSurfaceScrub: fx.cardSurfaceScrub })
|
|
156
|
+
for (const c of chunks) {
|
|
157
|
+
const issues = validateRichMarkdown(c)
|
|
158
|
+
expect(
|
|
159
|
+
issues,
|
|
160
|
+
`chunk failed parse-accept for fixture "${fx.name}":\n${c}\nissues: ${JSON.stringify(issues)}`,
|
|
161
|
+
).toEqual([])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// (b) STRUCTURE — the transformed body parses into the intended shape.
|
|
165
|
+
const ents = parseRichEntities(body)
|
|
166
|
+
for (const want of fx.expect) expectEntityPresent(ents, want)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── F1 extra assertion: the em/en-dash glyph is actually gone. ────────────
|
|
172
|
+
test('F1: card-surface scrub removes the em/en-dash glyph before the wire', () => {
|
|
173
|
+
const fx = TORTURE_SET.find((f) => f.name === 'F1-card-surface-dash-scrub')!
|
|
174
|
+
const { body } = transform(fx.input, { cardSurfaceScrub: true })
|
|
175
|
+
expect(body).not.toContain('—') // em dash
|
|
176
|
+
expect(body).not.toContain('–') // en dash
|
|
177
|
+
// And the scrubbed body is still parse-accept clean.
|
|
178
|
+
expect(isRichMarkdownValid(body)).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// ── F2 extra assertion: the paragraph break survives (not collapsed). ─────
|
|
182
|
+
test('F2: a loose interior pipe does not swallow the following paragraph break', () => {
|
|
183
|
+
const fx = TORTURE_SET.find((f) => f.name === 'F2-stray-pipe-keeps-paragraph-spacing')!
|
|
184
|
+
const { body } = transform(fx.input)
|
|
185
|
+
// Both paragraphs are present and separated by a blank line (>= one \n\n).
|
|
186
|
+
expect(body).toContain('plan A | plan B')
|
|
187
|
+
expect(body).toContain('one pass')
|
|
188
|
+
expect(body).toMatch(/\n\s*\n/) // a real paragraph gap survived
|
|
189
|
+
// The loose pipe line must NOT have been turned into / read as a table
|
|
190
|
+
// row — the oracle would flag a half-row; it must be clean.
|
|
191
|
+
expect(isRichMarkdownValid(body)).toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// ── F4 extra assertion: a blank line now separates the fence from prose. ──
|
|
195
|
+
test('F4: a blank line is inserted between a closed fence and the following prose', () => {
|
|
196
|
+
const fx = TORTURE_SET.find((f) => f.name === 'F4-blank-line-after-closed-fence')!
|
|
197
|
+
const { body } = transform(fx.input)
|
|
198
|
+
// The fence stays balanced (closed) and the trailing prose survives.
|
|
199
|
+
const { fences } = scanFences(body)
|
|
200
|
+
expect(fences.length).toBe(1)
|
|
201
|
+
expect(fences[0].closed).toBe(true)
|
|
202
|
+
expect(body).toContain('And it ships today.')
|
|
203
|
+
// The line immediately following the fence close is blank (the F4 fix).
|
|
204
|
+
const lines = body.split('\n')
|
|
205
|
+
const closeIdx = lines.findIndex((l, i) => i > 0 && /^```/.test(l.trim()) && lines[i - 1] !== '```')
|
|
206
|
+
// Find the LAST fence-close line then assert the next non-fence line has a
|
|
207
|
+
// blank between it and the fence.
|
|
208
|
+
const fenceCloseLine = lines.map((l) => l.trim()).lastIndexOf('```')
|
|
209
|
+
expect(fenceCloseLine).toBeGreaterThan(0)
|
|
210
|
+
expect(lines[fenceCloseLine + 1] ?? '').toBe('')
|
|
211
|
+
void closeIdx
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// F7 — over-cap indivisible block hard-slices (degraded-but-delivered).
|
|
217
|
+
// Signal (a) does NOT apply per-chunk here by design (a hard slice can bisect
|
|
218
|
+
// a fence). What MUST hold: no content is dropped and every chunk fits the cap.
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
describe('F7-over-cap-hard-slice', () => {
|
|
222
|
+
const fx = TORTURE_SET.find((f) => f.name === 'F7-over-cap-hard-slice')!
|
|
223
|
+
const CAP = 100
|
|
224
|
+
|
|
225
|
+
test('an oversized indivisible fenced block is sliced to <= cap chunks, losing no content', () => {
|
|
226
|
+
const { chunks } = transform(fx.input, { cap: CAP })
|
|
227
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
228
|
+
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(CAP)
|
|
229
|
+
// No content dropped: the slices concatenate back to the original body.
|
|
230
|
+
// (The transform is a no-op on this fixture apart from chunking — it has no
|
|
231
|
+
// prose paragraphs to space, so body === input here.)
|
|
232
|
+
expect(chunks.join('')).toContain('x'.repeat(600))
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('the UN-chunked body is itself parse-accept valid (the fence is balanced)', () => {
|
|
236
|
+
// Sanity: the corruption F7 accepts is ONLY the intra-chunk fence bisection
|
|
237
|
+
// forced by the cap — the body as a whole is well-formed. This guards
|
|
238
|
+
// against F7 silently masking a genuine unbalanced-fence bug in the source.
|
|
239
|
+
const { body } = transform(fx.input, { cap: RICH_MESSAGE_MAX_CHARS })
|
|
240
|
+
expect(isRichMarkdownValid(body)).toBe(true)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Wiring-level: the fake bot API surfaces parsed entities on the rich path so
|
|
246
|
+
// a test can assert structure at the send boundary (deliverable 3). This uses
|
|
247
|
+
// the SAME independent oracle, keeping the check non-circular.
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
describe('wiring: sendRichMessage entity structure at the send boundary', () => {
|
|
251
|
+
test('a rich send of a torture body lands parse-valid with the intended entities', async () => {
|
|
252
|
+
const { createFakeBotApi } = await import('./fake-bot-api.js')
|
|
253
|
+
const bot = createFakeBotApi()
|
|
254
|
+
const fx = TORTURE_SET.find((f) => f.name === 'links')!
|
|
255
|
+
const { body } = transform(fx.input)
|
|
256
|
+
|
|
257
|
+
await bot.api.sendRichMessage('chat-1', { markdown: body })
|
|
258
|
+
const sent = bot.messagesIn('chat-1')
|
|
259
|
+
expect(sent).toHaveLength(1)
|
|
260
|
+
expect(sent[0].rich).toBe(true)
|
|
261
|
+
|
|
262
|
+
// Re-derive entities from what actually landed on the wire (sent[0].text is
|
|
263
|
+
// the rich markdown body) using the independent oracle.
|
|
264
|
+
const landed = sent[0].text
|
|
265
|
+
expect(isRichMarkdownValid(landed)).toBe(true)
|
|
266
|
+
for (const want of fx.expect) expectEntityPresent(parseRichEntities(landed), want)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Keep a reference so unused-import lint never trips on entitiesOfType even if
|
|
271
|
+
// a future edit drops its only direct use.
|
|
272
|
+
void entitiesOfType
|