switchroom 0.18.19 → 0.18.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/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-paint independence (#3231) regression.
|
|
3
|
+
*
|
|
4
|
+
* LIVE BUG (a57fbf, 2026-07-13): an async foreground sub-agent registered at
|
|
5
|
+
* 13:17:27, did two Bash calls, then its first tool BLOCKED for ~99s (no JSONL
|
|
6
|
+
* growth). Its spawning turn ended at 13:17:48 while it kept running, so there
|
|
7
|
+
* was neither a live parent turn to nest into NOR a worker-feed row to paint —
|
|
8
|
+
* and because the worker card is driven ONLY by growth-triggered onProgress
|
|
9
|
+
* cues, NOTHING surfaced. The card did not appear until 13:20:52, 205s after
|
|
10
|
+
* registration, on the next growth event that happened to route to the feed.
|
|
11
|
+
*
|
|
12
|
+
* The watcher's contract fix: a running, non-historical entry must emit a
|
|
13
|
+
* growth-INDEPENDENT skeleton liveness cue on every no-growth poll — an empty
|
|
14
|
+
* step line carrying the entry's real current state — so the gateway can paint
|
|
15
|
+
* (and keep alive) the card from registration onward, regardless of whether the
|
|
16
|
+
* worker's JSONL is currently growing. The gateway routes it: inert on the
|
|
17
|
+
* foreground-nest path (empty child), row-creating on the orphan/background
|
|
18
|
+
* worker-feed path.
|
|
19
|
+
*
|
|
20
|
+
* These assert OUTCOMES on the onProgress cue stream with an INJECTED clock.
|
|
21
|
+
* A skeleton cue is identified by its explicit `skeleton: true` discriminator
|
|
22
|
+
* (it also carries an empty `latestSummary` and no `progressLine`).
|
|
23
|
+
*
|
|
24
|
+
* RED-ON-REGRESSION: before the fix, readSubTail early-returns on a no-growth
|
|
25
|
+
* poll BEFORE firing any onProgress cue, so ZERO skeleton cues are emitted and
|
|
26
|
+
* every assertion below fails — reproducing the invisible-card window.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
30
|
+
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'fs'
|
|
31
|
+
import { tmpdir } from 'os'
|
|
32
|
+
import { join } from 'path'
|
|
33
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
34
|
+
|
|
35
|
+
function buildJSONL(...lines: object[]): string {
|
|
36
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
37
|
+
}
|
|
38
|
+
function subAgentUserMsg(promptText: string) {
|
|
39
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
40
|
+
}
|
|
41
|
+
function subAgentToolUse(name: string, id: string) {
|
|
42
|
+
return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Cue { agentId: string; progressLine?: string; latestSummary: string; elapsedMs: number; skeleton?: boolean }
|
|
46
|
+
|
|
47
|
+
describe('sub-agent card first-paint independence (#3231)', () => {
|
|
48
|
+
let tmpRoot = ''
|
|
49
|
+
const started: Array<ReturnType<typeof startSubagentWatcher>> = []
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
while (started.length) {
|
|
53
|
+
try { started.pop()?.stop() } catch { /* ignore */ }
|
|
54
|
+
}
|
|
55
|
+
if (tmpRoot) {
|
|
56
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
57
|
+
tmpRoot = ''
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const RESCAN_MS = 1000
|
|
62
|
+
|
|
63
|
+
function startWatcher(agentDir: string) {
|
|
64
|
+
let currentTime = 100_000
|
|
65
|
+
const cues: Cue[] = []
|
|
66
|
+
const intervals: Array<{ fn: () => void; ref: number }> = []
|
|
67
|
+
let nextRef = 1
|
|
68
|
+
const watcher = startSubagentWatcher({
|
|
69
|
+
agentDir,
|
|
70
|
+
onFinish: () => {},
|
|
71
|
+
onProgress: ({ agentId, progressLine, latestSummary, elapsedMs, skeleton }) => {
|
|
72
|
+
cues.push({ agentId, progressLine, latestSummary, elapsedMs, skeleton })
|
|
73
|
+
},
|
|
74
|
+
stallThresholdMs: 600_000,
|
|
75
|
+
silentSynthesisStallThresholdMs: 600_000,
|
|
76
|
+
rescanMs: RESCAN_MS,
|
|
77
|
+
now: () => currentTime,
|
|
78
|
+
setInterval: (fn) => { const ref = nextRef++; intervals.push({ fn, ref }); return { ref } },
|
|
79
|
+
clearInterval: (handle) => {
|
|
80
|
+
const { ref } = handle as { ref: number }
|
|
81
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
82
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
83
|
+
},
|
|
84
|
+
setTimeout: () => ({ ref: nextRef++ }),
|
|
85
|
+
clearTimeout: () => {},
|
|
86
|
+
log: () => {},
|
|
87
|
+
})
|
|
88
|
+
started.push(watcher)
|
|
89
|
+
return {
|
|
90
|
+
watcher,
|
|
91
|
+
cues,
|
|
92
|
+
poll: () => intervals[0]?.fn(),
|
|
93
|
+
advance: (ms: number) => { currentTime += ms },
|
|
94
|
+
now: () => currentTime,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const skeletonCues = (cues: Cue[]): Cue[] =>
|
|
99
|
+
cues.filter((c) => c.skeleton === true)
|
|
100
|
+
|
|
101
|
+
function makeSubagentDir(root: string): string {
|
|
102
|
+
const agentDir = join(root, 'agent')
|
|
103
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
104
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
105
|
+
return agentDir
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it('emits a growth-independent skeleton cue on the first no-growth poll after registration', () => {
|
|
109
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-firstpaint-'))
|
|
110
|
+
const agentDir = makeSubagentDir(tmpRoot)
|
|
111
|
+
const jsonlPath = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents', 'agent-deadbeef.jsonl')
|
|
112
|
+
|
|
113
|
+
// Start the watcher on an empty subagents dir, THEN the worker spawns — the
|
|
114
|
+
// real async Agent-tool path (the JSONL appears post-boot, so the entry is
|
|
115
|
+
// live/non-historical, not a boot-time rediscovery). Only its prompt is on
|
|
116
|
+
// disk, no assistant output yet (it is "thinking"): the pre-content window
|
|
117
|
+
// the user stares at.
|
|
118
|
+
const h = startWatcher(agentDir)
|
|
119
|
+
h.poll() // boot scan over the empty dir
|
|
120
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
121
|
+
h.advance(RESCAN_MS)
|
|
122
|
+
h.poll() // discover + register as a live worker
|
|
123
|
+
|
|
124
|
+
// Next poll: the JSONL has NOT grown. Pre-fix, readSubTail early-returns and
|
|
125
|
+
// NO cue fires — the card is invisible. Post-fix, a skeleton cue surfaces so
|
|
126
|
+
// the gateway can paint the card without waiting for the worker's output.
|
|
127
|
+
h.advance(RESCAN_MS)
|
|
128
|
+
h.poll()
|
|
129
|
+
|
|
130
|
+
const skel = skeletonCues(h.cues)
|
|
131
|
+
expect(skel.length, 'a skeleton cue must fire on a no-growth poll').toBeGreaterThanOrEqual(1)
|
|
132
|
+
// Tight bound vs the ~205s live behaviour: first cue is within a couple polls
|
|
133
|
+
// of registration, NOT minutes.
|
|
134
|
+
expect(skel[0].elapsedMs).toBeLessThanOrEqual(2 * RESCAN_MS)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('keeps surfacing skeleton cues while a worker is silent after an early tool burst (blocked first tool)', () => {
|
|
138
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-firstpaint-silent-'))
|
|
139
|
+
const agentDir = makeSubagentDir(tmpRoot)
|
|
140
|
+
const jsonlPath = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents', 'agent-a57fbf00.jsonl')
|
|
141
|
+
|
|
142
|
+
const h = startWatcher(agentDir)
|
|
143
|
+
h.poll() // boot scan over the empty dir
|
|
144
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Debug delegation regression')))
|
|
145
|
+
h.advance(RESCAN_MS)
|
|
146
|
+
h.poll() // discover + register as a live worker
|
|
147
|
+
|
|
148
|
+
// The worker does two quick Bash calls (the a57fbf shape) …
|
|
149
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
|
|
150
|
+
h.advance(RESCAN_MS)
|
|
151
|
+
h.poll()
|
|
152
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b2')))
|
|
153
|
+
h.advance(RESCAN_MS)
|
|
154
|
+
h.poll()
|
|
155
|
+
|
|
156
|
+
// … then its tool BLOCKS: no JSONL growth for a long stretch (~30 polls).
|
|
157
|
+
// The card must NOT go dark — a skeleton cue must fire on essentially every
|
|
158
|
+
// no-growth poll so the feed row is created/kept-alive and the heartbeat can
|
|
159
|
+
// climb the elapsed. Pre-fix, zero cues fire across the entire silent window.
|
|
160
|
+
const before = h.cues.length
|
|
161
|
+
for (let i = 0; i < 30; i++) {
|
|
162
|
+
h.advance(RESCAN_MS)
|
|
163
|
+
h.poll()
|
|
164
|
+
}
|
|
165
|
+
const duringSilence = h.cues.slice(before)
|
|
166
|
+
const skel = duringSilence.filter((c) => c.skeleton === true)
|
|
167
|
+
expect(skel.length, 'silent worker must keep emitting skeleton cues').toBeGreaterThanOrEqual(20)
|
|
168
|
+
// No skeleton cue ever fabricates content — the step line stays empty.
|
|
169
|
+
for (const c of skel) expect(c.latestSummary).toBe('')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Residual A regression: a worker / sub-agent's OPENING narration must paint
|
|
3
|
+
* EARLY on its card (~the next poll after PENDING_NARRATIVE_FLUSH_MS) WITHOUT
|
|
4
|
+
* waiting for the worker's first tool — the same time-box the main-agent card
|
|
5
|
+
* got in #3227, applied uniformly to the poll-driven worker/sub-agent path via
|
|
6
|
+
* the SHARED NarrativeFlushController kernel driven by the injected poll clock.
|
|
7
|
+
*
|
|
8
|
+
* These assert OUTCOMES on the onProgress cue stream (the worker card is
|
|
9
|
+
* replace-on-write via onProgress), driven by an INJECTED clock so the timing
|
|
10
|
+
* is deterministic:
|
|
11
|
+
* - a parked opening narration fires a narrative cue after the flush window
|
|
12
|
+
* with NO tool event (RED if the early-paint tick is removed);
|
|
13
|
+
* - it fires at more than one entry (depth-generic — the registry is flat, so
|
|
14
|
+
* every sub-agent/worker/nested sub-worker is a WorkerEntry and the gate is
|
|
15
|
+
* per-entry);
|
|
16
|
+
* - the early-painted block is NOT re-fired (no double-print) and does not
|
|
17
|
+
* leave a stale duplicate when the first real tool step lands after it;
|
|
18
|
+
* - a tool arriving WITHIN the window still resolves the block exactly once
|
|
19
|
+
* (no early-paint + resolve double).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
23
|
+
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'fs'
|
|
24
|
+
import { tmpdir } from 'os'
|
|
25
|
+
import { join } from 'path'
|
|
26
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
27
|
+
import { PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
|
|
28
|
+
|
|
29
|
+
function buildJSONL(...lines: object[]): string {
|
|
30
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
31
|
+
}
|
|
32
|
+
function subAgentUserMsg(promptText: string) {
|
|
33
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
34
|
+
}
|
|
35
|
+
function subAgentAssistantText(text: string) {
|
|
36
|
+
return { type: 'assistant', message: { content: [{ type: 'text', text }] } }
|
|
37
|
+
}
|
|
38
|
+
function subAgentToolUse(name: string, id: string) {
|
|
39
|
+
return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Cue { progressLine?: string; latestSummary: string; skeleton?: boolean }
|
|
43
|
+
|
|
44
|
+
describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
|
|
45
|
+
let tmpRoot = ''
|
|
46
|
+
const started: Array<ReturnType<typeof startSubagentWatcher>> = []
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
while (started.length) {
|
|
50
|
+
try { started.pop()?.stop() } catch { /* ignore */ }
|
|
51
|
+
}
|
|
52
|
+
if (tmpRoot) {
|
|
53
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
54
|
+
tmpRoot = ''
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/** Watcher over a real tmpdir with an INJECTED, manually-advanced clock. */
|
|
59
|
+
function startWatcher(agentDir: string) {
|
|
60
|
+
let currentTime = 100_000
|
|
61
|
+
const cues: Array<{ agentId: string } & Cue> = []
|
|
62
|
+
const intervals: Array<{ fn: () => void; ref: number }> = []
|
|
63
|
+
let nextRef = 1
|
|
64
|
+
const watcher = startSubagentWatcher({
|
|
65
|
+
agentDir,
|
|
66
|
+
onFinish: () => {},
|
|
67
|
+
onProgress: ({ agentId, progressLine, latestSummary, skeleton }) => {
|
|
68
|
+
cues.push({ agentId, progressLine, latestSummary, skeleton })
|
|
69
|
+
},
|
|
70
|
+
stallThresholdMs: 600_000,
|
|
71
|
+
silentSynthesisStallThresholdMs: 600_000,
|
|
72
|
+
rescanMs: 1000,
|
|
73
|
+
now: () => currentTime,
|
|
74
|
+
setInterval: (fn) => { const ref = nextRef++; intervals.push({ fn, ref }); return { ref } },
|
|
75
|
+
clearInterval: (handle) => {
|
|
76
|
+
const { ref } = handle as { ref: number }
|
|
77
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
78
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
79
|
+
},
|
|
80
|
+
setTimeout: () => ({ ref: nextRef++ }),
|
|
81
|
+
clearTimeout: () => {},
|
|
82
|
+
log: () => {},
|
|
83
|
+
})
|
|
84
|
+
started.push(watcher)
|
|
85
|
+
return {
|
|
86
|
+
watcher,
|
|
87
|
+
cues,
|
|
88
|
+
poll: () => intervals[0]?.fn(),
|
|
89
|
+
advance: (ms: number) => { currentTime += ms },
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function narrativeCues(cues: Cue[]): string[] {
|
|
94
|
+
// Narrative cues carry NO progressLine; tool-label cues do. Skeleton
|
|
95
|
+
// liveness cues (#3231) also carry no progressLine but an empty summary —
|
|
96
|
+
// exclude them here so this counts only real narrative content.
|
|
97
|
+
return cues.filter((c) => !c.skeleton && c.progressLine == null).map((c) => c.latestSummary)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('paints a parked opening narration after the flush window with NO tool event', () => {
|
|
101
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-'))
|
|
102
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
103
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
104
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
105
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
106
|
+
|
|
107
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
108
|
+
const h = startWatcher(agentDir)
|
|
109
|
+
h.poll() // register + promote to live
|
|
110
|
+
|
|
111
|
+
// Opening narration arrives; the worker then "thinks" (no tool yet).
|
|
112
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — pulling the logs first.')))
|
|
113
|
+
h.poll() // parks the block, arms the early-paint timer — NO cue yet
|
|
114
|
+
expect(narrativeCues(h.cues), 'parked block must not paint before the window').toHaveLength(0)
|
|
115
|
+
|
|
116
|
+
// Advance past the flush window and poll again — STILL no new jsonl event.
|
|
117
|
+
h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
|
|
118
|
+
h.poll()
|
|
119
|
+
|
|
120
|
+
const narr = narrativeCues(h.cues)
|
|
121
|
+
expect(narr, 'opening narration must early-paint without a tool').toHaveLength(1)
|
|
122
|
+
expect(narr[0]).toContain('pulling the logs')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('does NOT paint before the flush window elapses', () => {
|
|
126
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-nofire-'))
|
|
127
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
128
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
129
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
130
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
131
|
+
|
|
132
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
133
|
+
const h = startWatcher(agentDir)
|
|
134
|
+
h.poll()
|
|
135
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — pulling the logs first.')))
|
|
136
|
+
h.poll()
|
|
137
|
+
|
|
138
|
+
// Advance LESS than the window — the tick must not fire yet.
|
|
139
|
+
h.advance(PENDING_NARRATIVE_FLUSH_MS - 50)
|
|
140
|
+
h.poll()
|
|
141
|
+
expect(narrativeCues(h.cues), 'must not paint before the window').toHaveLength(0)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('is depth-generic: two independent entries each early-paint their opening narration', () => {
|
|
145
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-depth-'))
|
|
146
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
147
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
148
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
149
|
+
// Two entries stand in for two nesting levels: the registry is FLAT, so a
|
|
150
|
+
// top worker and a nested depth-2 sub-worker are both plain WorkerEntry
|
|
151
|
+
// rows and take the identical per-entry gate — this proves the mechanism
|
|
152
|
+
// is depth-generic (it operates on the generic node, not a fixed level).
|
|
153
|
+
const aPath = join(subagentsDir, 'agent-aaaa0000.jsonl')
|
|
154
|
+
const bPath = join(subagentsDir, 'agent-bbbb1111.jsonl')
|
|
155
|
+
writeFileSync(aPath, buildJSONL(subAgentUserMsg('Level 1 task')))
|
|
156
|
+
writeFileSync(bPath, buildJSONL(subAgentUserMsg('Level 2 task')))
|
|
157
|
+
const h = startWatcher(agentDir)
|
|
158
|
+
h.poll()
|
|
159
|
+
appendFileSync(aPath, buildJSONL(subAgentAssistantText('Worker A: reading the config.')))
|
|
160
|
+
appendFileSync(bPath, buildJSONL(subAgentAssistantText('Worker B: cloning the repo.')))
|
|
161
|
+
h.poll()
|
|
162
|
+
expect(narrativeCues(h.cues)).toHaveLength(0)
|
|
163
|
+
|
|
164
|
+
h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
|
|
165
|
+
h.poll()
|
|
166
|
+
|
|
167
|
+
const aCues = h.cues.filter((c) => c.agentId === 'aaaa0000' && c.progressLine == null)
|
|
168
|
+
const bCues = h.cues.filter((c) => c.agentId === 'bbbb1111' && c.progressLine == null)
|
|
169
|
+
expect(aCues.map((c) => c.latestSummary).join(' ')).toContain('reading the config')
|
|
170
|
+
expect(bCues.map((c) => c.latestSummary).join(' ')).toContain('cloning the repo')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('does not double-print: an early-painted block is not re-shown when the first real tool lands', () => {
|
|
174
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-nodup-'))
|
|
175
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
176
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
177
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
178
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
179
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
180
|
+
const h = startWatcher(agentDir)
|
|
181
|
+
h.poll()
|
|
182
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — pulling the logs first.')))
|
|
183
|
+
h.poll()
|
|
184
|
+
h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
|
|
185
|
+
h.poll() // early-paint fires (1 narrative cue)
|
|
186
|
+
expect(narrativeCues(h.cues)).toHaveLength(1)
|
|
187
|
+
|
|
188
|
+
// Now the worker's first real tool lands. The parked block was already
|
|
189
|
+
// consumed by the timer → it must NOT re-fire as a narrative cue, and the
|
|
190
|
+
// tool-label cue is a SEPARATE step (no stale duplicate narration).
|
|
191
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
|
|
192
|
+
h.advance(10)
|
|
193
|
+
h.poll()
|
|
194
|
+
|
|
195
|
+
expect(narrativeCues(h.cues), 'narration must not be re-shown').toHaveLength(1)
|
|
196
|
+
const toolCues = h.cues.filter((c) => c.progressLine != null)
|
|
197
|
+
expect(toolCues.length, 'the tool step fires as its own cue').toBeGreaterThanOrEqual(1)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('a tool arriving WITHIN the window resolves the block exactly once (no early-paint + resolve double)', () => {
|
|
201
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-within-'))
|
|
202
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
203
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
204
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
205
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
206
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
207
|
+
const h = startWatcher(agentDir)
|
|
208
|
+
h.poll()
|
|
209
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — let me find the repo.')))
|
|
210
|
+
h.poll()
|
|
211
|
+
// Tool lands well within the window → resolve shows it once; timer never fires.
|
|
212
|
+
h.advance(50)
|
|
213
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
|
|
214
|
+
h.poll()
|
|
215
|
+
// Advance past the window: the (already-consumed) block must not re-paint.
|
|
216
|
+
h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
|
|
217
|
+
h.poll()
|
|
218
|
+
expect(narrativeCues(h.cues), 'resolved narration shows exactly once').toHaveLength(1)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
@@ -584,9 +584,10 @@ describe('startSubagentWatcher', () => {
|
|
|
584
584
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
585
585
|
const h = startWatcherSync({
|
|
586
586
|
agentDir,
|
|
587
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
587
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
588
588
|
// Narrative ticks carry NO progressLine (tool ticks do); record them.
|
|
589
|
-
|
|
589
|
+
// Skeleton liveness cues (#3231) are not narrative — exclude them.
|
|
590
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
590
591
|
},
|
|
591
592
|
})
|
|
592
593
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo path')))
|
|
@@ -614,8 +615,8 @@ describe('startSubagentWatcher', () => {
|
|
|
614
615
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
615
616
|
const h = startWatcherSync({
|
|
616
617
|
agentDir,
|
|
617
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
618
|
-
if (progressLine == null) narrativeCues.push(latestSummary)
|
|
618
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
619
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
619
620
|
},
|
|
620
621
|
})
|
|
621
622
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
|
|
@@ -647,8 +648,8 @@ describe('startSubagentWatcher', () => {
|
|
|
647
648
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
648
649
|
const h = startWatcherSync({
|
|
649
650
|
agentDir,
|
|
650
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
651
|
-
allCues.push({ progressLine, latestSummary })
|
|
651
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
652
|
+
if (!skeleton) allCues.push({ progressLine, latestSummary })
|
|
652
653
|
},
|
|
653
654
|
})
|
|
654
655
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
|
|
@@ -698,8 +699,8 @@ describe('startSubagentWatcher', () => {
|
|
|
698
699
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
699
700
|
const h = startWatcherSync({
|
|
700
701
|
agentDir,
|
|
701
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
702
|
-
if (progressLine == null) narrativeCues.push(latestSummary)
|
|
702
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
703
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
703
704
|
},
|
|
704
705
|
})
|
|
705
706
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
@@ -728,8 +729,8 @@ describe('startSubagentWatcher', () => {
|
|
|
728
729
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
729
730
|
const h = startWatcherSync({
|
|
730
731
|
agentDir,
|
|
731
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
732
|
-
if (progressLine == null) narrativeCues.push(latestSummary)
|
|
732
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
733
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
733
734
|
},
|
|
734
735
|
})
|
|
735
736
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
|
|
@@ -761,8 +762,8 @@ describe('startSubagentWatcher', () => {
|
|
|
761
762
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
762
763
|
const h = startWatcherSync({
|
|
763
764
|
agentDir,
|
|
764
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
765
|
-
if (progressLine == null) narrativeCues.push(latestSummary)
|
|
765
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
766
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
766
767
|
},
|
|
767
768
|
})
|
|
768
769
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
|
|
@@ -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
|
|
|
@@ -185,56 +187,67 @@ describe('splitMarkdownChunks', () => {
|
|
|
185
187
|
})
|
|
186
188
|
|
|
187
189
|
// -------------------------------------------------------------------------
|
|
188
|
-
// Chunk-boundary
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
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).
|
|
192
194
|
// -------------------------------------------------------------------------
|
|
193
195
|
|
|
194
|
-
test('no chunk starts or ends with a bare
|
|
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.
|
|
195
198
|
const A = 'Alpha sentence one'
|
|
196
199
|
const B = 'Bravo sentence two'
|
|
197
|
-
const
|
|
198
|
-
const chunks = splitMarkdownChunks(
|
|
200
|
+
const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
|
|
201
|
+
const chunks = splitMarkdownChunks(spaced, 33)
|
|
199
202
|
expect(chunks.length).toBeGreaterThan(1)
|
|
200
|
-
const
|
|
203
|
+
const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
|
|
201
204
|
for (const c of chunks) {
|
|
202
205
|
const lines = c.split('\n')
|
|
203
|
-
expect(
|
|
204
|
-
expect(
|
|
206
|
+
expect(spacerOnly.test(lines[0])).toBe(false)
|
|
207
|
+
expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
|
|
205
208
|
}
|
|
206
209
|
})
|
|
207
210
|
|
|
208
211
|
test('visible paragraph content survives the boundary (no text dropped)', () => {
|
|
209
212
|
const A = 'Alpha sentence one'
|
|
210
213
|
const B = 'Bravo sentence two'
|
|
211
|
-
const
|
|
212
|
-
const chunks = splitMarkdownChunks(
|
|
214
|
+
const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
|
|
215
|
+
const chunks = splitMarkdownChunks(spaced, 33)
|
|
213
216
|
const rejoined = chunks.join('\n')
|
|
214
217
|
expect(rejoined).toContain(`${A}.`)
|
|
215
218
|
expect(rejoined).toContain(`${B}.`)
|
|
216
219
|
})
|
|
217
220
|
|
|
218
|
-
test('
|
|
221
|
+
test('spacer-boundary strip is robust across several gaps and small caps', () => {
|
|
219
222
|
const paras = Array.from({ length: 6 }, (_, i) => `Paragraph ${i} body text here.`)
|
|
220
|
-
const
|
|
221
|
-
const
|
|
223
|
+
const spaced = addParagraphSpacers(paras.join('\n\n'))
|
|
224
|
+
const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
|
|
222
225
|
for (const cap of [20, 31, 40, 64]) {
|
|
223
|
-
const chunks = splitMarkdownChunks(
|
|
226
|
+
const chunks = splitMarkdownChunks(spaced, cap)
|
|
224
227
|
for (const c of chunks) {
|
|
225
228
|
const lines = c.split('\n')
|
|
226
|
-
expect(
|
|
227
|
-
expect(
|
|
229
|
+
expect(spacerOnly.test(lines[0])).toBe(false)
|
|
230
|
+
expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
|
|
228
231
|
}
|
|
229
|
-
// No visible word is dropped: concatenating the chunks' non-blank
|
|
230
|
-
// reproduces the original word sequence. (Word-level,
|
|
231
|
-
// because a small cap may split mid-word —
|
|
232
|
-
// space-boundary behaviour.)
|
|
233
|
-
const words = (s: string): string[] =>
|
|
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)
|
|
234
238
|
expect(words(chunks.join(' '))).toEqual(words(paras.join(' ')))
|
|
235
239
|
}
|
|
236
240
|
})
|
|
237
241
|
|
|
242
|
+
test('a boundary with NO spacer is unaffected (legacy ^\\n+ behaviour preserved)', () => {
|
|
243
|
+
const text = Array.from({ length: 10 }, (_, i) => `plain line ${i}`).join('\n\n')
|
|
244
|
+
const chunks = splitMarkdownChunks(text, 40)
|
|
245
|
+
// No spacer was ever present, so no chunk gains/loses anything beyond the
|
|
246
|
+
// normal leading-newline strip; content is preserved.
|
|
247
|
+
const rejoined = chunks.join('\n').replace(/\n+/g, '\n')
|
|
248
|
+
expect(rejoined).toBe(text.replace(/\n+/g, '\n'))
|
|
249
|
+
})
|
|
250
|
+
|
|
238
251
|
// -------------------------------------------------------------------------
|
|
239
252
|
// Entity-aware chunking (#finding-3): a cut must never bisect an inline
|
|
240
253
|
// span (`**bold**`, `` `code` ``, `_italic_`, `[label](href)`), which would
|
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
normalizeParagraphBreaks,
|
|
32
32
|
normalizePunctuation,
|
|
33
33
|
stripExcessBold,
|
|
34
|
+
addParagraphSpacers,
|
|
34
35
|
splitMarkdownChunks,
|
|
36
|
+
PARAGRAPH_SPACER,
|
|
35
37
|
RICH_MESSAGE_MAX_CHARS,
|
|
36
38
|
} from '../format.js'
|
|
37
39
|
|
|
@@ -128,9 +130,7 @@ describe('decideTurnFlush — prose+trailing-sentinel is suppressed, not leaked
|
|
|
128
130
|
// the real gateway turn-flush render pipeline (post-#2669 rich-markdown path):
|
|
129
131
|
// decideTurnFlush -> join('\n\n')
|
|
130
132
|
// -> repairEscapedWhitespace -> normalizeParagraphBreaks
|
|
131
|
-
// -> splitMarkdownChunks -> sendRichMessage
|
|
132
|
-
// (no paragraph-spacer pass — the NBSP spacer was removed in the #2669
|
|
133
|
-
// follow-up; gaps are plain `\n\n`, one visible blank line)
|
|
133
|
+
// -> addParagraphSpacers -> splitMarkdownChunks -> sendRichMessage
|
|
134
134
|
// so it pins the end-to-end fix, not just the pure decision. The corpus is a
|
|
135
135
|
// REAL captured-transcript shape (three separate content[i].text blocks, one
|
|
136
136
|
// stored UNTRIMMED with a trailing '\n' exactly as session-tail.ts pushes
|
|
@@ -156,7 +156,8 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
|
|
|
156
156
|
const d = decideTurnFlush({ chatId: '12345', replyCalled: false, capturedText: blocks })
|
|
157
157
|
expect(d.kind).toBe('flush')
|
|
158
158
|
const joined = (d as { kind: 'flush'; text: string }).text
|
|
159
|
-
|
|
159
|
+
const normalized = normalizeParagraphBreaks(repairEscapedWhitespace(joined))
|
|
160
|
+
return addParagraphSpacers(normalized)
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
it('separates whole blocks with a visible paragraph gap, not a wall-of-text', () => {
|
|
@@ -166,29 +167,32 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
|
|
|
166
167
|
expect(out).toContain('The `auth` handler looks correct')
|
|
167
168
|
expect(out).toContain('Want me to open a PR')
|
|
168
169
|
// The wall-of-text failure mode glues two blocks one '\n' apart. Assert the
|
|
169
|
-
// boundary carries a real paragraph break
|
|
170
|
-
//
|
|
171
|
-
expect(out).toContain(
|
|
170
|
+
// boundary carries a real paragraph break with the injected visible spacer
|
|
171
|
+
// line (#2692 rich-path spacer), and NOT a single-newline join.
|
|
172
|
+
expect(out).toContain(`\n\n${PARAGRAPH_SPACER}\n\n`)
|
|
172
173
|
expect(out).not.toContain('flagged.\nThe `auth`')
|
|
173
|
-
//
|
|
174
|
-
|
|
174
|
+
// A spacer sits specifically between block 1 and block 2.
|
|
175
|
+
const b1 = out.indexOf('flagged.')
|
|
176
|
+
const b2 = out.indexOf('The `auth` handler')
|
|
177
|
+
expect(out.slice(b1, b2)).toContain(PARAGRAPH_SPACER)
|
|
175
178
|
})
|
|
176
179
|
|
|
177
180
|
it('collapses the untrimmed-trailing-newline stack — no 3+ newline run reaches the wire', () => {
|
|
178
181
|
const out = renderLikeTurnFlush(realBlocks)
|
|
179
182
|
// Block 2's trailing '\n' + the '\n\n' join = 3 newlines; normalize
|
|
180
|
-
// collapses 3+ runs to '\n\n'
|
|
183
|
+
// collapses 3+ runs to '\n\n' and addParagraphSpacers wedges exactly one
|
|
184
|
+
// spacer, so no doubled/stacked blank run survives.
|
|
181
185
|
expect(out).not.toMatch(/\n{3,}/)
|
|
182
|
-
// One
|
|
183
|
-
const
|
|
184
|
-
expect(
|
|
186
|
+
// One spacer per block transition: 3 blocks → 2 gaps → 2 spacers.
|
|
187
|
+
const spacerCount = out.split(PARAGRAPH_SPACER).length - 1
|
|
188
|
+
expect(spacerCount).toBe(2)
|
|
185
189
|
})
|
|
186
190
|
|
|
187
191
|
it('the whole separated answer stays in one rich chunk here (well under 32768)', () => {
|
|
188
192
|
const out = renderLikeTurnFlush(realBlocks)
|
|
189
193
|
const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
|
|
190
194
|
expect(chunks.length).toBe(1)
|
|
191
|
-
expect(chunks[0]).toContain(
|
|
195
|
+
expect(chunks[0]).toContain(PARAGRAPH_SPACER)
|
|
192
196
|
})
|
|
193
197
|
|
|
194
198
|
it('still SUPPRESSES a real transcript that deliberately terminates with a bare NO_REPLY (#2053 guard intact)', () => {
|
|
@@ -224,9 +228,9 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
|
|
|
224
228
|
// runs (gateway executeReply):
|
|
225
229
|
// repairEscapedWhitespace -> normalizeParagraphBreaks -> redactOutboundText
|
|
226
230
|
// -> stripExcessBold(normalizePunctuation) -> scrubVoice
|
|
227
|
-
// (
|
|
231
|
+
// -> addParagraphSpacers (send side)
|
|
228
232
|
// The original #2798 change gave turn-flush the paragraph steps + redact +
|
|
229
|
-
// scrub but OMITTED `stripExcessBold(normalizePunctuation(...))`.
|
|
233
|
+
// scrub + spacers but OMITTED `stripExcessBold(normalizePunctuation(...))`.
|
|
230
234
|
// This suite reconstructs the deterministic format chain of BOTH paths (the
|
|
231
235
|
// runtime-only redact + voice-scrub steps are literally the same calls on both
|
|
232
236
|
// paths and are out of scope here) and pins that turn-flush now matches reply
|
|
@@ -308,7 +312,7 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
|
|
|
308
312
|
function formatChain(text: string): string {
|
|
309
313
|
let t = normalizeParagraphBreaks(repairEscapedWhitespace(text))
|
|
310
314
|
t = stripExcessBold(normalizePunctuation(t))
|
|
311
|
-
return t
|
|
315
|
+
return addParagraphSpacers(t)
|
|
312
316
|
}
|
|
313
317
|
|
|
314
318
|
const input =
|