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.
Files changed (57) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +59 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  6. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  7. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  8. package/profiles/coding/CLAUDE.md.hbs +1 -1
  9. package/telegram-plugin/answer-ready-flush.ts +187 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +1114 -184
  11. package/telegram-plugin/format.ts +179 -20
  12. package/telegram-plugin/gateway/cron-session.ts +32 -0
  13. package/telegram-plugin/gateway/gateway.ts +794 -106
  14. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  15. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  16. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  17. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  18. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  19. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  20. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  21. package/telegram-plugin/narrative-flush.ts +181 -0
  22. package/telegram-plugin/pending-work-progress.ts +65 -1
  23. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  24. package/telegram-plugin/session-tail.ts +6 -1
  25. package/telegram-plugin/silent-end.ts +182 -0
  26. package/telegram-plugin/stream-reply-handler.ts +14 -5
  27. package/telegram-plugin/subagent-watcher.ts +330 -82
  28. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  29. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  30. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  31. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  32. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  33. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  34. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  35. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  36. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  37. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  38. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  39. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  40. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  41. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  42. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  43. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  44. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  45. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  46. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  47. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
  48. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  49. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  51. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  52. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  53. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  54. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
  55. package/telegram-plugin/tool-activity-summary.ts +78 -16
  56. package/telegram-plugin/turn-flush-safety.ts +4 -4
  57. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Time-boxed narrative early-paint — the TIMER half of the JSONL-text-narrative
