switchroom 0.18.19 → 0.18.20

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.
Files changed (47) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +36 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/answer-ready-flush.ts +187 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1073 -182
  7. package/telegram-plugin/format.ts +179 -20
  8. package/telegram-plugin/gateway/cron-session.ts +32 -0
  9. package/telegram-plugin/gateway/gateway.ts +775 -105
  10. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  11. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  13. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  14. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  15. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  16. package/telegram-plugin/narrative-flush.ts +181 -0
  17. package/telegram-plugin/pending-work-progress.ts +65 -1
  18. package/telegram-plugin/session-tail.ts +6 -1
  19. package/telegram-plugin/silent-end.ts +182 -0
  20. package/telegram-plugin/stream-reply-handler.ts +14 -5
  21. package/telegram-plugin/subagent-watcher.ts +244 -81
  22. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  23. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  24. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  25. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  26. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  27. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  28. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  29. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  30. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  31. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  32. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  33. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  34. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  35. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  36. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  37. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  38. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
  39. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  41. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  42. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  43. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  44. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
  45. package/telegram-plugin/tool-activity-summary.ts +78 -16
  46. package/telegram-plugin/turn-flush-safety.ts +4 -4
  47. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -0,0 +1,218 @@
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 }
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 }) => {
68
+ cues.push({ agentId, progressLine, latestSummary })
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.
95
+ return cues.filter((c) => c.progressLine == null).map((c) => c.latestSummary)
96
+ }
97
+
98
+ it('paints a parked opening narration after the flush window with NO tool event', () => {
99
+ tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-'))
100
+ const agentDir = join(tmpRoot, 'agent')
101
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
102
+ mkdirSync(subagentsDir, { recursive: true })
103
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
104
+
105
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
106
+ const h = startWatcher(agentDir)
107
+ h.poll() // register + promote to live
108
+
109
+ // Opening narration arrives; the worker then "thinks" (no tool yet).
110
+ appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — pulling the logs first.')))
111
+ h.poll() // parks the block, arms the early-paint timer — NO cue yet
112
+ expect(narrativeCues(h.cues), 'parked block must not paint before the window').toHaveLength(0)
113
+
114
+ // Advance past the flush window and poll again — STILL no new jsonl event.
115
+ h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
116
+ h.poll()
117
+
118
+ const narr = narrativeCues(h.cues)
119
+ expect(narr, 'opening narration must early-paint without a tool').toHaveLength(1)
120
+ expect(narr[0]).toContain('pulling the logs')
121
+ })
122
+
123
+ it('does NOT paint before the flush window elapses', () => {
124
+ tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-nofire-'))
125
+ const agentDir = join(tmpRoot, 'agent')
126
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
127
+ mkdirSync(subagentsDir, { recursive: true })
128
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
129
+
130
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
131
+ const h = startWatcher(agentDir)
132
+ h.poll()
133
+ appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — pulling the logs first.')))
134
+ h.poll()
135
+
136
+ // Advance LESS than the window — the tick must not fire yet.
137
+ h.advance(PENDING_NARRATIVE_FLUSH_MS - 50)
138
+ h.poll()
139
+ expect(narrativeCues(h.cues), 'must not paint before the window').toHaveLength(0)
140
+ })
141
+
142
+ it('is depth-generic: two independent entries each early-paint their opening narration', () => {
143
+ tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-depth-'))
144
+ const agentDir = join(tmpRoot, 'agent')
145
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
146
+ mkdirSync(subagentsDir, { recursive: true })
147
+ // Two entries stand in for two nesting levels: the registry is FLAT, so a
148
+ // top worker and a nested depth-2 sub-worker are both plain WorkerEntry
149
+ // rows and take the identical per-entry gate — this proves the mechanism
150
+ // is depth-generic (it operates on the generic node, not a fixed level).
151
+ const aPath = join(subagentsDir, 'agent-aaaa0000.jsonl')
152
+ const bPath = join(subagentsDir, 'agent-bbbb1111.jsonl')
153
+ writeFileSync(aPath, buildJSONL(subAgentUserMsg('Level 1 task')))
154
+ writeFileSync(bPath, buildJSONL(subAgentUserMsg('Level 2 task')))
155
+ const h = startWatcher(agentDir)
156
+ h.poll()
157
+ appendFileSync(aPath, buildJSONL(subAgentAssistantText('Worker A: reading the config.')))
158
+ appendFileSync(bPath, buildJSONL(subAgentAssistantText('Worker B: cloning the repo.')))
159
+ h.poll()
160
+ expect(narrativeCues(h.cues)).toHaveLength(0)
161
+
162
+ h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
163
+ h.poll()
164
+
165
+ const aCues = h.cues.filter((c) => c.agentId === 'aaaa0000' && c.progressLine == null)
166
+ const bCues = h.cues.filter((c) => c.agentId === 'bbbb1111' && c.progressLine == null)
167
+ expect(aCues.map((c) => c.latestSummary).join(' ')).toContain('reading the config')
168
+ expect(bCues.map((c) => c.latestSummary).join(' ')).toContain('cloning the repo')
169
+ })
170
+
171
+ it('does not double-print: an early-painted block is not re-shown when the first real tool lands', () => {
172
+ tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-nodup-'))
173
+ const agentDir = join(tmpRoot, 'agent')
174
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
175
+ mkdirSync(subagentsDir, { recursive: true })
176
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
177
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
178
+ const h = startWatcher(agentDir)
179
+ h.poll()
180
+ appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — pulling the logs first.')))
181
+ h.poll()
182
+ h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
183
+ h.poll() // early-paint fires (1 narrative cue)
184
+ expect(narrativeCues(h.cues)).toHaveLength(1)
185
+
186
+ // Now the worker's first real tool lands. The parked block was already
187
+ // consumed by the timer → it must NOT re-fire as a narrative cue, and the
188
+ // tool-label cue is a SEPARATE step (no stale duplicate narration).
189
+ appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
190
+ h.advance(10)
191
+ h.poll()
192
+
193
+ expect(narrativeCues(h.cues), 'narration must not be re-shown').toHaveLength(1)
194
+ const toolCues = h.cues.filter((c) => c.progressLine != null)
195
+ expect(toolCues.length, 'the tool step fires as its own cue').toBeGreaterThanOrEqual(1)
196
+ })
197
+
198
+ it('a tool arriving WITHIN the window resolves the block exactly once (no early-paint + resolve double)', () => {
199
+ tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-within-'))
200
+ const agentDir = join(tmpRoot, 'agent')
201
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
202
+ mkdirSync(subagentsDir, { recursive: true })
203
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
204
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
205
+ const h = startWatcher(agentDir)
206
+ h.poll()
207
+ appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — let me find the repo.')))
208
+ h.poll()
209
+ // Tool lands well within the window → resolve shows it once; timer never fires.
210
+ h.advance(50)
211
+ appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
212
+ h.poll()
213
+ // Advance past the window: the (already-consumed) block must not re-paint.
214
+ h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
215
+ h.poll()
216
+ expect(narrativeCues(h.cues), 'resolved narration shows exactly once').toHaveLength(1)
217
+ })
218
+ })
@@ -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 blank-line hygiene. Paragraph gaps are now plain `\n\n`
189
- // (the NBSP spacer was removed in the #2669 follow-up). When a cut lands in
190
- // a gap, the boundary must not leave a chunk that opens or ends with a bare
191
- // blank line, and no visible content may be dropped.
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 blank line at a `\\n\\n` gap cut', () => {
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 text = `${A}.\n\n${B}.`
198
- const chunks = splitMarkdownChunks(text, 33)
200
+ const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
201
+ const chunks = splitMarkdownChunks(spaced, 33)
199
202
  expect(chunks.length).toBeGreaterThan(1)
