switchroom 0.13.19 → 0.13.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/telegram-plugin/dist/gateway/gateway.js +201 -31
- package/telegram-plugin/gateway/disconnect-flush.ts +37 -0
- package/telegram-plugin/gateway/gateway.ts +138 -8
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +37 -4
- package/telegram-plugin/handoff-continuity.ts +8 -2
- package/telegram-plugin/recent-outbound-dedup.ts +51 -5
- package/telegram-plugin/runtime-metrics.ts +22 -0
- package/telegram-plugin/subagent-watcher.ts +25 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +114 -0
- package/telegram-plugin/tests/handoff-continuity.test.ts +15 -2
- package/telegram-plugin/tests/inbound-delivery-gate.test.ts +77 -4
- package/telegram-plugin/tests/recent-outbound-dedup.test.ts +72 -0
- package/telegram-plugin/tests/subagent-watcher-enoent-deregister.test.ts +152 -0
- package/telegram-plugin/tests/text-voice-scrub.test.ts +174 -0
- package/telegram-plugin/text-voice-scrub.ts +199 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +72 -45
|
@@ -219,7 +219,7 @@ describe("shouldShowHandoffLine", () => {
|
|
|
219
219
|
describe("formatHandoffLine", () => {
|
|
220
220
|
it("wraps the topic in italic HTML with the return emoji", () => {
|
|
221
221
|
const line = formatHandoffLine("fixing the bug", "html");
|
|
222
|
-
expect(line).toBe("<i>↩️ Picked up where we left off
|
|
222
|
+
expect(line).toBe("<i>↩️ Picked up where we left off, fixing the bug</i>\n\n");
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
it("escapes HTML-unsafe chars in the topic", () => {
|
|
@@ -238,7 +238,7 @@ describe("formatHandoffLine", () => {
|
|
|
238
238
|
|
|
239
239
|
it("produces plain text for 'text' format", () => {
|
|
240
240
|
const line = formatHandoffLine("simple", "text");
|
|
241
|
-
expect(line).toBe("↩️ Picked up where we left off
|
|
241
|
+
expect(line).toBe("↩️ Picked up where we left off, simple\n\n");
|
|
242
242
|
});
|
|
243
243
|
|
|
244
244
|
it("always ends with a blank-line separator", () => {
|
|
@@ -246,4 +246,17 @@ describe("formatHandoffLine", () => {
|
|
|
246
246
|
expect(formatHandoffLine("t", fmt).endsWith("\n\n")).toBe(true);
|
|
247
247
|
}
|
|
248
248
|
});
|
|
249
|
+
|
|
250
|
+
// Regression guard: the handoff prefix was an em-dash bypass for the
|
|
251
|
+
// v0.13.20 voice scrubber (the framework prefix is concatenated AFTER
|
|
252
|
+
// scrubVoice runs in executeReply). Replacing the em-dash with a
|
|
253
|
+
// comma at the template source closes that leak. Pin it so a future
|
|
254
|
+
// operator who "fixes typography" doesn't re-introduce the dash.
|
|
255
|
+
it("does NOT contain an em-dash or en-dash in any format (voice-scrub guard)", () => {
|
|
256
|
+
for (const fmt of ["html", "markdownv2", "text"] as const) {
|
|
257
|
+
const line = formatHandoffLine("anything goes here", fmt);
|
|
258
|
+
expect(line).not.toContain("—");
|
|
259
|
+
expect(line).not.toContain("–");
|
|
260
|
+
}
|
|
261
|
+
});
|
|
249
262
|
});
|
|
@@ -41,13 +41,86 @@ describe('decideInboundDelivery', () => {
|
|
|
41
41
|
).toBe('deliver')
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
-
it('is total: the ONLY deferral path is mid-turn AND not steering', () => {
|
|
44
|
+
it('is total: the ONLY deferral path is mid-turn AND not steering AND not interrupt', () => {
|
|
45
45
|
for (const turnInFlight of [true, false]) {
|
|
46
46
|
for (const isSteering of [true, false]) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
for (const isInterrupt of [true, false]) {
|
|
48
|
+
const decision = decideInboundDelivery({ turnInFlight, isSteering, isInterrupt })
|
|
49
|
+
const expectBuffer = turnInFlight && !isSteering && !isInterrupt
|
|
50
|
+
expect(decision).toBe(expectBuffer ? 'buffer-until-idle' : 'deliver')
|
|
51
|
+
}
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
})
|
|
55
|
+
|
|
56
|
+
// ─── Interrupt-marker carve-out (2026-05-24 fix for the stranded-body bug) ──
|
|
57
|
+
// Live UAT trace: user fires `! actually do X` mid-turn. SIGINT delivered
|
|
58
|
+
// to claude via tmux send-keys. The killed turn does NOT emit
|
|
59
|
+
// turn_complete in many cases (mid-tool-call kill, in-flight subagent),
|
|
60
|
+
// so the post-`!` body sits in pendingInboundBuffer forever — the
|
|
61
|
+
// turn-complete drain trigger never fires. The user never gets a reply
|
|
62
|
+
// to their replacement instruction.
|
|
63
|
+
//
|
|
64
|
+
// The carve-out is a peer of isSteering: an interrupt body is by
|
|
65
|
+
// definition an intentional mid-turn delivery — the user explicitly
|
|
66
|
+
// asked for "stop and do this instead".
|
|
67
|
+
describe('interrupt-marker carve-out', () => {
|
|
68
|
+
it('delivers a `!`-interrupt body mid-turn (does NOT buffer)', () => {
|
|
69
|
+
// The headline regression fix. Without the carve-out the killed turn
|
|
70
|
+
// strands the body indefinitely.
|
|
71
|
+
expect(
|
|
72
|
+
decideInboundDelivery({
|
|
73
|
+
turnInFlight: true,
|
|
74
|
+
isSteering: false,
|
|
75
|
+
isInterrupt: true,
|
|
76
|
+
}),
|
|
77
|
+
).toBe('deliver')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('delivers a `!`-interrupt body even when claude is idle (no harm)', () => {
|
|
81
|
+
expect(
|
|
82
|
+
decideInboundDelivery({
|
|
83
|
+
turnInFlight: false,
|
|
84
|
+
isSteering: false,
|
|
85
|
+
isInterrupt: true,
|
|
86
|
+
}),
|
|
87
|
+
).toBe('deliver')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('isInterrupt is optional — omitting it preserves legacy behavior', () => {
|
|
91
|
+
// Backward-compat for callers that haven't been updated yet. Mirrors
|
|
92
|
+
// the optional-default pattern used in other gateway predicates this
|
|
93
|
+
// session (silent-reply-anchor wasOverPingSuppressed, recent-outbound-
|
|
94
|
+
// dedup turnKey).
|
|
95
|
+
expect(
|
|
96
|
+
decideInboundDelivery({ turnInFlight: true, isSteering: false }),
|
|
97
|
+
).toBe('buffer-until-idle')
|
|
98
|
+
expect(
|
|
99
|
+
decideInboundDelivery({ turnInFlight: false, isSteering: false }),
|
|
100
|
+
).toBe('deliver')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('explicit isInterrupt:false is identical to omitting it', () => {
|
|
104
|
+
expect(
|
|
105
|
+
decideInboundDelivery({
|
|
106
|
+
turnInFlight: true,
|
|
107
|
+
isSteering: false,
|
|
108
|
+
isInterrupt: false,
|
|
109
|
+
}),
|
|
110
|
+
).toBe('buffer-until-idle')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('interrupt + steering combination delivers (both are exempt paths)', () => {
|
|
114
|
+
// Pathological prompt: `! /steer change tactics`. parseInterruptMarker
|
|
115
|
+
// strips the `!`, then steering parse sees `/steer`. Either flag
|
|
116
|
+
// alone delivers; both together still deliver. No regression.
|
|
117
|
+
expect(
|
|
118
|
+
decideInboundDelivery({
|
|
119
|
+
turnInFlight: true,
|
|
120
|
+
isSteering: true,
|
|
121
|
+
isInterrupt: true,
|
|
122
|
+
}),
|
|
123
|
+
).toBe('deliver')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
53
126
|
})
|
|
@@ -190,3 +190,75 @@ describe('OutboundDedupCache — multiple entries per chat', () => {
|
|
|
190
190
|
expect(cache.check('chat', undefined, LONG_HTML, 6000)).not.toBeNull()
|
|
191
191
|
})
|
|
192
192
|
})
|
|
193
|
+
|
|
194
|
+
// ─── turnKey carve-out (2026-05-23 cross-turn-swallow fix) ───────────────────
|
|
195
|
+
// Without turnKey awareness, the 60s TTL eats the SECOND turn's reply when a
|
|
196
|
+
// user asks similar questions back-to-back (forensic audit on midturn-silent
|
|
197
|
+
// UAT). The carve-out: both sides non-null + distinct ⇒ treat as miss.
|
|
198
|
+
// Within-turn (#546 retry race) protection unchanged: same turnKey on both
|
|
199
|
+
// sides ⇒ legacy hit. Null on either side ⇒ legacy hit.
|
|
200
|
+
|
|
201
|
+
const LONG_TEXT = 'long enough text to count as content for the dedup floor'
|
|
202
|
+
|
|
203
|
+
describe('OutboundDedupCache — turnKey carve-out', () => {
|
|
204
|
+
it('cross-turn identical content with distinct non-null turnKeys MISSES', () => {
|
|
205
|
+
// The headline bug: dedup was eating user replies across turns.
|
|
206
|
+
const cache = new OutboundDedupCache()
|
|
207
|
+
cache.record('chat', undefined, LONG_TEXT, 1000, 'turn-A')
|
|
208
|
+
expect(
|
|
209
|
+
cache.check('chat', undefined, LONG_TEXT, 5000, 'turn-B'),
|
|
210
|
+
).toBeNull()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('within-turn duplicates (same turnKey) STILL HIT — preserves #546 protection', () => {
|
|
214
|
+
// Same-turn retry race the module was built for.
|
|
215
|
+
const cache = new OutboundDedupCache()
|
|
216
|
+
cache.record('chat', undefined, LONG_TEXT, 1000, 'turn-A')
|
|
217
|
+
expect(
|
|
218
|
+
cache.check('chat', undefined, LONG_TEXT, 10_000, 'turn-A'),
|
|
219
|
+
).not.toBeNull()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('record-null + check-non-null → legacy hit', () => {
|
|
223
|
+
// Boot-time / silent-marker callers pass null on record; later
|
|
224
|
+
// executeReply checks with a turnKey. Legacy match must persist
|
|
225
|
+
// for the #546 protection to cover these cross-context cases.
|
|
226
|
+
const cache = new OutboundDedupCache()
|
|
227
|
+
cache.record('chat', undefined, LONG_TEXT, 1000, null)
|
|
228
|
+
expect(
|
|
229
|
+
cache.check('chat', undefined, LONG_TEXT, 5000, 'turn-A'),
|
|
230
|
+
).not.toBeNull()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('record-non-null + check-null → legacy hit', () => {
|
|
234
|
+
// Symmetric direction: turn-flush records with turnKey, a later
|
|
235
|
+
// null-context probe (rare but possible) still matches.
|
|
236
|
+
const cache = new OutboundDedupCache()
|
|
237
|
+
cache.record('chat', undefined, LONG_TEXT, 1000, 'turn-A')
|
|
238
|
+
expect(
|
|
239
|
+
cache.check('chat', undefined, LONG_TEXT, 5000, null),
|
|
240
|
+
).not.toBeNull()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('cross-turn entry does NOT shadow a same-turn match later in the list', () => {
|
|
244
|
+
// Edge case the predicate must handle: when the scan hits a stale
|
|
245
|
+
// cross-turn entry whose hash matches, it must keep scanning past
|
|
246
|
+
// it to find a real same-turn match. (The carve-out is implemented
|
|
247
|
+
// as `continue`, not `return null`.)
|
|
248
|
+
const cache = new OutboundDedupCache()
|
|
249
|
+
cache.record('chat', undefined, LONG_TEXT, 1000, 'turn-A') // older, cross-turn
|
|
250
|
+
cache.record('chat', undefined, LONG_TEXT, 3000, 'turn-B') // newer, same turn as query
|
|
251
|
+
expect(
|
|
252
|
+
cache.check('chat', undefined, LONG_TEXT, 5000, 'turn-B'),
|
|
253
|
+
).not.toBeNull()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('legacy 4-arg API still compiles + matches (default turnKey=null)', () => {
|
|
257
|
+
// Backward-compat smoke test — older callers that haven't been
|
|
258
|
+
// updated to pass turnKey continue to behave as the original test
|
|
259
|
+
// suite pins.
|
|
260
|
+
const cache = new OutboundDedupCache()
|
|
261
|
+
cache.record('chat', undefined, LONG_TEXT, 1000)
|
|
262
|
+
expect(cache.check('chat', undefined, LONG_TEXT, 5000)).not.toBeNull()
|
|
263
|
+
})
|
|
264
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the readSubTail ENOENT/EACCES deregister path.
|
|
3
|
+
*
|
|
4
|
+
* Production symptom: clerk agent's gateway-supervisor.log was growing
|
|
5
|
+
* at ~30 ENOENT lines/sec sustained (540k+ in 3 days) because the
|
|
6
|
+
* watcher's poll loop kept statx-ing JSONL files Claude Code had
|
|
7
|
+
* already reaped along with the parent session's `subagents/` dir.
|
|
8
|
+
* Same shape on klanker with EACCES (635 events) — likely a perm
|
|
9
|
+
* flip during cleanup.
|
|
10
|
+
*
|
|
11
|
+
* Fix shape: when readSubTail's statSync throws ENOENT or EACCES,
|
|
12
|
+
* log ONE line + invoke the onFileVanished callback so the watcher
|
|
13
|
+
* factory can call cleanupTerminalAgent and stop polling. Other
|
|
14
|
+
* errors (parse, malformed JSONL) keep the legacy per-poll log line.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
18
|
+
import { readSubTail } from '../subagent-watcher.js'
|
|
19
|
+
import type { WorkerEntry } from '../subagent-watcher.js'
|
|
20
|
+
|
|
21
|
+
function fakeFsThrowingFromStat(code: 'ENOENT' | 'EACCES' | 'EOTHER') {
|
|
22
|
+
const err = new Error(`fake ${code}`) as NodeJS.ErrnoException
|
|
23
|
+
err.code = code
|
|
24
|
+
return {
|
|
25
|
+
existsSync: () => true,
|
|
26
|
+
readdirSync: () => [],
|
|
27
|
+
statSync: () => { throw err },
|
|
28
|
+
openSync: () => -1,
|
|
29
|
+
closeSync: () => {},
|
|
30
|
+
readSync: () => 0,
|
|
31
|
+
watch: () => ({ close: () => {} } as ReturnType<typeof require>),
|
|
32
|
+
} as unknown as Parameters<typeof readSubTail>[4]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeEntry(): WorkerEntry {
|
|
36
|
+
return {
|
|
37
|
+
agentId: 'a1234567890abcdef',
|
|
38
|
+
filePath: '/tmp/fake/agent-a1234567890abcdef.jsonl',
|
|
39
|
+
dispatchedAt: 0,
|
|
40
|
+
lastActivityAt: 0,
|
|
41
|
+
toolCount: 0,
|
|
42
|
+
state: 'running',
|
|
43
|
+
completionNotified: false,
|
|
44
|
+
stallNotified: false,
|
|
45
|
+
historical: false,
|
|
46
|
+
description: '',
|
|
47
|
+
lastSummaryLine: '',
|
|
48
|
+
lastResultText: '',
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeTail() {
|
|
53
|
+
return {
|
|
54
|
+
cursor: 0,
|
|
55
|
+
pendingPartial: '',
|
|
56
|
+
hasEmittedStart: false,
|
|
57
|
+
watcher: null,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('readSubTail — ENOENT/EACCES deregister', () => {
|
|
62
|
+
it('fires onFileVanished and logs ONCE on ENOENT', () => {
|
|
63
|
+
const onFileVanished = vi.fn()
|
|
64
|
+
const log = vi.fn()
|
|
65
|
+
const entry = makeEntry()
|
|
66
|
+
|
|
67
|
+
readSubTail(
|
|
68
|
+
entry,
|
|
69
|
+
makeTail(),
|
|
70
|
+
0,
|
|
71
|
+
vi.fn(),
|
|
72
|
+
fakeFsThrowingFromStat('ENOENT'),
|
|
73
|
+
log,
|
|
74
|
+
null,
|
|
75
|
+
null,
|
|
76
|
+
undefined,
|
|
77
|
+
onFileVanished,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(onFileVanished).toHaveBeenCalledTimes(1)
|
|
81
|
+
expect(onFileVanished).toHaveBeenCalledWith('a1234567890abcdef', 'ENOENT')
|
|
82
|
+
expect(log).toHaveBeenCalledTimes(1)
|
|
83
|
+
expect(log.mock.calls[0][0]).toMatch(/JSONL vanished for a1234567890abcdef \(ENOENT\) — deregistering/)
|
|
84
|
+
expect(log.mock.calls[0][0]).not.toMatch(/read error/)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('fires onFileVanished and logs ONCE on EACCES (klanker pattern)', () => {
|
|
88
|
+
const onFileVanished = vi.fn()
|
|
89
|
+
const log = vi.fn()
|
|
90
|
+
|
|
91
|
+
readSubTail(
|
|
92
|
+
makeEntry(),
|
|
93
|
+
makeTail(),
|
|
94
|
+
0,
|
|
95
|
+
vi.fn(),
|
|
96
|
+
fakeFsThrowingFromStat('EACCES'),
|
|
97
|
+
log,
|
|
98
|
+
null,
|
|
99
|
+
null,
|
|
100
|
+
undefined,
|
|
101
|
+
onFileVanished,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
expect(onFileVanished).toHaveBeenCalledTimes(1)
|
|
105
|
+
expect(onFileVanished).toHaveBeenCalledWith('a1234567890abcdef', 'EACCES')
|
|
106
|
+
expect(log).toHaveBeenCalledTimes(1)
|
|
107
|
+
expect(log.mock.calls[0][0]).toMatch(/EACCES/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('still logs the legacy "read error" for unexpected error codes', () => {
|
|
111
|
+
// Regression guard: parse errors, EIO, EBUSY, etc. must still
|
|
112
|
+
// surface their detail. Only file-vanished codes are deregistered.
|
|
113
|
+
const onFileVanished = vi.fn()
|
|
114
|
+
const log = vi.fn()
|
|
115
|
+
|
|
116
|
+
readSubTail(
|
|
117
|
+
makeEntry(),
|
|
118
|
+
makeTail(),
|
|
119
|
+
0,
|
|
120
|
+
vi.fn(),
|
|
121
|
+
fakeFsThrowingFromStat('EOTHER'),
|
|
122
|
+
log,
|
|
123
|
+
null,
|
|
124
|
+
null,
|
|
125
|
+
undefined,
|
|
126
|
+
onFileVanished,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
expect(onFileVanished).not.toHaveBeenCalled()
|
|
130
|
+
expect(log).toHaveBeenCalledTimes(1)
|
|
131
|
+
expect(log.mock.calls[0][0]).toMatch(/read error a1234567890abcdef/)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('omitting onFileVanished is safe (optional callback)', () => {
|
|
135
|
+
const log = vi.fn()
|
|
136
|
+
|
|
137
|
+
expect(() =>
|
|
138
|
+
readSubTail(
|
|
139
|
+
makeEntry(),
|
|
140
|
+
makeTail(),
|
|
141
|
+
0,
|
|
142
|
+
vi.fn(),
|
|
143
|
+
fakeFsThrowingFromStat('ENOENT'),
|
|
144
|
+
log,
|
|
145
|
+
null,
|
|
146
|
+
null,
|
|
147
|
+
undefined,
|
|
148
|
+
),
|
|
149
|
+
).not.toThrow()
|
|
150
|
+
expect(log).toHaveBeenCalledTimes(1)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit suite for #1683 text-voice-scrub.
|
|
3
|
+
*
|
|
4
|
+
* The fleet sample on 2026-05-23 showed 73% of outbound replies
|
|
5
|
+
* shipped at least one em-dash despite the SOUL.md.hbs soft rule.
|
|
6
|
+
* These tests pin the deterministic transform that the framework
|
|
7
|
+
* enforces, including the code/inline/HTML/URL preservation that
|
|
8
|
+
* keeps the scrub from mangling legitimate non-prose contexts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
12
|
+
|
|
13
|
+
import { scrubVoice } from '../text-voice-scrub.js'
|
|
14
|
+
|
|
15
|
+
describe('scrubVoice — em / en dash replacement', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
delete process.env.SWITCHROOM_DISABLE_VOICE_SCRUB
|
|
18
|
+
})
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
delete process.env.SWITCHROOM_DISABLE_VOICE_SCRUB
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('mechanical rewrite of spaced dashes', () => {
|
|
24
|
+
it('replaces a spaced em-dash before lowercase with a comma', () => {
|
|
25
|
+
const r = scrubVoice('on it — checking the calendar')
|
|
26
|
+
expect(r.scrubbed).toBe('on it, checking the calendar')
|
|
27
|
+
expect(r.replaced).toBe(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('replaces a spaced em-dash before an uppercase letter with a period', () => {
|
|
31
|
+
// The model often writes "Here's the result — Done." style.
|
|
32
|
+
const r = scrubVoice("Here's the result — Done.")
|
|
33
|
+
expect(r.scrubbed).toBe("Here's the result. Done.")
|
|
34
|
+
expect(r.replaced).toBe(1)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('handles multiple em-dashes in one sentence', () => {
|
|
38
|
+
const r = scrubVoice('one — two — three — done')
|
|
39
|
+
expect(r.scrubbed).toBe('one, two, three, done')
|
|
40
|
+
expect(r.replaced).toBe(3)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('treats en-dash (–) identically to em-dash', () => {
|
|
44
|
+
const r = scrubVoice('on it – checking the calendar')
|
|
45
|
+
expect(r.scrubbed).toBe('on it, checking the calendar')
|
|
46
|
+
expect(r.replaced).toBe(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('replaces unspaced word-dash-word as a comma', () => {
|
|
50
|
+
// Less common but seen in tightly-typed prose.
|
|
51
|
+
const r = scrubVoice('flag—on or flag—off')
|
|
52
|
+
expect(r.scrubbed).toBe('flag, on or flag, off')
|
|
53
|
+
expect(r.replaced).toBe(2)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('replaces end-of-line dashes with a period', () => {
|
|
57
|
+
const r = scrubVoice('thinking out loud —\nnext line here')
|
|
58
|
+
expect(r.scrubbed).toBe('thinking out loud.\nnext line here')
|
|
59
|
+
expect(r.replaced).toBe(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('converts a leading-dash sentence-start to ASCII hyphen', () => {
|
|
63
|
+
// Quoted-style or list-bullet em-dash at message start; falls
|
|
64
|
+
// through to the catch-all rule.
|
|
65
|
+
const r = scrubVoice('— note: ship it')
|
|
66
|
+
expect(r.scrubbed).toBe('- note: ship it')
|
|
67
|
+
expect(r.replaced).toBe(1)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('protected regions are left alone', () => {
|
|
72
|
+
it('preserves dashes inside fenced code blocks', () => {
|
|
73
|
+
const input = 'here is code:\n```bash\nfoo --bar — baz\n```\nand prose — done'
|
|
74
|
+
const r = scrubVoice(input)
|
|
75
|
+
expect(r.scrubbed).toBe(
|
|
76
|
+
'here is code:\n```bash\nfoo --bar — baz\n```\nand prose, done',
|
|
77
|
+
)
|
|
78
|
+
expect(r.replaced).toBe(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('preserves dashes inside inline code', () => {
|
|
82
|
+
const r = scrubVoice('the flag `--really — keep` matters — yes')
|
|
83
|
+
expect(r.scrubbed).toBe('the flag `--really — keep` matters, yes')
|
|
84
|
+
expect(r.replaced).toBe(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('preserves dashes inside <code> HTML tags', () => {
|
|
88
|
+
const r = scrubVoice('see <code>x — y</code> and note — ok')
|
|
89
|
+
expect(r.scrubbed).toBe('see <code>x — y</code> and note, ok')
|
|
90
|
+
expect(r.replaced).toBe(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('preserves dashes inside <pre> HTML tags', () => {
|
|
94
|
+
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, ok')
|
|
96
|
+
expect(r.replaced).toBe(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('preserves dashes inside URLs', () => {
|
|
100
|
+
const r = scrubVoice('see https://example.com/a—b for context — ok')
|
|
101
|
+
expect(r.scrubbed).toBe(
|
|
102
|
+
'see https://example.com/a—b for context, ok',
|
|
103
|
+
)
|
|
104
|
+
expect(r.replaced).toBe(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('preserves a code block containing markdown that could otherwise match', () => {
|
|
108
|
+
// The placeholder restore must put the original raw fence back,
|
|
109
|
+
// not a transformed copy.
|
|
110
|
+
const fence =
|
|
111
|
+
'```\n# heading — title\nfunction f() {}\n```'
|
|
112
|
+
const r = scrubVoice(fence + '\ntrailing — yes')
|
|
113
|
+
expect(r.scrubbed).toBe(fence + '\ntrailing, yes')
|
|
114
|
+
expect(r.replaced).toBe(1)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('no-op cases', () => {
|
|
119
|
+
it('returns identity (same string, replaced=0) when input has no dashes', () => {
|
|
120
|
+
const input = 'no dashes anywhere, just commas and periods.'
|
|
121
|
+
const r = scrubVoice(input)
|
|
122
|
+
expect(r.scrubbed).toBe(input)
|
|
123
|
+
expect(r.replaced).toBe(0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('returns identity when input is empty', () => {
|
|
127
|
+
const r = scrubVoice('')
|
|
128
|
+
expect(r.scrubbed).toBe('')
|
|
129
|
+
expect(r.replaced).toBe(0)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('kill switch (SWITCHROOM_DISABLE_VOICE_SCRUB=1) returns input unchanged', () => {
|
|
133
|
+
process.env.SWITCHROOM_DISABLE_VOICE_SCRUB = '1'
|
|
134
|
+
const r = scrubVoice('on it — checking')
|
|
135
|
+
expect(r.scrubbed).toBe('on it — checking')
|
|
136
|
+
expect(r.replaced).toBe(0)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('kill switch accepts "true" as well as "1"', () => {
|
|
140
|
+
process.env.SWITCHROOM_DISABLE_VOICE_SCRUB = 'true'
|
|
141
|
+
const r = scrubVoice('on it — checking')
|
|
142
|
+
expect(r.scrubbed).toBe('on it — checking')
|
|
143
|
+
expect(r.replaced).toBe(0)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('realistic fleet samples', () => {
|
|
148
|
+
it('scrubs a multi-step status message', () => {
|
|
149
|
+
const input =
|
|
150
|
+
"I'll check the calendar — should take a few seconds. " +
|
|
151
|
+
'Result: empty for Saturday — nothing scheduled. Anything else?'
|
|
152
|
+
const r = scrubVoice(input)
|
|
153
|
+
expect(r.scrubbed).toBe(
|
|
154
|
+
"I'll check the calendar, should take a few seconds. " +
|
|
155
|
+
'Result: empty for Saturday, nothing scheduled. Anything else?',
|
|
156
|
+
)
|
|
157
|
+
expect(r.replaced).toBe(2)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('mixed prose and code keeps the code untouched', () => {
|
|
161
|
+
const input =
|
|
162
|
+
'Running `git status --short` — looks clean. ' +
|
|
163
|
+
'```\nM file.ts — modified\n```\n' +
|
|
164
|
+
'Ready to commit — go?'
|
|
165
|
+
const r = scrubVoice(input)
|
|
166
|
+
expect(r.scrubbed).toBe(
|
|
167
|
+
'Running `git status --short`, looks clean. ' +
|
|
168
|
+
'```\nM file.ts — modified\n```\n' +
|
|
169
|
+
'Ready to commit, go?',
|
|
170
|
+
)
|
|
171
|
+
expect(r.replaced).toBe(2)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
})
|