3
+ * primitive, and the pure, fully-unit-testable kernel of it (companion to
4
+ * `narrative-dedup.ts`, which owns the SHOW-vs-SUPPRESS decision).
5
+ *
6
+ * ## Why this exists
7
+ * A `text` / `sub_agent_text` block is parked for ONE lookahead step so the
8
+ * reducer can tell DRAFT-THEN-SEND (suppress — it duplicates the reply) from
9
+ * WORKING NARRATION (show). On a normal turn the lookahead is the FIRST real
10
+ * `tool_use`, so the opening narration ("On it, pulling the logs…") only painted
11
+ * when that tool landed — a visible gap while the agent thinks before its first
12
+ * tool.
13
+ *
14
+ * This kernel adds a deterministic timer: when a block is parked, arm a short
15
+ * timer. If a lookahead event (tool_use / next text / turn_end) arrives first,
16
+ * the normal path resolves the block and the timer is CANCELLED. If the timer
17
+ * fires first, the parked narration is painted EARLY — so it surfaces
18
+ * ~`flushMs` into the turn instead of waiting for the first tool.
19
+ *
20
+ * ## Anti-double-print guarantee (the correctness core)
21
+ * The whole reason the decision is deferred is that a parked block might turn out
22
+ * to be the outgoing reply, which must NEVER surface as a card narration step AND
23
+ * as the canonical reply. The timer introduces a window where a block is painted
24
+ * BEFORE its lookahead reply arrives. This kernel keeps the guarantee
25
+ * deterministically, NOT by hoping the window hides the race:
26
+ * - A block painted by the timer is remembered (`timerShown`).
27
+ * - When the reply/stream_reply lookahead finally arrives (on a tool or at
28
+ * turn_end), if it is a draft-then-send of the timer-painted block
29
+ * (`isDraftOfReply`), the kernel emits a RETRACT effect so the caller removes
30
+ * the prematurely-shown step. Correctness therefore does not depend on the
31
+ * 250ms window winning the race — the retract is the guarantee; the timer is
32
+ * only the early-paint trigger.
33
+ *
34
+ * ## Purity
35
+ * No I/O, no real timers, no state of its own beyond the two explicit slots. All
36
+ * effects (paint a step, retract a step, arm/disarm a real timer) are injected,
37
+ * so the kernel is driven deterministically in tests with fake timers. The caller
38
+ * (gateway reducer for the main agent, subagent-watcher for sub/worker) owns the
39
+ * effects and the per-turn lifetime.
40
+ */
41
+
42
+ import { REPLY_TOOLS, isDraftOfReply } from './narrative-dedup.js'
43
+
44
+ /**
45
+ * Time-box for the parked-narrative early-paint (the kernel's home for the
46
+ * constant so BOTH callers share ONE source of truth):
47
+ * - the main-agent gateway path drives the kernel with a real `setTimeout`
48
+ * scheduler (`makeNarrativeGate`, gateway.ts);
49
+ * - the worker / sub-agent watcher drives the SAME kernel with a POLL-driven
50
+ * scheduler (stamp `deadline = nowFn()+flushMs`; flush on the next poll
51
+ * tick once `nowFn() >= deadline`) — see `subagent-watcher.ts`.
52
+ * If a parked opening narration gets no lookahead event (tool_use / next text /
53
+ * turn_end) within this window — the "narrate, then think before the first
54
+ * tool" gap — it is painted EARLY instead of waiting for the first tool. Chosen
55
+ * so a normal draft-then-send `reply` (emitted right after the text block)
56
+ * resolves the block FIRST; correctness does NOT depend on that race — a
57
+ * timer-painted block that later proves to be the reply is deterministically
58
+ * retracted (main path) or is structurally harmless (worker path: the reply is
59
+ * a Telegram-surface tool that never renders on the card). Named const so a
60
+ * test can pin it.
61
+ */
62
+ export const PENDING_NARRATIVE_FLUSH_MS = 250
63
+
64
+ /** Side effects the kernel drives. The caller owns rendering + retraction. */
65
+ export interface NarrativeFlushEffects {
66
+ /** Paint a narrative block as a transient liveness step (SHOW). */
67
+ show(text: string): void
68
+ /**
69
+ * Retract a previously-SHOWN narration step (it turned out to draft the reply).
70
+ * Caller removes it from the feed. No-op-safe if already gone.
71
+ */
72
+ retractShown(text: string): void
73
+ }
74
+
75
+ /** Arms / disarms the real early-paint timer. Injected so tests can fake it. */
76
+ export interface NarrativeFlushScheduler {
77
+ /** Arm the timer; MUST cancel any prior armed callback first (at-most-one). */
78
+ arm(fn: () => void, ms: number): void
79
+ /** Cancel the armed callback if any. Idempotent. */
80
+ disarm(): void
81
+ }
82
+
83
+ /**
84
+ * Pure park/timer/retract state machine for one turn (or one sub-agent entry).
85
+ * Construct once per turn with the effect + scheduler wiring; discard at teardown.
86
+ */
87
+ export class NarrativeFlushController {
88
+ /** Block parked awaiting its lookahead event. Null when nothing pending. */
89
+ private pending: string | null = null
90
+ /** Block the timer painted EARLY, retained for possible retract. */
91
+ private timerShown: string | null = null
92
+
93
+ constructor(
94
+ private readonly effects: NarrativeFlushEffects,
95
+ private readonly scheduler: NarrativeFlushScheduler,
96
+ private readonly flushMs: number,
97
+ ) {}
98
+
99
+ /** Test/inspection accessors (no behaviour). */
100
+ get pendingText(): string | null {
101
+ return this.pending
102
+ }
103
+ get timerShownText(): string | null {
104
+ return this.timerShown
105
+ }
106
+
107
+ /**
108
+ * Gate step 1: a new narrative block arrived. It is itself the lookahead for
109
+ * the previously-parked block (pure narration — a draft is followed by a reply,
110
+ * not more text) → SHOW the old one, then park the new one and arm the timer.
111
+ */
112
+ stage(text: string): void {
113
+ this.scheduler.disarm()
114
+ if (this.pending != null) {
115
+ this.effects.show(this.pending)
116
+ }
117
+ this.pending = text
118
+ this.scheduler.arm(() => this.onTimerFire(), this.flushMs)
119
+ }
120
+
121
+ /**
122
+ * The early-paint timer fired: no lookahead arrived within the window. If the
123
+ * block is still parked, SHOW it now and remember it so a later draft-then-send
124
+ * reply can retract it.
125
+ */
126
+ private onTimerFire(): void {
127
+ if (this.pending == null) return // a lookahead already consumed it
128
+ const text = this.pending
129
+ this.pending = null
130
+ this.timerShown = text
131
+ this.effects.show(text)
132
+ }
133
+
134
+ /**
135
+ * Gate step 2: a tool_use lookahead arrived. Cancel the timer, retract a
136
+ * timer-painted block if THIS reply drafts it, then resolve the parked block:
137
+ * SUPPRESS a reply-draft, else SHOW.
138
+ */
139
+ resolveOnTool(toolName: string, input: Record<string, unknown> | undefined): void {
140
+ this.scheduler.disarm()
141
+ const replyText =
142
+ REPLY_TOOLS.has(toolName) && typeof input?.text === 'string' ? (input.text as string) : null
143
+ if (replyText != null) this.maybeRetract(replyText)
144
+ const pending = this.pending
145
+ if (pending == null) return
146
+ this.pending = null
147
+ if (replyText != null && isDraftOfReply(pending, replyText)) return // draft → SUPPRESS
148
+ this.effects.show(pending)
149
+ }
150
+
151
+ /**
152
+ * Gate step 3: turn_end — the terminal lookahead. Cancel the timer, retract a
153
+ * timer-painted block if the delivered reply drafts it, then SHOW a genuine
154
+ * trailing narration (or SUPPRESS a trailing draft of the answer).
155
+ */
156
+ flushAtTurnEnd(lastReplyText: string): void {
157
+ this.scheduler.disarm()
158
+ if (lastReplyText.length > 0) this.maybeRetract(lastReplyText)
159
+ const pending = this.pending
160
+ if (pending == null) return
161
+ this.pending = null
162
+ if (lastReplyText.length > 0 && isDraftOfReply(pending, lastReplyText)) return // trailing draft
163
+ this.effects.show(pending)
164
+ }
165
+
166
+ /** Turn teardown: cancel the timer so it can neither leak nor fire late. */
167
+ teardown(): void {
168
+ this.scheduler.disarm()
169
+ this.pending = null
170
+ this.timerShown = null
171
+ }
172
+
173
+ /** Retract the timer-painted block iff this reply is its draft-then-send. */
174
+ private maybeRetract(replyText: string): void {
175
+ const shown = this.timerShown
176
+ if (shown == null) return
177
+ if (!isDraftOfReply(shown, replyText)) return
178
+ this.timerShown = null
179
+ this.effects.retractShown(shown)
180
+ }
181
+ }
@@ -64,6 +64,19 @@
64
64
  export const EDIT_INTERVAL_MS = 60_000