200
- const blankOnly = /^[ \t]*$/
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(blankOnly.test(lines[0])).toBe(false)
204
- expect(blankOnly.test(lines[lines.length - 1])).toBe(false)
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 text = `${A}.\n\n${B}.`
212
- const chunks = splitMarkdownChunks(text, 33)
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('blank-line-boundary strip is robust across several gaps and small caps', () => {
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 text = paras.join('\n\n')
221
- const blankOnly = /^[ \t]*$/
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(text, cap)
226
+ const chunks = splitMarkdownChunks(spaced, cap)
224
227
  for (const c of chunks) {
225
228
  const lines = c.split('\n')
226
- expect(blankOnly.test(lines[0])).toBe(false)
227
- expect(blankOnly.test(lines[lines.length - 1])).toBe(false)
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 tokens
230
- // reproduces the original word sequence. (Word-level, not line-level,
231
- // because a small cap may split mid-word — the chunker's normal
232
- // space-boundary behaviour.)
233
- const words = (s: string): string[] => s.split(/\s+/).filter((w) => w.length > 0)
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
- return normalizeParagraphBreaks(repairEscapedWhitespace(joined))
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 (plain `\n\n`, one blank line —
170
- // no NBSP spacer), and NOT a single-newline join.
171
- expect(out).toContain('flagged.\n\nThe `auth`')
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
- // No U+00A0 anywhere.
174
- expect(out).not.toContain(String.fromCharCode(0xa0))
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', so no doubled/stacked blank run survives.
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 blank-line gap per block transition: 3 blocks → 2 gaps.
183
- const gapCount = (out.match(/\n\n/g) ?? []).length
184
- expect(gapCount).toBe(2)
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('\n\n')
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
- // (no send-side paragraph-spacer pass — removed in the #2669 follow-up).
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 =
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ computeTurnStatus,
5
+ backstopSendOutcome,
6
+ finalizeBackstopSend,
7
+ buildTurnRecord,
8
+ type DeliveryOutcome,
9
+ } from '../gateway/turn-record-status.js'
10
+
11
+ /**
12
+ * PR B — send-honesty. The turns.jsonl `status` must reflect the REAL send
13
+ * outcome, not the speculative `finalAnswerDelivered` flag the turn-flush
14
+ * backstop sets before its async send runs.
15
+ *
16
+ * These assert the recorded status OUTCOME for each turn shape — the exact
17
+ * string `emitTurnRecord` writes — not merely that a code path ran.
18
+ */
19
+
20
+ describe('computeTurnStatus — recorded turn status reflects real outcome', () => {
21
+ it('genuine no-reply turn → no_reply', () => {
22
+ expect(computeTurnStatus({ finalAnswerDelivered: false })).toBe('no_reply')
23
+ })
24
+
25
+ it('synchronous reply-tool delivery (no deliveryOutcome) → complete', () => {
26
+ expect(computeTurnStatus({ finalAnswerDelivered: true })).toBe('complete')
27
+ })
28
+
29
+ it('reply-tool short-circuit suppressed the flush → complete (reply delivered)', () => {
30
+ expect(
31
+ computeTurnStatus({ finalAnswerDelivered: true, deliveryOutcome: 'suppressed' }),
32
+ ).toBe('complete')
33
+ })
34
+
35
+ it('fail-safe: undefined outcome + finalAnswerDelivered false never fabricates complete', () => {
36
+ expect(computeTurnStatus({ finalAnswerDelivered: false, deliveryOutcome: undefined })).toBe(
37
+ 'no_reply',
38
+ )
39
+ })
40
+ })
41
+
42
+ describe('backstopSendOutcome — resolve outcome from what happened on the wire', () => {
43
+ it('throw → failed', () => {
44
+ expect(backstopSendOutcome({ threw: true, sentCount: 0, chunkCount: 1 })).toBe('failed')
45
+ })
46
+
47
+ it('partial (no throw, short delivery) → failed', () => {
48
+ expect(backstopSendOutcome({ threw: false, sentCount: 1, chunkCount: 2 })).toBe('failed')
49
+ })
50
+
51
+ it('full delivery → delivered', () => {
52
+ expect(backstopSendOutcome({ threw: false, sentCount: 3, chunkCount: 3 })).toBe('delivered')
53
+ })
54
+
55
+ it('Fix 5 — empty split (0 chunks, no throw) → failed, not delivered', () => {
56
+ expect(backstopSendOutcome({ threw: false, sentCount: 0, chunkCount: 0 })).toBe('failed')
57
+ })
58
+ })
59
+
60
+ /**
61
+ * Fix 3 — WIRING integration. These drive the SAME seams the gateway
62
+ * turn-flush IIFE runs — `finalizeBackstopSend` (the stamp) feeding
63
+ * `buildTurnRecord` (which `emitTurnRecord` serializes verbatim) — and assert
64
+ * the RECORDED status string. If the accounting stamps the wrong branch, or the
65
+ * record builder ever reverted to the speculative `finalAnswerDelivered`
66
+ * ternary, these fail — not just if the pure predicate is wrong.
67
+ */
68
+ describe('turn-flush wiring → recorded turns.jsonl status', () => {
69
+ const ENDED_AT = 1_700_000_500_000
70
+ const mkTurn = () => ({
71
+ agent: 'test-agent',
72
+ startedAt: ENDED_AT - 5_000,
73
+ toolCallCount: 0,
74
+ turnId: 'turn-abc',
75
+ // speculatively set at gateway.ts flush site BEFORE the send runs:
76
+ finalAnswerDelivered: true,
77
+ deliveryOutcome: undefined as DeliveryOutcome | undefined,
78
+ })
79
+
80
+ const recordAfterSend = (send: { threw: boolean; sentCount: number; chunkCount: number }) => {
81
+ const turn = mkTurn()
82
+ finalizeBackstopSend(turn, send) // mutates turn.deliveryOutcome — as the IIFE does
83
+ return buildTurnRecord(turn, ENDED_AT)
84
+ }
85
+
86
+ it('send SUCCEEDS (all chunks delivered) → complete', () => {
87
+ expect(recordAfterSend({ threw: false, sentCount: 2, chunkCount: 2 }).status).toBe('complete')
88
+ })
89
+
90
+ it('send THROWS (simulated FLOOD_WAIT_ACTIVE) → send_failed, never complete', () => {
91
+ // BUG ORACLE: pre-fix, `finalAnswerDelivered=true` was written BEFORE the
92
+ // send ran and the record read that flag → 'complete' even though the send
93
+ // threw and the user got nothing. Assert the wired outcome is honest.
94
+ const rec = recordAfterSend({ threw: true, sentCount: 0, chunkCount: 1 })
95
+ expect(rec.status).toBe('send_failed')
96
+ expect(rec.status).not.toBe('complete')
97
+ })
98
+
99
+ it('PARTIAL multi-chunk (chunk 1 ok, chunk 2 throws) → send_failed', () => {
100
+ expect(recordAfterSend({ threw: true, sentCount: 1, chunkCount: 3 }).status).toBe('send_failed')
101
+ })
102
+
103
+ it('reply-tool suppressed the flush → complete (reply delivered), via the stamp', () => {
104
+ const turn = mkTurn()
105
+ turn.deliveryOutcome = 'suppressed' // the IIFE's suppressed-branch stamp
106
+ expect(buildTurnRecord(turn, ENDED_AT).status).toBe('complete')
107
+ })
108
+
109
+ it('record carries the honest tuple (tools + duration) alongside status', () => {
110
+ const rec = recordAfterSend({ threw: true, sentCount: 0, chunkCount: 1 })
111
+ expect(rec).toMatchObject({
112
+ status: 'send_failed',
113
+ tools: 0,
114
+ duration_ms: 5_000,
115
+ turn_id: 'turn-abc',
116
+ agent: 'test-agent',
117
+ })
118
+ })
119
+ })