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,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)
|
|
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
|
-
|
|
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
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
// text
|
|
361
|
-
let effectiveText: string =
|
|
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
|