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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { describeToolUse } from '../tool-activity-summary.js'
|
|
3
|
+
import { computeLabel } from '../hooks/tool-label-pretool.mjs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SINGLE STATUS-VOCABULARY GUARANTEE (know-what-my-agent-is-doing).
|
|
7
|
+
*
|
|
8
|
+
* Every activity surface renders per-tool status wording from ONE composer:
|
|
9
|
+
* `computeLabel` (hooks/tool-label-pretool.mjs). The consumers:
|
|
10
|
+
*
|
|
11
|
+
* - real-time live feed — the PreToolUse sidecar runs computeLabel at
|
|
12
|
+
* tool-call time (`tool_label` events → appendActivityLabel);
|
|
13
|
+
* - flush-time feed — appendActivityLine → describeToolUse;
|
|
14
|
+
* - nested sub-agent / worker-card steps — subagent-watcher →
|
|
15
|
+
* describeToolUse (whose lines the 🛠 Worker card renders via
|
|
16
|
+
* renderStatusCard).
|
|
17
|
+
*
|
|
18
|
+
* `describeToolUse` is a thin delegating wrapper over computeLabel. Before
|
|
19
|
+
* the delegation the two tables had drifted (same action, different copy on
|
|
20
|
+
* different surfaces — the wording drift the spec bars). This test is the
|
|
21
|
+
* anti-drift lock: it sweeps a corpus of every tool shape both composers
|
|
22
|
+
* handle and asserts they emit IDENTICAL labels. If someone re-forks the
|
|
23
|
+
* vocabulary — a second wording table in describeToolUse, a hook-only
|
|
24
|
+
* rewording — this fails CI.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Corpus: (toolName, input) pairs spanning every branch of the composer.
|
|
28
|
+
const CORPUS: Array<[string, Record<string, unknown>]> = [
|
|
29
|
+
['Bash', { command: 'ls -la', description: 'List repo files' }],
|
|
30
|
+
['Bash', { command: 'grep -r foo .' }],
|
|
31
|
+
['BashOutput', {}],
|
|
32
|
+
['KillShell', {}],
|
|
33
|
+
['Read', { file_path: '/home/u/code/gateway.ts' }],
|
|
34
|
+
['Read', {}],
|
|
35
|
+
['Edit', { file_path: '/a/b/CLAUDE.md' }],
|
|
36
|
+
['MultiEdit', { file_path: '/a/b/x.ts' }],
|
|
37
|
+
['Edit', {}],
|
|
38
|
+
['Write', { file_path: 'notes.txt' }],
|
|
39
|
+
['Write', {}],
|
|
40
|
+
['NotebookEdit', { notebook_path: '/n/analysis.ipynb' }],
|
|
41
|
+
['NotebookEdit', {}],
|
|
42
|
+
['Grep', { pattern: 'TODO' }],
|
|
43
|
+
['Grep', { pattern: 'TODO', path: 'src/' }],
|
|
44
|
+
['Grep', {}],
|
|
45
|
+
['Glob', { pattern: '**/*.ts' }],
|
|
46
|
+
['Glob', {}],
|
|
47
|
+
['WebFetch', { url: 'https://www.example.com/path?q=1' }],
|
|
48
|
+
['WebFetch', {}],
|
|
49
|
+
['WebSearch', { query: 'best running shoes' }],
|
|
50
|
+
['WebSearch', {}],
|
|
51
|
+
['Task', { description: 'Review the migration' }],
|
|
52
|
+
['Agent', {}],
|
|
53
|
+
['TodoWrite', {}],
|
|
54
|
+
['TaskCreate', {}],
|
|
55
|
+
['TaskUpdate', {}],
|
|
56
|
+
['TaskList', {}],
|
|
57
|
+
['ToolSearch', {}],
|
|
58
|
+
['Skill', { skill: 'switchroom' }],
|
|
59
|
+
['SomeFutureBuiltin', {}],
|
|
60
|
+
['mcp__hindsight__recall', { query: 'x' }],
|
|
61
|
+
['mcp__hindsight__reflect', { query: 'x' }],
|
|
62
|
+
['mcp__hindsight__retain', {}],
|
|
63
|
+
['mcp__hindsight__update_memory', {}],
|
|
64
|
+
['mcp__hindsight__list_memories', {}],
|
|
65
|
+
['mcp__claude_ai_Google_Calendar__list_events', {}],
|
|
66
|
+
['mcp__claude_ai_Gmail__search', {}],
|
|
67
|
+
['mcp__claude_ai_Google_Drive__search_files', {}],
|
|
68
|
+
['mcp__claude_ai_Notion__notion-search', {}],
|
|
69
|
+
['mcp__perplexity__perplexity_search', { query: 'weather sydney' }],
|
|
70
|
+
['mcp__webkite__read', { url: 'https://example.org/docs' }],
|
|
71
|
+
['mcp__acme__do_thing', { description: 'Fetched the report' }],
|
|
72
|
+
['mcp__acme__do_thing', {}],
|
|
73
|
+
['mcp__switchroom-telegram__get_recent_messages', {}],
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
// Suppressed-on-both corpus: surfaces must agree these render NOTHING.
|
|
77
|
+
const SUPPRESSED: Array<[string, Record<string, unknown>]> = [
|
|
78
|
+
['mcp__switchroom-telegram__reply', { text: 'hi' }],
|
|
79
|
+
['mcp__switchroom-telegram__stream_reply', {}],
|
|
80
|
+
['mcp__switchroom-telegram__edit_message', {}],
|
|
81
|
+
['mcp__switchroom-telegram__react', {}],
|
|
82
|
+
['mcp__clerk-telegram__reply', {}],
|
|
83
|
+
['mcp__hindsight__sync_retain', {}],
|
|
84
|
+
['Skill', {}], // empty-slug Skill stays suppressed (#2111 sidecar contract)
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
describe('single status-vocabulary composer (drift fails CI)', () => {
|
|
88
|
+
it('describeToolUse and computeLabel emit identical labels across the corpus', () => {
|
|
89
|
+
for (const [tool, input] of CORPUS) {
|
|
90
|
+
expect(describeToolUse(tool, input), `label drift for ${tool}`).toBe(
|
|
91
|
+
computeLabel(tool, input),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('both composers agree on suppression (never a surfaced reply/control tool)', () => {
|
|
97
|
+
for (const [tool, input] of SUPPRESSED) {
|
|
98
|
+
expect(describeToolUse(tool, input), `describeToolUse must suppress ${tool}`).toBeNull()
|
|
99
|
+
expect(computeLabel(tool, input), `computeLabel must suppress ${tool}`).toBeNull()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('the previously-drifted wordings are unified (the Ken-observed drift)', () => {
|
|
104
|
+
// These exact pairs rendered DIFFERENT copy per surface before the
|
|
105
|
+
// unification. Pin the unified form on both composers.
|
|
106
|
+
expect(computeLabel('Grep', { pattern: 'TODO' })).toBe('Searching for TODO')
|
|
107
|
+
expect(describeToolUse('Grep', { pattern: 'TODO' })).toBe('Searching for TODO')
|
|
108
|
+
expect(computeLabel('WebFetch', { url: 'https://www.example.com/path?q=1' })).toBe(
|
|
109
|
+
'Reading example.com',
|
|
110
|
+
)
|
|
111
|
+
expect(describeToolUse('WebFetch', { url: 'https://www.example.com/path?q=1' })).toBe(
|
|
112
|
+
'Reading example.com',
|
|
113
|
+
)
|
|
114
|
+
expect(computeLabel('mcp__hindsight__retain', {})).toBe('Saving to memory')
|
|
115
|
+
expect(describeToolUse('mcp__hindsight__retain', {})).toBe('Saving to memory')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('never emits raw shell/query syntax from either composer', () => {
|
|
119
|
+
for (const [tool, input] of CORPUS) {
|
|
120
|
+
const label = computeLabel(tool, input)
|
|
121
|
+
if (label == null) continue
|
|
122
|
+
expect(label, `raw syntax leaked for ${tool}`).not.toMatch(/grep -r|ls -la/)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -117,6 +117,45 @@ describe('handleStreamReply', () => {
|
|
|
117
117
|
expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
+
it('applies addParagraphSpacers on the rich path (multi-paragraph gap spaced)', async () => {
|
|
121
|
+
const state = makeState()
|
|
122
|
+
// Spacer dep replaces every `\n\n` gap with a visible marker so we can
|
|
123
|
+
// assert the rich path ran it (mirrors the real gateway wiring).
|
|
124
|
+
const deps = makeDeps(bot, {
|
|
125
|
+
addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const pending = handleStreamReply(
|
|
129
|
+
{ chat_id: '1', text: 'Para one.\n\nPara two.', done: true },
|
|
130
|
+
state,
|
|
131
|
+
deps,
|
|
132
|
+
)
|
|
133
|
+
await microtaskFlush()
|
|
134
|
+
await pending
|
|
135
|
+
|
|
136
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
137
|
+
expect(richSendMarkdown(bot)).toBe('Para one.\n\nSPACER\n\nPara two.')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('does NOT apply addParagraphSpacers on the literal format=text path', async () => {
|
|
141
|
+
const state = makeState()
|
|
142
|
+
const deps = makeDeps(bot, {
|
|
143
|
+
addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const pending = handleStreamReply(
|
|
147
|
+
{ chat_id: '1', text: 'Para one.\n\nPara two.', format: 'text', done: true },
|
|
148
|
+
state,
|
|
149
|
+
deps,
|
|
150
|
+
)
|
|
151
|
+
await microtaskFlush()
|
|
152
|
+
await pending
|
|
153
|
+
|
|
154
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
|
|
155
|
+
// Literal path is byte-exact — no spacer injected.
|
|
156
|
+
expect(bot.api.sendMessage.mock.calls[0][1]).toBe('Para one.\n\nPara two.')
|
|
157
|
+
})
|
|
158
|
+
|
|
120
159
|
it('throws when text exceeds the rich-message cap (no silent id:pending)', async () => {
|
|
121
160
|
const state = makeState()
|
|
122
161
|
const deps = makeDeps(bot)
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
repairEscapedWhitespace,
|
|
20
20
|
escapeMarkdown,
|
|
21
21
|
splitMarkdownChunks,
|
|
22
|
+
addParagraphSpacers,
|
|
23
|
+
PARAGRAPH_SPACER,
|
|
22
24
|
RICH_MESSAGE_MAX_CHARS,
|
|
23
25
|
} from '../format.js'
|
|
24
26
|
|
|
@@ -63,6 +65,26 @@ describe('escapeMarkdown', () => {
|
|
|
63
65
|
// `\*` in the input is backslash + star → both get escaped exactly once.
|
|
64
66
|
expect(escapeMarkdown('\\*')).toBe('\\\\\\*')
|
|
65
67
|
})
|
|
68
|
+
|
|
69
|
+
// The de-dup pass (#2695) replaced ~12 local escaper copies with this one
|
|
70
|
+
// canonical function. Every consolidated caller now depends on this exact
|
|
71
|
+
// contract: the FULL metacharacter set in a single string, each escaped.
|
|
72
|
+
test('escapes the full metacharacter set in one combined string', () => {
|
|
73
|
+
// Backslash placed last so it doesn't re-escape the chars after it.
|
|
74
|
+
expect(escapeMarkdown('`*_~=[]|\\')).toBe('\\`\\*\\_\\~\\=\\[\\]\\|\\\\')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('already-safe text passes through unchanged', () => {
|
|
78
|
+
const safe = 'Switched fleet to alice (5h window 29% resets in 2h)'
|
|
79
|
+
expect(escapeMarkdown(safe)).toBe(safe)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// card-format.ts re-exports this same symbol; the consolidated callers
|
|
83
|
+
// import from one or the other. Lock that they are the identical function.
|
|
84
|
+
test('card-format re-exports the identical canonical escaper', async () => {
|
|
85
|
+
const cardFormat = await import('../card-format.js')
|
|
86
|
+
expect(cardFormat.escapeMarkdown).toBe(escapeMarkdown)
|
|
87
|
+
})
|
|
66
88
|
})
|
|
67
89
|
|
|
68
90
|
// ---------------------------------------------------------------------------
|
|
@@ -101,7 +123,10 @@ describe('splitMarkdownChunks', () => {
|
|
|
101
123
|
// A long body with a fenced code block straddling the cap. The chunker
|
|
102
124
|
// must move the whole fence to a single chunk so no chunk has an odd
|
|
103
125
|
// number of fence delimiters (which would swallow the next chunk).
|
|
104
|
-
|
|
126
|
+
// The fence FITS within the cap, so the chunker can move it whole to a
|
|
127
|
+
// single chunk and keep every chunk's fence count balanced. (An oversized
|
|
128
|
+
// fence that cannot fit is hard-sliced instead — see the F7 tests below.)
|
|
129
|
+
const fence = '```\n' + 'code line\n'.repeat(8) + '```'
|
|
105
130
|
const text = 'intro paragraph '.repeat(15) + '\n\n' + fence + '\n\noutro paragraph'
|
|
106
131
|
const chunks = splitMarkdownChunks(text, 180)
|
|
107
132
|
for (const c of chunks) {
|
|
@@ -128,15 +153,103 @@ describe('splitMarkdownChunks', () => {
|
|
|
128
153
|
}
|
|
129
154
|
})
|
|
130
155
|
|
|
131
|
-
test('
|
|
132
|
-
// A single fenced block larger than maxLen has no safe interior cut
|
|
133
|
-
//
|
|
134
|
-
//
|
|
156
|
+
test('hard-slices an indivisible block that exceeds the cap (F7 — no oversized chunk)', () => {
|
|
157
|
+
// A single fenced block larger than maxLen has no safe interior cut. The
|
|
158
|
+
// OLD behaviour emitted it whole, which Telegram rejects with
|
|
159
|
+
// RICH_MESSAGE_TEXT_TOO_LONG and drops the entire answer. F7 delegates to
|
|
160
|
+
// hardSliceToCap so the region comes out as multiple <= cap character
|
|
161
|
+
// slices instead — degraded-but-delivered beats a hard reject.
|
|
135
162
|
const giant = '```\n' + 'x'.repeat(500) + '\n```'
|
|
136
163
|
const chunks = splitMarkdownChunks(giant, 100)
|
|
137
|
-
|
|
138
|
-
expect(
|
|
139
|
-
|
|
164
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
165
|
+
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100)
|
|
166
|
+
// No content is dropped — the slices concatenate back to the original.
|
|
167
|
+
expect(chunks.join('')).toBe(giant)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('hard-slices a single unbreakable >maxLen token run into <= cap chunks (F7)', () => {
|
|
171
|
+
// One giant pipe-bearing line with no space/newline boundary below the
|
|
172
|
+
// cap: neither the blank-line, newline, nor space heuristic can find a
|
|
173
|
+
// cut, and the table-row back-off cannot help either. This is the exact
|
|
174
|
+
// "cut <= 0" path — it must hard-slice, not emit an oversized chunk.
|
|
175
|
+
const runaway = 'a|b|'.repeat(200) // 800 chars, no spaces/newlines
|
|
176
|
+
const chunks = splitMarkdownChunks(runaway, 100)
|
|
177
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
178
|
+
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100)
|
|
179
|
+
expect(chunks.join('')).toBe(runaway)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('an indivisible region larger than the real 32768 cap still slices to <= cap (F7)', () => {
|
|
183
|
+
const giant = 'z'.repeat(RICH_MESSAGE_MAX_CHARS + 5000)
|
|
184
|
+
const chunks = splitMarkdownChunks(giant) // default cap = RICH_MESSAGE_MAX_CHARS
|
|
185
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
186
|
+
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
// Chunk-boundary spacer hygiene. addParagraphSpacers injects a
|
|
191
|
+
// `\n\n${PARAGRAPH_SPACER}\n\n` gap between prose paragraphs. When a cut
|
|
192
|
+
// lands inside that gap, the boundary must not leave a chunk that opens or
|
|
193
|
+
// ends with a bare U+00A0 spacer line (a stray blank bubble line).
|
|
194
|
+
// -------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
test('no chunk starts or ends with a bare U+00A0 spacer line (reviewer repro)', () => {
|
|
197
|
+
// The exact reviewer repro: a spacer gap straddling a small cap.
|
|
198
|
+
const A = 'Alpha sentence one'
|
|
199
|
+
const B = 'Bravo sentence two'
|
|
200
|
+
const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
|
|
201
|
+
const chunks = splitMarkdownChunks(spaced, 33)
|
|
202
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
203
|
+
const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
|
|
204
|
+
for (const c of chunks) {
|
|
205
|
+
const lines = c.split('\n')
|
|
206
|
+
expect(spacerOnly.test(lines[0])).toBe(false)
|
|
207
|
+
expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('visible paragraph content survives the boundary (no text dropped)', () => {
|
|
212
|
+
const A = 'Alpha sentence one'
|
|
213
|
+
const B = 'Bravo sentence two'
|
|
214
|
+
const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
|
|
215
|
+
const chunks = splitMarkdownChunks(spaced, 33)
|
|
216
|
+
const rejoined = chunks.join('\n')
|
|
217
|
+
expect(rejoined).toContain(`${A}.`)
|
|
218
|
+
expect(rejoined).toContain(`${B}.`)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('spacer-boundary strip is robust across several gaps and small caps', () => {
|
|
222
|
+
const paras = Array.from({ length: 6 }, (_, i) => `Paragraph ${i} body text here.`)
|
|
223
|
+
const spaced = addParagraphSpacers(paras.join('\n\n'))
|
|
224
|
+
const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
|
|
225
|
+
for (const cap of [20, 31, 40, 64]) {
|
|
226
|
+
const chunks = splitMarkdownChunks(spaced, cap)
|
|
227
|
+
for (const c of chunks) {
|
|
228
|
+
const lines = c.split('\n')
|
|
229
|
+
expect(spacerOnly.test(lines[0])).toBe(false)
|
|
230
|
+
expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
|
|
231
|
+
}
|
|
232
|
+
// No visible word is dropped: concatenating the chunks' non-blank,
|
|
233
|
+
// non-spacer tokens reproduces the original word sequence. (Word-level,
|
|
234
|
+
// not line-level, because a small cap may split mid-word — that's the
|
|
235
|
+
// chunker's normal space-boundary behaviour, orthogonal to spacers.)
|
|
236
|
+
const words = (s: string): string[] =>
|
|
237
|
+
s.split(/\s+/).filter((w) => w.length > 0 && w !== PARAGRAPH_SPACER)
|
|
238
|
+
// Join chunks with a space — each chunk is a separate Telegram message,
|
|
239
|
+
// so inter-chunk whitespace is irrelevant; what matters is no word is
|
|
240
|
+
// lost or fused. (A small cap may end a chunk mid-sentence at a space
|
|
241
|
+
// boundary, e.g. "...here." | "Paragraph 1...", which is normal.)
|
|
242
|
+
expect(words(chunks.join(' '))).toEqual(words(paras.join(' ')))
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('a boundary with NO spacer is unaffected (legacy ^\\n+ behaviour preserved)', () => {
|
|
247
|
+
const text = Array.from({ length: 10 }, (_, i) => `plain line ${i}`).join('\n\n')
|
|
248
|
+
const chunks = splitMarkdownChunks(text, 40)
|
|
249
|
+
// No spacer was ever present, so no chunk gains/loses anything beyond the
|
|
250
|
+
// normal leading-newline strip; content is preserved.
|
|
251
|
+
const rejoined = chunks.join('\n').replace(/\n+/g, '\n')
|
|
252
|
+
expect(rejoined).toBe(text.replace(/\n+/g, '\n'))
|
|
140
253
|
})
|
|
141
254
|
})
|
|
142
255
|
|
|
@@ -10,7 +10,95 @@
|
|
|
10
10
|
|
|
11
11
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
12
12
|
|
|
13
|
-
import { scrubVoice } from '../text-voice-scrub.js'
|
|
13
|
+
import { scrubVoice, normalizeDashes } from '../text-voice-scrub.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Regression guard for the em-dash comma-splice bug seen in a real reply
|
|
17
|
+
* (the #2737-era voice scrub). The original #1683 rule degraded a
|
|
18
|
+
* clause-joining em-dash to a comma when the next word was lowercase, which
|
|
19
|
+
* joined two independent clauses with a comma — a comma splice ("voice came
|
|
20
|
+
* back, three PRs stacked"). The fix degrades to a full stop and recapitalizes
|
|
21
|
+
* the following word, so the output is never a splice. These tests use the
|
|
22
|
+
* verbatim failure strings from the stored bad message.
|
|
23
|
+
*/
|
|
24
|
+
describe('scrubVoice — em-dash between clauses never produces a comma splice', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
delete process.env.SWITCHROOM_DISABLE_VOICE_SCRUB
|
|
27
|
+
delete process.env.SWITCHROOM_VOICE_STRIP_OPENERS
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('lowercase-continued independent clause becomes two sentences, not a splice (real string 1)', () => {
|
|
31
|
+
const r = scrubVoice('**Why voice came back** — three PRs stacked')
|
|
32
|
+
// Was: "**Why voice came back**, three PRs stacked" (comma splice).
|
|
33
|
+
expect(r.scrubbed).toBe('**Why voice came back**. Three PRs stacked')
|
|
34
|
+
expect(r.scrubbed).not.toContain('**, three')
|
|
35
|
+
expect(r.replaced).toBe(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('lowercase-continued independent clause becomes two sentences, not a splice (real string 2)', () => {
|
|
39
|
+
const r = scrubVoice(
|
|
40
|
+
'never reached the agents — this is what unblocked everything today.',
|
|
41
|
+
)
|
|
42
|
+
// Was: "...the agents, this is what unblocked..." (comma splice).
|
|
43
|
+
expect(r.scrubbed).toBe(
|
|
44
|
+
'never reached the agents. This is what unblocked everything today.',
|
|
45
|
+
)
|
|
46
|
+
expect(r.scrubbed).not.toContain('agents, this')
|
|
47
|
+
expect(r.replaced).toBe(1)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('the scrubbed output contains no ", <lowercase clause>" splice for any spaced dash', () => {
|
|
51
|
+
const r = scrubVoice(
|
|
52
|
+
'first thing — second independent clause and — third one here',
|
|
53
|
+
)
|
|
54
|
+
// No comma directly joining the two independent clauses.
|
|
55
|
+
expect(r.scrubbed).toBe(
|
|
56
|
+
'first thing. Second independent clause and. Third one here',
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('normalizeDashes (card / worker-narration path) also avoids the splice', () => {
|
|
61
|
+
// The non-reply surfaces reuse normalizeDashes; same splice-free rule.
|
|
62
|
+
expect(normalizeDashes('came back — three PRs stacked')).toBe(
|
|
63
|
+
'came back. Three PRs stacked',
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('a dash before a NON-letter (digit / paren) is not wrongly recapitalized', () => {
|
|
68
|
+
// capFirst only touches an ASCII lowercase letter; a digit / punctuation
|
|
69
|
+
// start is left as-is after the full stop.
|
|
70
|
+
expect(normalizeDashes('the count — 3 items remain')).toBe(
|
|
71
|
+
'the count. 3 items remain',
|
|
72
|
+
)
|
|
73
|
+
expect(normalizeDashes('the note — (see appendix)')).toBe(
|
|
74
|
+
'the note. (see appendix)',
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('dashes inside code spans, fenced blocks, and URLs are still never touched', () => {
|
|
79
|
+
const input =
|
|
80
|
+
'prose — one\n' +
|
|
81
|
+
'inline `a — b — c` stays\n' +
|
|
82
|
+
'```\nfenced x — y — z stays\n```\n' +
|
|
83
|
+
'link https://ex.io/a—b—c stays too — end'
|
|
84
|
+
const r = scrubVoice(input)
|
|
85
|
+
expect(r.scrubbed).toContain('`a — b — c`')
|
|
86
|
+
expect(r.scrubbed).toContain('fenced x — y — z stays')
|
|
87
|
+
expect(r.scrubbed).toContain('https://ex.io/a—b—c')
|
|
88
|
+
// Only the two prose dashes were scrubbed (to full stops).
|
|
89
|
+
expect(r.scrubbed).toContain('prose. One')
|
|
90
|
+
expect(r.scrubbed).toContain('stays too. End')
|
|
91
|
+
expect(r.replaced).toBe(2)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('a double-space inside inline code / a URL is left untouched by the scrub', () => {
|
|
95
|
+
// The scrub must not normalize whitespace inside protected regions.
|
|
96
|
+
const r = scrubVoice('keep `foo bar` and http://h/a b intact — done')
|
|
97
|
+
expect(r.scrubbed).toContain('`foo bar`')
|
|
98
|
+
expect(r.scrubbed).toContain('http://h/a b')
|
|
99
|
+
expect(r.scrubbed).toContain('intact. Done')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
14
102
|
|
|
15
103
|
describe('scrubVoice — em / en dash replacement', () => {
|
|
16
104
|
beforeEach(() => {
|
|
@@ -21,9 +109,13 @@ describe('scrubVoice — em / en dash replacement', () => {
|
|
|
21
109
|
})
|
|
22
110
|
|
|
23
111
|
describe('mechanical rewrite of spaced dashes', () => {
|
|
24
|
-
it('replaces a spaced em-dash before lowercase with a comma', () => {
|
|
112
|
+
it('replaces a spaced em-dash before lowercase with a full stop (no comma splice)', () => {
|
|
113
|
+
// #1683 originally emitted a comma here, which produced a comma splice
|
|
114
|
+
// between two independent clauses ("on it, checking the calendar"). The
|
|
115
|
+
// splice-free rule degrades the clause-joining dash to a period and
|
|
116
|
+
// recapitalizes the following word.
|
|
25
117
|
const r = scrubVoice('on it — checking the calendar')
|
|
26
|
-
expect(r.scrubbed).toBe('on it
|
|
118
|
+
expect(r.scrubbed).toBe('on it. Checking the calendar')
|
|
27
119
|
expect(r.replaced).toBe(1)
|
|
28
120
|
})
|
|
29
121
|
|
|
@@ -34,22 +126,23 @@ describe('scrubVoice — em / en dash replacement', () => {
|
|
|
34
126
|
expect(r.replaced).toBe(1)
|
|
35
127
|
})
|
|
36
128
|
|
|
37
|
-
it('handles multiple em-dashes in one sentence', () => {
|
|
129
|
+
it('handles multiple em-dashes in one sentence (each becomes a full stop)', () => {
|
|
38
130
|
const r = scrubVoice('one — two — three — done')
|
|
39
|
-
expect(r.scrubbed).toBe('one
|
|
131
|
+
expect(r.scrubbed).toBe('one. Two. Three. Done')
|
|
40
132
|
expect(r.replaced).toBe(3)
|
|
41
133
|
})
|
|
42
134
|
|
|
43
135
|
it('treats en-dash (–) identically to em-dash', () => {
|
|
44
136
|
const r = scrubVoice('on it – checking the calendar')
|
|
45
|
-
expect(r.scrubbed).toBe('on it
|
|
137
|
+
expect(r.scrubbed).toBe('on it. Checking the calendar')
|
|
46
138
|
expect(r.replaced).toBe(1)
|
|
47
139
|
})
|
|
48
140
|
|
|
49
|
-
it('replaces unspaced word-dash-word
|
|
50
|
-
// Less common but seen in tightly-typed prose.
|
|
141
|
+
it('replaces unspaced word-dash-word with a full stop (no comma splice)', () => {
|
|
142
|
+
// Less common but seen in tightly-typed prose. Same splice-free rule as
|
|
143
|
+
// the spaced form: a period plus a recapitalized following word.
|
|
51
144
|
const r = scrubVoice('flag—on or flag—off')
|
|
52
|
-
expect(r.scrubbed).toBe('flag
|
|
145
|
+
expect(r.scrubbed).toBe('flag. On or flag. Off')
|
|
53
146
|
expect(r.replaced).toBe(2)
|
|
54
147
|
})
|
|
55
148
|
|
|
@@ -66,6 +159,32 @@ describe('scrubVoice — em / en dash replacement', () => {
|
|
|
66
159
|
expect(r.scrubbed).toBe('- note: ship it')
|
|
67
160
|
expect(r.replaced).toBe(1)
|
|
68
161
|
})
|
|
162
|
+
|
|
163
|
+
it('does not recapitalize a mixed-case brand token after a dash', () => {
|
|
164
|
+
// capFirst must leave a lowercase-initial mixed-case token (iOS, eBay,
|
|
165
|
+
// iPhone, macOS) alone — recapitalizing "iOS" to "IOS" corrupts the
|
|
166
|
+
// brand. A normal lowercase word still gets recapitalized.
|
|
167
|
+
const r = scrubVoice('foo—iOS build')
|
|
168
|
+
expect(r.scrubbed).toBe('foo. iOS build')
|
|
169
|
+
expect(r.replaced).toBe(1)
|
|
170
|
+
|
|
171
|
+
const spaced = scrubVoice('shipped — iPhone ready')
|
|
172
|
+
expect(spaced.scrubbed).toBe('shipped. iPhone ready')
|
|
173
|
+
|
|
174
|
+
const normal = scrubVoice('foo—done here')
|
|
175
|
+
expect(normal.scrubbed).toBe('foo. Done here')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('preserves numeric ranges as ASCII hyphens, not full stops', () => {
|
|
179
|
+
// A digit-flanked dash is a range, not a clause break. It falls through
|
|
180
|
+
// to the catch-all rule and becomes an ASCII hyphen.
|
|
181
|
+
const unspaced = scrubVoice('10–20')
|
|
182
|
+
expect(unspaced.scrubbed).toBe('10-20')
|
|
183
|
+
expect(unspaced.replaced).toBe(1)
|
|
184
|
+
|
|
185
|
+
const tight = scrubVoice('scored 3–2')
|
|
186
|
+
expect(tight.scrubbed).toBe('scored 3-2')
|
|
187
|
+
})
|
|
69
188
|
})
|
|
70
189
|
|
|
71
190
|
describe('protected regions are left alone', () => {
|
|
@@ -73,33 +192,33 @@ describe('scrubVoice — em / en dash replacement', () => {
|
|
|
73
192
|
const input = 'here is code:\n```bash\nfoo --bar — baz\n```\nand prose — done'
|
|
74
193
|
const r = scrubVoice(input)
|
|
75
194
|
expect(r.scrubbed).toBe(
|
|
76
|
-
'here is code:\n```bash\nfoo --bar — baz\n```\nand prose
|
|
195
|
+
'here is code:\n```bash\nfoo --bar — baz\n```\nand prose. Done',
|
|
77
196
|
)
|
|
78
197
|
expect(r.replaced).toBe(1)
|
|
79
198
|
})
|
|
80
199
|
|
|
81
200
|
it('preserves dashes inside inline code', () => {
|
|
82
201
|
const r = scrubVoice('the flag `--really — keep` matters — yes')
|
|
83
|
-
expect(r.scrubbed).toBe('the flag `--really — keep` matters
|
|
202
|
+
expect(r.scrubbed).toBe('the flag `--really — keep` matters. Yes')
|
|
84
203
|
expect(r.replaced).toBe(1)
|
|
85
204
|
})
|
|
86
205
|
|
|
87
206
|
it('preserves dashes inside <code> HTML tags', () => {
|
|
88
207
|
const r = scrubVoice('see <code>x — y</code> and note — ok')
|
|
89
|
-
expect(r.scrubbed).toBe('see <code>x — y</code> and note
|
|
208
|
+
expect(r.scrubbed).toBe('see <code>x — y</code> and note. Ok')
|
|
90
209
|
expect(r.replaced).toBe(1)
|
|
91
210
|
})
|
|
92
211
|
|
|
93
212
|
it('preserves dashes inside <pre> HTML tags', () => {
|
|
94
213
|
const r = scrubVoice('block:\n<pre>x — y\nz — w</pre>\nend — ok')
|
|
95
|
-
expect(r.scrubbed).toBe('block:\n<pre>x — y\nz — w</pre>\nend
|
|
214
|
+
expect(r.scrubbed).toBe('block:\n<pre>x — y\nz — w</pre>\nend. Ok')
|
|
96
215
|
expect(r.replaced).toBe(1)
|
|
97
216
|
})
|
|
98
217
|
|
|
99
218
|
it('preserves dashes inside URLs', () => {
|
|
100
219
|
const r = scrubVoice('see https://example.com/a—b for context — ok')
|
|
101
220
|
expect(r.scrubbed).toBe(
|
|
102
|
-
'see https://example.com/a—b for context
|
|
221
|
+
'see https://example.com/a—b for context. Ok',
|
|
103
222
|
)
|
|
104
223
|
expect(r.replaced).toBe(1)
|
|
105
224
|
})
|
|
@@ -110,7 +229,7 @@ describe('scrubVoice — em / en dash replacement', () => {
|
|
|
110
229
|
const fence =
|
|
111
230
|
'```\n# heading — title\nfunction f() {}\n```'
|
|
112
231
|
const r = scrubVoice(fence + '\ntrailing — yes')
|
|
113
|
-
expect(r.scrubbed).toBe(fence + '\ntrailing
|
|
232
|
+
expect(r.scrubbed).toBe(fence + '\ntrailing. Yes')
|
|
114
233
|
expect(r.replaced).toBe(1)
|
|
115
234
|
})
|
|
116
235
|
})
|
|
@@ -151,8 +270,8 @@ describe('scrubVoice — em / en dash replacement', () => {
|
|
|
151
270
|
'Result: empty for Saturday — nothing scheduled. Anything else?'
|
|
152
271
|
const r = scrubVoice(input)
|
|
153
272
|
expect(r.scrubbed).toBe(
|
|
154
|
-
"I'll check the calendar
|
|
155
|
-
'Result: empty for Saturday
|
|
273
|
+
"I'll check the calendar. Should take a few seconds. " +
|
|
274
|
+
'Result: empty for Saturday. Nothing scheduled. Anything else?',
|
|
156
275
|
)
|
|
157
276
|
expect(r.replaced).toBe(2)
|
|
158
277
|
})
|
|
@@ -164,9 +283,9 @@ describe('scrubVoice — em / en dash replacement', () => {
|
|
|
164
283
|
'Ready to commit — go?'
|
|
165
284
|
const r = scrubVoice(input)
|
|
166
285
|
expect(r.scrubbed).toBe(
|
|
167
|
-
'Running `git status --short
|
|
286
|
+
'Running `git status --short`. Looks clean. ' +
|
|
168
287
|
'```\nM file.ts — modified\n```\n' +
|
|
169
|
-
'Ready to commit
|
|
288
|
+
'Ready to commit. Go?',
|
|
170
289
|
)
|
|
171
290
|
expect(r.replaced).toBe(2)
|
|
172
291
|
})
|
|
@@ -293,15 +412,16 @@ describe('scrubVoice — opener strip is OFF by default (prompt carries tone)',
|
|
|
293
412
|
|
|
294
413
|
it('STILL normalizes em-dashes by default (punctuation, no content removed)', () => {
|
|
295
414
|
const r = scrubVoice('on it — checking the calendar')
|
|
296
|
-
expect(r.scrubbed).toBe('on it
|
|
415
|
+
expect(r.scrubbed).toBe('on it. Checking the calendar')
|
|
297
416
|
expect(r.replaced).toBe(1)
|
|
298
417
|
expect(r.openersStripped).toBe(0)
|
|
299
418
|
})
|
|
300
419
|
|
|
301
420
|
it('an affirmation opener with an em-dash keeps the words, fixes only the dash', () => {
|
|
302
421
|
const r = scrubVoice('Exactly right — the token had expired.')
|
|
303
|
-
// Opener preserved; the em-dash
|
|
304
|
-
|
|
422
|
+
// Opener preserved (strip is OFF); the em-dash between two independent
|
|
423
|
+
// clauses becomes a full stop, not a comma (no splice).
|
|
424
|
+
expect(r.scrubbed).toBe('Exactly right. The token had expired.')
|
|
305
425
|
expect(r.openersStripped).toBe(0)
|
|
306
426
|
expect(r.replaced).toBe(1)
|
|
307
427
|
})
|