65
65
  export const POLL_INTERVAL_MS = 5_000
66
66
  export const MAX_LIFETIME_MS = 30 * 60_000
67
+ /**
68
+ * TTL bounding how long an in-flight background dispatch (Agent/Task) suppresses
69
+ * the idle auto-/clear gate (#3117). Deliberately tied to `MAX_LIFETIME_MS` (the
70
+ * cross-turn ambient budget cap): the two express the same "how long do we
71
+ * believe a background worker is still legitimately running" budget, so keeping
72
+ * them equal means the idle-suppression window and the ambient-liveness window
73
+ * agree by construction. A SHORTER idle TTL would risk clobbering a legitimate
74
+ * ~25-min worker mid-flight; a LONGER one would weaken the self-healing bound
75
+ * (a leaked `pending=true` that never clears would disable idle-clear for
76
+ * longer). 30 min is the deliberate midpoint the design settled on. The gate
77
+ * suppresses only while `dispatch age < this`; past it the suppression lapses so
78
+ * a stuck flag can never disable idle-clear permanently. */
79
+ export const BACKGROUND_WORK_SUPPRESS_TTL_MS = MAX_LIFETIME_MS
67
80
  /** Rich-message wire cap is 32768 (#2669); budget headroom for the
68
81
  * suffix and any escape expansion. If the anchor text plus suffix
69
82
  * would exceed this, we skip the edit (the user still sees the
@@ -142,6 +155,14 @@ interface State {
142
155
  /** True after a `tool_use(Agent|Task)` was observed for this key in
143
156
  * the current turn. Cleared on next turn start. */
144
157
  pending: boolean
158
+ /** Epoch ms when `pending` was last set true (#3117). null when not
159
+ * pending. Load-bearing for the idle-clear suppression TTL: without a
160
+ * timestamp the gate cannot bound how long an in-flight background dispatch
161
+ * holds off idle-clear, so a leaked `pending=true` would disable idle-clear
162
+ * forever. Re-stamped on each `noteAsyncDispatch` (a fresh dispatch re-arms
163
+ * the TTL) and cleared when `pending` is set false (`startTurn`); a full
164
+ * `clearPending` drops the whole entry, which is equivalent. */
165
+ dispatchedAt: number | null
145
166
  /** The captured anchor — last outbound reply message_id for this
146
167
  * key. */
147
168
  anchorMessageId: number | null
@@ -179,6 +200,7 @@ function ensure(key: string): State {
179
200
  if (!s) {
180
201
  s = {
181
202
  pending: false,
203
+ dispatchedAt: null,
182
204
  anchorMessageId: null,
183
205
  anchorOriginalText: '',
184
206
  anchorLiteralText: false,
@@ -217,6 +239,7 @@ export function startTurn(key: string): void {
217
239
  // Only the per-turn fields reset. activatedAt/lastEditAt belong to
218
240
  // the prior turn's pending-progress and are cleared separately.
219
241
  s.pending = false
242
+ s.dispatchedAt = null
220
243
  s.anchorMessageId = null
221
244
  s.anchorOriginalText = ''
222
245
  s.anchorLiteralText = false
@@ -229,7 +252,13 @@ export function startTurn(key: string): void {
229
252
  */
230
253
  export function noteAsyncDispatch(key: string): void {
231
254
  if (!enabled()) return
232
- ensure(key).pending = true
255
+ const s = ensure(key)
256
+ s.pending = true
257
+ // Stamp (and re-stamp) the dispatch epoch so the idle-clear suppression TTL
258
+ // (#3117) can bound how long this holds off /clear. Re-stamping on each
259
+ // dispatch means a fresh Agent/Task within the same wait re-arms the TTL —
260
+ // freshest legitimate work wins.
261
+ s.dispatchedAt = nowMs()
233
262
  }
234
263
 
235
264
  /**
@@ -296,6 +325,41 @@ export function hasPendingAsyncDispatch(key: string): boolean {
296
325
  return stateByKey.get(key)?.pending === true
297
326
  }
298
327
 
328
+ /**
329
+ * Age in ms since the current pending async dispatch was stamped for `key`
330
+ * (#3117), or null if there is no pending dispatch (or, defensively, no
331
+ * timestamp — a pre-stamp entry from a rolling upgrade). Uses the same clock
332
+ * override as the rest of the module so tests drive it deterministically.
333
+ */
334
+ export function asyncDispatchAgeMs(key: string): number | null {
335
+ const s = stateByKey.get(key)
336
+ if (s == null || s.pending !== true || s.dispatchedAt == null) return null
337
+ return nowMs() - s.dispatchedAt
338
+ }
339
+
340
+ /**
341
+ * True iff ANY chat key currently has a pending async dispatch whose age is
342
+ * within `ttlMs` (#3117). The idle auto-/clear gate is agent-wide (not scoped
343
+ * to a single chat key), so it consults this aggregate: while a background
344
+ * sub-agent is in flight AND fresh, suppress the clear; once every pending
345
+ * dispatch has aged past the TTL (a leaked/stuck flag), the suppression lapses
346
+ * and idle-clear self-heals. A non-positive `ttlMs` disables suppression.
347
+ */
348
+ export function anyPendingAsyncDispatchWithin(ttlMs: number): boolean {
349
+ if (ttlMs <= 0) return false
350
+ const now = nowMs()
351
+ for (const s of stateByKey.values()) {
352
+ if (
353
+ s.pending === true &&
354
+ s.dispatchedAt != null &&
355
+ now - s.dispatchedAt < ttlMs
356
+ ) {
357
+ return true
358
+ }
359
+ }
360
+ return false
361
+ }
362
+
299
363
  /**
300
364
  * Clear pending-progress for a chat — reasons:
301
365
  * 'inbound' — user sent a new message, they're re-engaged
@@ -277,6 +277,12 @@ export function applySubagentsSchema(db: SqliteDatabase): void {
277
277
  // column is guaranteed to exist (either created with the table or added by
278
278
  // the migration above).
279
279
  db.exec('CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)')
280
+ // Same deferred-index rationale as jsonl_agent_id above: parent_agent_id is
281
+ // added by the ALTER migration for pre-existing tables, so its index must be
282
+ // created here (after the column is guaranteed to exist), not in the base SQL.
283
+ // Backs the per-poll child-existence probe in subagent-watcher.ts
284
+ // (`SELECT 1 FROM subagents WHERE parent_agent_id = ? LIMIT 1`).
285
+ db.exec('CREATE INDEX IF NOT EXISTS subagents_parent_agent ON subagents(parent_agent_id)')
280
286
  }
281
287
 
282
288
  // ---------------------------------------------------------------------------
@@ -119,7 +119,12 @@ export type SessionEvent =
119
119
  // gate keys on it.
120
120
  | { kind: 'text'; text: string; blockIndex: number; lastInMessage: boolean }
121
121
  | { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string }
122
- | { kind: 'turn_end'; durationMs: number }
122
+ // `reason` is set ONLY by an internal gateway-synthesized turn_end (never by
123
+ // the JSONL projection). `answer-ready-quiescence` (PR A) marks the positive
124
+ // deterministic quiescence-flush signal, which — unlike the orphaned-reply
125
+ // backstop's bare `durationMs:-1` — deliberately bypasses the recently-
126
+ // streaming suppression guard (quiescence IS the "streaming settled" signal).
127
+ | { kind: 'turn_end'; durationMs: number; reason?: 'answer-ready-quiescence' }
123
128
  // Multi-agent: sub-agent-scoped events. agentId is the sub-agent JSONL
124
129
  // filename stem (e.g. "aac6f1…"). Routed through the same ingest path
125
130
  // as parent events; the reducer fans them out to per-sub-agent state.
@@ -38,10 +38,34 @@ export interface SilentEndState {
38
38
  threadId: number | null
39
39
  /** Stable identifier for the in-flight turn (statusKey shape). */
40
40
  turnKey: string
41
+ /**
42
+ * Per-turn nonce — `deriveTurnId`'s `${chatKey}#${messageId}` shape (#3228).
43
+ * Unlike `turnKey` (the STABLE `chatId:threadId` statusKey, identical across
44
+ * every turn on the same chat/thread), this is unique per inbound message.
45
+ * The Stop hook stamps it from the enqueue envelope's `message_id`; the
46
+ * gateway's `decideCapturedProseDelivery` requires it to match the LIVE
47
+ * turn's `turnId` before delivering `pendingText`, so a stale captured-prose
48
+ * record from a PRIOR turn on the same chat can never be misdelivered on a
49
+ * later turn (Finding 3). Optional — absent when no message_id was derivable
50
+ * (synthetic inbounds), in which case delivery falls back to the turnKey
51
+ * match alone.
52
+ */
53
+ turnId?: string
41
54
  /** Incremented each time the Stop hook blocks for this turn. */
42
55
  retryCount: number
43
56
  /** Wall-clock ms of last write. */
44
57
  timestamp: number
58
+ /**
59
+ * Option A transcript-prose bridge. The substantive final-answer prose the
60
+ * model wrote as plain transcript text but never sent through the reply
61
+ * tool, as isolated by the Stop hook's transcript scan
62
+ * (`scanTurnForFinalReply` → `pendingText`). Present only on a hook-written
63
+ * state file for a turn that ended silently WITH deliverable prose; the
64
+ * gateway's own `writeSilentEndState` never sets it. The gateway reads it
65
+ * back to deliver the answer directly on the first silent-end. Optional —
66
+ * absent for the zero-prose (genuinely empty) silent-end.
67
+ */
68
+ pendingText?: string
45
69
  }
46
70
 
47
71
  export interface SilentEndDeps {
@@ -256,6 +280,164 @@ export function clearSilentEndState(turnKey: string, deps?: SilentEndDeps): void
256
280
  }
257
281
  }
258
282
 
283
+ /**
284
+ * Minimum length (chars, trimmed) of captured prose the gateway will deliver
285
+ * directly on a silent-end. Mirrors `FINAL_ANSWER_MIN_CHARS` in
286
+ * `hooks/silent-end-scan.mjs` and `isFinalAnswerReply`'s 200-char substantive
287
+ * backstop — the same bar used to recognise a real answer. A shorter trailing
288
+ * fragment is not a dropped answer and must not be re-materialised.
289
+ */
290
+ export const CAPTURED_PROSE_MIN_CHARS = 200
291
+
292
+ /**
293
+ * Result of the captured-prose delivery decision (Option A transcript-prose
294
+ * bridge). Pure, side-effect-free — the gateway consumes `deliver`/`text` and
295
+ * owns the actual send + dedup + obligation-close bookkeeping.
296
+ */
297
+ export interface CapturedProseDecision {
298
+ /** True → the gateway should deliver `text` directly on this silent-end. */
299
+ deliver: boolean
300
+ /** The prose to deliver; present iff `deliver === true`. */
301
+ text?: string
302
+ /** Machine-readable reason (for logs / tests). */
303
+ reason:
304
+ | 'captured-prose'
305
+ | 'no-state'
306
+ | 'turnkey-mismatch'
307
+ | 'turnid-mismatch'
308
+ | 'no-substantive-prose'
309
+ }
310
+
311
+ /**
312
+ * Decide whether the current silent-end turn has a substantive undelivered
313
+ * final answer that the gateway should deliver directly (Option A).
314
+ *
315
+ * Reads the same `silent-end-pending.json` the Stop hook wrote. Delivers ONLY
316
+ * when (a) the record belongs to THIS turn — BOTH the stable `turnKey` AND, when
317
+ * present, the per-turn `turnId` nonce must match (#3228 Finding 3: `turnKey`
318
+ * alone is `chatId:threadId` and identical across turns on the same chat, so it
319
+ * cannot by itself prove the record is this turn's rather than a stale
320
+ * carryover) — and (b) the persisted `pendingText` clears the substance floor.
321
+ * Otherwise returns `deliver:false` and the caller falls through to the existing
322
+ * re-prompt / represent safety nets unchanged.
323
+ *
324
+ * turnId matching is enforced ONLY when BOTH sides carry one. A record written
325
+ * without a nonce (synthetic inbound with no message_id) or a caller that has
326
+ * no live turnId falls back to the turnKey match alone — never suppress
327
+ * delivery on doubt, and never break the pre-nonce path.
328
+ *
329
+ * Extracted as a pure core (mirrors `decideTurnFlush` / `decideTurnEndGate`)
330
+ * so the gateway runs the exact code the regression tests exercise —
331
+ * `gateway.ts` is not importable in tests.
332
+ */
333
+ export function decideCapturedProseDelivery(
334
+ args: { turnKey: string; turnId?: string | null; minChars?: number },
335
+ deps?: SilentEndDeps,
336
+ ): CapturedProseDecision {
337
+ const minChars = args.minChars ?? CAPTURED_PROSE_MIN_CHARS
338
+ const state = readSilentEndState(deps)
339
+ if (state == null) return { deliver: false, reason: 'no-state' }
340
+ if (state.turnKey !== args.turnKey) return { deliver: false, reason: 'turnkey-mismatch' }
341
+ // Per-turn nonce guard (#3228 Finding 3). When the on-disk record was stamped
342
+ // with a `turnId` AND the caller passes the live turn's `turnId`, they MUST
343
+ // match — otherwise the record belongs to a different turn on the same chat.
344
+ if (
345
+ typeof state.turnId === 'string' &&
346
+ state.turnId !== '' &&
347
+ args.turnId != null &&
348
+ args.turnId !== '' &&
349
+ state.turnId !== args.turnId
350
+ ) {
351
+ return { deliver: false, reason: 'turnid-mismatch' }
352
+ }
353
+ const text = typeof state.pendingText === 'string' ? state.pendingText : ''
354
+ if (text.trim().length < minChars) return { deliver: false, reason: 'no-substantive-prose' }
355
+ return { deliver: true, text, reason: 'captured-prose' }
356
+ }
357
+
358
+ /**
359
+ * Outcome of a captured-prose send attempt (Option A transcript-prose bridge).
360
+ * - `sent` — the answer was delivered fresh this call.
361
+ * - `skipped-dedup`— the exact answer already went out (dedup hit); nothing
362
+ * new was sent, but the answer IS with the user.
363
+ * - `failed` — the send threw; the answer did NOT reach the user.
364
+ */
365
+ export type CapturedProseSendOutcome = 'sent' | 'skipped-dedup' | 'failed'
366
+
367
+ /**
368
+ * The bookkeeping effects the gateway applies after a captured-prose send
369
+ * attempt, injected so the settlement decision is a pure, testable core
370
+ * (`gateway.ts` itself is not importable in tests).
371
+ */
372
+ export interface CapturedProseSettlementEffects {
373
+ /** Close the ending turn's delivery obligation. */
374
+ closeObligation: () => void
375
+ /** Clear the silent-end state file for this turn. */
376
+ clearState: () => void
377
+ /**
378
+ * Arm the deterministic Stop-hook re-prompt (recordUndeliveredTurnEnd) so
379
+ * the answer is recoverable — the send-failure safety net. Returns the
380
+ * `{ exhausted }` verdict from `recordUndeliveredTurnEnd`: `true` when the
381
+ * re-prompt budget was ALREADY spent (retryCount >= SILENT_END_MAX_RETRIES)
382
+ * on the attempt that failed, so the re-prompt can no longer recover the
383
+ * answer and the caller must deliver a user-facing fallback instead (#3228
384
+ * exhaustion-boundary gap).
385
+ */
386
+ recordUndelivered: () => { exhausted: boolean }
387
+ }
388
+
389
+ /**
390
+ * Result of `settleCapturedProseDelivery`. `exhausted` is meaningful only on
391
+ * the `failed` outcome — `true` means the Stop-hook re-prompt net is spent, so
392
+ * the caller must fire the user-facing fallback (a plain-text retry of the
393
+ * captured prose, then the generic apology) so the turn never goes silent.
394
+ * Always `false` for `sent` / `skipped-dedup` (the answer is already with the
395
+ * user; no fallback).
396
+ */
397
+ export interface CapturedProseSettlementResult {
398
+ exhausted: boolean
399
+ }
400
+
401
+ /**
402
+ * Apply the correct bookkeeping after a captured-prose send attempt (#3228
403
+ * Finding 1). This is the deterministic core the gateway's
404
+ * `deliverCapturedProse` routes ALL three of its settlement points through.
405
+ *
406
+ * - `sent` / `skipped-dedup` → the answer is with the user, so CLOSE the
407
+ * obligation and CLEAR the silent-end state; the represent and the
408
+ * exhausted fallback must not re-fire for the same answer.
409
+ *
410
+ * - `failed` → the send threw, so the answer is NOT with the user. We must
411
+ * NOT close the obligation or clear the state; instead we ARM the Stop-hook
412
+ * re-prompt net (`recordUndelivered`). This is the regression fix: the
413
+ * shared turn-end teardown (`decideObligationTurnEnd`) already CLOSES the
414
+ * obligation whenever `replyCalled === true` (the interim-ack case that
415
+ * reaches captured-prose delivery), so "just leave the obligation open" is
416
+ * NOT a real net — without recordUndelivered a thrown send permanently
417
+ * loses the answer, strictly worse than the pre-bridge behaviour.
418
+ *
419
+ * The `{ exhausted }` verdict from `recordUndelivered` is threaded back out
420
+ * to the caller (#3228 exhaustion-boundary gap): when the failed send
421
+ * happened on the attempt where the re-prompt budget was ALREADY spent,
422
+ * `recordUndeliveredTurnEnd` clears the state and reports `exhausted:true` —
423
+ * the Stop-hook re-prompt can no longer recover the answer, so the caller
424
+ * MUST deliver a user-facing fallback (mirroring the non-captured
425
+ * exhausted path) or the user gets NEITHER the answer NOR the apology.
426
+ *
427
+ * Pure — no IO, no module state; the caller injects the effects.
428
+ */
429
+ export function settleCapturedProseDelivery(
430
+ outcome: CapturedProseSendOutcome,
431
+ effects: CapturedProseSettlementEffects,
432
+ ): CapturedProseSettlementResult {
433
+ if (outcome === 'failed') {
434
+ return { exhausted: effects.recordUndelivered().exhausted }
435
+ }
436
+ effects.closeObligation()
437
+ effects.clearState()
438
+ return { exhausted: false }
439
+ }
440
+
259
441
  /**
260
442
  * Read the state file (for tests + diagnostics). Returns null when
261
443
  * absent or unparsable.
@@ -171,6 +171,14 @@ export interface StreamReplyDeps {
171
171
  * after normalizePunctuation. Optional for backward compat.
172
172
  */
173
173
  stripExcessBold?: (text: string) => string
174
+ /**
175
+ * Insert a visible blank-line spacer into each prose `\n\n` gap so the rich
176
+ * GFM renderer shows a real empty line between paragraphs (the rich engine
177
+ * otherwise renders `\n\n` tight — the post-#2669 paragraph-spacing
178
+ * regression). Applied only on the rich path (never on `format:'text'`).
179
+ * Optional for backward compat; omitted → no spacers added.
180
+ */
181
+ addParagraphSpacers?: (text: string) => string
174
182
  /** Validates the chat id against the access list. Throws on deny. */
175
183
  assertAllowedChat: (chatId: string) => void
176
184
  /** Resolves the effective thread id (explicit, last-inbound, or undefined). */
@@ -354,11 +362,12 @@ export async function handleStreamReply(
354
362
  // markdown→HTML / MarkdownV2 rendering happens here anymore — the raw
355
363
  // text IS the wire payload.
356
364
  const literalText = format === 'text'
357
- // No paragraph-spacer pass: the NBSP spacer (#2669) was removed in the
358
- // follow-up because the Bot API 10.1 rich GFM renderer already shows a `\n\n`
359
- // gap as one blank line — the spacer double-gapped every paragraph. The raw
360
- // text (already normalized upstream) is the effective text on both paths.
361
- let effectiveText: string = rawText
365
+ // Paragraph-spacing fix (rich-message regression after #2669): inject a
366
+ // visible blank-line spacer into prose `\n\n` gaps on the rich path so
367
+ // multi-paragraph answers don't render jammed together. The literal
368
+ // (`format:'text'`) path must stay byte-exact, so it is left untouched.
369
+ let effectiveText: string =
370
+ !literalText && deps.addParagraphSpacers ? deps.addParagraphSpacers(rawText) : rawText
362
371
 
363
372
  // Inline status-accent header (issue #320 fallback). Prepended so it
364
373
  // leads the body. Since stream_reply callers pass the full text snapshot