switchroom 0.19.13 → 0.19.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +4 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +1027 -509
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +4 -0
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -3
- package/telegram-plugin/gateway/outbox-sweep.ts +73 -5
- package/telegram-plugin/gateway/rich-message-handler.ts +235 -0
- package/telegram-plugin/gateway/stream-render.ts +107 -15
- package/telegram-plugin/gateway/unhandled-message.ts +14 -0
- package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
- package/telegram-plugin/hooks/narration-classify.mjs +210 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +33 -7
- package/telegram-plugin/hooks/silent-end-scan.mjs +171 -85
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +87 -0
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silent-end.ts +42 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
- package/telegram-plugin/tests/forward-origin.test.ts +20 -0
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
- package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +600 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +19 -11
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
- package/telegram-plugin/tests/silent-end.test.ts +7 -1
- package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
- package/telegram-plugin/turn-flush-safety.ts +66 -53
|
@@ -404,10 +404,13 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
|
|
|
404
404
|
const NARRATION_B = "Now let me query the second source; it is slower than expected, hang tight…" // opener + …
|
|
405
405
|
const NARRATION_C = "I'll cross-reference the last set of figures against the ledger before I summarise…" // opener + …
|
|
406
406
|
|
|
407
|
-
it('
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
407
|
+
it('narration interleaved with work-tools, ending on a terminal narration line → only the terminal block, never the join (#3513 structural coalescer)', () => {
|
|
408
|
+
// #3513 follow-up — A and B are each followed by a turn-continuing tool_use
|
|
409
|
+
// (Bash / Read) → structurally intra-turn → UNCONDITIONALLY suppressed.
|
|
410
|
+
// C is the terminal block (no tool after it), so the deterministic coalescer
|
|
411
|
+
// delivers C ALONE. The #3228 Finding-2 join-masquerade (concatenating short
|
|
412
|
+
// narration to cross the 200-char floor) is STILL prevented: the 200-floor
|
|
413
|
+
// `pendingText` stays undefined, and the delivered text is never the join.
|
|
411
414
|
expect((NARRATION_A + '\n\n' + NARRATION_B + '\n\n' + NARRATION_C).length)
|
|
412
415
|
.toBeGreaterThanOrEqual(200)
|
|
413
416
|
const text = jsonl(
|
|
@@ -421,8 +424,12 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
|
|
|
421
424
|
const r = scanTurnForFinalReply(text)
|
|
422
425
|
expect(r.decided).toBe('block')
|
|
423
426
|
expect(r.reason).toBe('no-final-reply')
|
|
424
|
-
//
|
|
425
|
-
expect(r.pendingText).
|
|
427
|
+
// Never the join of the suppressed A/B blocks (masquerade guard preserved).
|
|
428
|
+
expect(r.pendingText).not.toContain(NARRATION_A)
|
|
429
|
+
expect(r.pendingText).not.toContain(NARRATION_B)
|
|
430
|
+
// The terminal block is the coalescer's selection (delivered via the
|
|
431
|
+
// zero-reply lowered floor); the 200-char masquerade path yields nothing.
|
|
432
|
+
expect(r.pendingText).toBe(NARRATION_C)
|
|
426
433
|
})
|
|
427
434
|
|
|
428
435
|
it('zero-delivery turn of MULTIPLE sub-200 REAL-CONTENT blocks → JOINED pendingText (review item 3 drop fix)', () => {
|
|
@@ -445,15 +452,34 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
|
|
|
445
452
|
expect(r.hasTrailingProse).toBe(true)
|
|
446
453
|
})
|
|
447
454
|
|
|
448
|
-
it('leading narration + a real sub-200 answer block → only the answer is delivered (
|
|
455
|
+
it('leading narration FOLLOWED BY A TOOL + a real sub-200 answer block → only the answer is delivered (structural strip)', () => {
|
|
456
|
+
// #3513 follow-up — the narration is stripped by the STRUCTURAL signal (a
|
|
457
|
+
// work-tool followed it), not by wording. The terminal answer block (no tool
|
|
458
|
+
// after it) is the coalescer's selection.
|
|
449
459
|
const NARR = 'Let me pull the numbers first…' // narration opener + …
|
|
450
460
|
const ANSWER = 'Revenue was up 12% quarter-over-quarter, driven mostly by the new enterprise tier.' // ~85, real
|
|
451
|
-
const text = jsonl(
|
|
461
|
+
const text = jsonl(
|
|
462
|
+
ENQUEUE,
|
|
463
|
+
assistantText(NARR),
|
|
464
|
+
assistantToolUse('Bash', { command: 'psql -c "select ..."' }),
|
|
465
|
+
assistantText(ANSWER),
|
|
466
|
+
)
|
|
452
467
|
const r = scanTurnForFinalReply(text)
|
|
453
468
|
expect(r.decided).toBe('block')
|
|
454
469
|
expect(r.pendingText).toBe(ANSWER)
|
|
455
470
|
})
|
|
456
471
|
|
|
472
|
+
it('leading narration with NO tool + a real answer → both delivered joined (fail-open; no wording strip on the backstop path, #3513)', () => {
|
|
473
|
+
// Without a structural continuation signal the coalescer delivers the full
|
|
474
|
+
// terminal run — the wording-based strip is gone (it was provably incomplete).
|
|
475
|
+
const NARR = 'Let me pull the numbers first…'
|
|
476
|
+
const ANSWER = 'Revenue was up 12% quarter-over-quarter, driven mostly by the new enterprise tier.'
|
|
477
|
+
const text = jsonl(ENQUEUE, assistantText(NARR), assistantText(ANSWER))
|
|
478
|
+
const r = scanTurnForFinalReply(text)
|
|
479
|
+
expect(r.decided).toBe('block')
|
|
480
|
+
expect(r.pendingText).toBe(`${NARR}\n\n${ANSWER}`)
|
|
481
|
+
})
|
|
482
|
+
|
|
457
483
|
it('trailing narration after a delivered reply, all sub-floor → block only if a real ≥floor block exists; here → allow, no pendingText', () => {
|
|
458
484
|
// Each trailing block is sub-floor, so `sawUndeliveredTextAfterAllow` is
|
|
459
485
|
// false → allow. (The old joined-floor logic never affected the block
|
|
@@ -469,18 +495,21 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
|
|
|
469
495
|
expect(r.pendingText).toBeUndefined()
|
|
470
496
|
})
|
|
471
497
|
|
|
472
|
-
it('a genuine ≥floor answer followed by a SHORT closer →
|
|
473
|
-
// "big answer then short closer"
|
|
474
|
-
//
|
|
498
|
+
it('a genuine ≥floor answer followed by a SHORT closer → both delivered as the terminal run (answer never dropped, #3513)', () => {
|
|
499
|
+
// #3513 follow-up — "big answer then short closer", NO tool after either
|
|
500
|
+
// block, so both are the terminal run and are delivered JOINED. The #3228
|
|
501
|
+
// concern (a short closer DISPLACING the answer) cannot happen: the coalescer
|
|
502
|
+
// never drops the answer — it delivers the whole terminal run.
|
|
475
503
|
const answer = 'Here is the real answer you were waiting for: ' + 'A'.repeat(300)
|
|
504
|
+
const closer = 'Let me know if you need anything else.'
|
|
476
505
|
const text = jsonl(
|
|
477
506
|
ENQUEUE,
|
|
478
507
|
assistantText(answer),
|
|
479
|
-
assistantText(
|
|
508
|
+
assistantText(closer),
|
|
480
509
|
)
|
|
481
510
|
const r = scanTurnForFinalReply(text)
|
|
482
511
|
expect(r.decided).toBe('block')
|
|
483
|
-
expect(r.pendingText).toBe(answer)
|
|
512
|
+
expect(r.pendingText).toBe(`${answer}\n\n${closer}`)
|
|
484
513
|
})
|
|
485
514
|
|
|
486
515
|
it('substance-floor boundary: a single trailing block at 199/200/201 chars', () => {
|
|
@@ -787,11 +787,17 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
|
|
|
787
787
|
expect(lowered.text).toBe(joined)
|
|
788
788
|
})
|
|
789
789
|
|
|
790
|
-
it('review item 3:
|
|
790
|
+
it('review item 3: intra-turn narration blocks each FOLLOWED BY A TOOL → NO pendingText (structural suppression, #3513)', () => {
|
|
791
|
+
// #3513 follow-up — each narration block is followed by a turn-continuing
|
|
792
|
+
// tool_use, so the deterministic coalescer suppresses BOTH unconditionally.
|
|
793
|
+
// The terminal run is empty and the last (tool-followed) block is sub-200 →
|
|
794
|
+
// the empty-terminal corner returns null → no pendingText, no masquerade.
|
|
791
795
|
const transcript = writeTranscript([
|
|
792
796
|
ENQUEUE,
|
|
793
797
|
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Let me check the first source now, scanning the rows…' }] } },
|
|
798
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] } },
|
|
794
799
|
{ type: 'assistant', message: { content: [{ type: 'text', text: "Now let me query the second source; hang tight…" }] } },
|
|
800
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: '/tmp/x' } }] } },
|
|
795
801
|
])
|
|
796
802
|
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
797
803
|
expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
|
|
@@ -683,13 +683,36 @@ describe('selectFlushDeliveryText — deliver the terminal answer, strip only na
|
|
|
683
683
|
expect(out).toBe('Here are the results:')
|
|
684
684
|
})
|
|
685
685
|
|
|
686
|
-
|
|
686
|
+
// #3513 follow-up — decideTurnFlush now routes through the deterministic
|
|
687
|
+
// `selectBackstopDelivery` coalescer, NOT the wording heuristic. With NO
|
|
688
|
+
// structural provenance (no `capturedBlockMeta`, so no block was followed by a
|
|
689
|
+
// turn-continuing tool_use) EVERY block is part of the terminal run and is
|
|
690
|
+
// delivered joined — fail OPEN, never drop. The wording-based narration strip
|
|
691
|
+
// is gone on the backstop path (it was provably incomplete; #3513). When the
|
|
692
|
+
// structural signal IS present it, and only it, suppresses.
|
|
693
|
+
it('decideTurnFlush delivers the full terminal run when NO tool-follow provenance is present (fail-open)', () => {
|
|
687
694
|
const decision = decideTurnFlush({
|
|
688
695
|
chatId: 'chat1',
|
|
689
696
|
replyCalled: false,
|
|
690
697
|
capturedText: ['Let me check.', 'Now let me compose the reply.', answer],
|
|
691
698
|
})
|
|
692
699
|
expect(decision.kind).toBe('flush')
|
|
700
|
+
if (decision.kind === 'flush') {
|
|
701
|
+
expect(decision.text).toBe(`Let me check.\n\nNow let me compose the reply.\n\n${answer}`)
|
|
702
|
+
}
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it('decideTurnFlush suppresses tool-followed blocks UNCONDITIONALLY (structural provenance drives the coalescer)', () => {
|
|
706
|
+
// The first two blocks were each followed by a turn-continuing tool_use →
|
|
707
|
+
// intra-turn narration → suppressed with NO length/wording gate. Only the
|
|
708
|
+
// terminal answer (not tool-followed) survives.
|
|
709
|
+
const decision = decideTurnFlush({
|
|
710
|
+
chatId: 'chat1',
|
|
711
|
+
replyCalled: false,
|
|
712
|
+
capturedText: ['Let me check.', 'Now let me compose the reply.', answer],
|
|
713
|
+
capturedBlockMeta: [true, true, false],
|
|
714
|
+
})
|
|
715
|
+
expect(decision.kind).toBe('flush')
|
|
693
716
|
if (decision.kind === 'flush') {
|
|
694
717
|
expect(decision.text).toBe(answer)
|
|
695
718
|
expect(decision.text).not.toContain('Let me check')
|
|
@@ -832,11 +855,20 @@ describe('selectFlushDeliveryText — structural provenance (followedByToolUse)
|
|
|
832
855
|
// trailing ellipsis/colon — so only the structural flag could strip it.
|
|
833
856
|
const wrapUp = 'Saved that to memory.'
|
|
834
857
|
// meta[0]=true (a memory tool_use followed the real answer), [1]=false.
|
|
858
|
+
// The STANDALONE `selectFlushDeliveryText` (the old #3237 substance-gated
|
|
859
|
+
// function, unchanged and still exported) keeps the substantial block.
|
|
835
860
|
const out = selectFlushDeliveryText([realAnswer, wrapUp], [true, false])
|
|
836
861
|
expect(out).toBe(`${realAnswer}\n\n${wrapUp}`)
|
|
837
862
|
expect(out).toContain('The migration completed cleanly')
|
|
838
863
|
|
|
839
|
-
//
|
|
864
|
+
// #3513 follow-up — decideTurnFlush now routes through
|
|
865
|
+
// `selectBackstopDelivery`, which suppresses a tool-followed block
|
|
866
|
+
// UNCONDITIONALLY (no substantive-floor carve-out). This DELIBERATELY
|
|
867
|
+
// overturns #3237's substance-gate ON THE BACKSTOP PATH: a real answer the
|
|
868
|
+
// model followed with a turn-continuing tool_use is treated as intra-turn
|
|
869
|
+
// and excluded; only the terminal (non-tool-followed) block is delivered.
|
|
870
|
+
// The deterministic contract is "the terminal run wins" — the model keeps
|
|
871
|
+
// its answer by ending on it or calling the reply tool.
|
|
840
872
|
const decision = decideTurnFlush({
|
|
841
873
|
chatId: 'chat1',
|
|
842
874
|
replyCalled: false,
|
|
@@ -845,7 +877,7 @@ describe('selectFlushDeliveryText — structural provenance (followedByToolUse)
|
|
|
845
877
|
})
|
|
846
878
|
expect(decision.kind).toBe('flush')
|
|
847
879
|
if (decision.kind === 'flush') {
|
|
848
|
-
expect(decision.text).toBe(
|
|
880
|
+
expect(decision.text).toBe(wrapUp)
|
|
849
881
|
}
|
|
850
882
|
})
|
|
851
883
|
|
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* and can be set to `0` / `false` / `off` to disable without a rebuild.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import {
|
|
23
|
+
isNarrationBlock,
|
|
24
|
+
isStructuralNarration,
|
|
25
|
+
selectBackstopDelivery,
|
|
26
|
+
SUBSTANTIVE_MIN_CHARS,
|
|
27
|
+
} from './hooks/narration-classify.mjs'
|
|
28
|
+
|
|
22
29
|
const SILENT_MARKERS = new Set(['NO_REPLY', 'HEARTBEAT_OK'])
|
|
23
30
|
// Small buffer so `NO_REPLY.` with a stray period still counts as silent.
|
|
24
31
|
const SILENT_MARKER_MAX_LEN = Math.max(
|
|
@@ -134,7 +141,7 @@ export function endsWithSilentMarker(text: string | undefined): boolean {
|
|
|
134
141
|
* `hooks/silent-end-scan.mjs` — the same bar the codebase uses everywhere to
|
|
135
142
|
* recognise "this text block is a real answer, not a short narration/closer".
|
|
136
143
|
*/
|
|
137
|
-
export const FLUSH_SUBSTANTIVE_MIN_CHARS =
|
|
144
|
+
export const FLUSH_SUBSTANTIVE_MIN_CHARS = SUBSTANTIVE_MIN_CHARS
|
|
138
145
|
|
|
139
146
|
/**
|
|
140
147
|
* Pick the text a turn-flush should actually DELIVER from the captured
|
|
@@ -203,9 +210,33 @@ export function selectFlushDeliveryText(
|
|
|
203
210
|
.map((b, i) => ({ text: b.trim(), followedByToolUse: followedByToolUse?.[i] }))
|
|
204
211
|
.filter(c => c.text.length > 0)
|
|
205
212
|
if (candidates.length === 0) return ''
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
|
|
214
|
+
// #3513 — the STRUCTURAL rule now applies to the TERMINAL block too, not only
|
|
215
|
+
// preceding blocks. A lone trailing block that a `tool_use` FOLLOWED (the
|
|
216
|
+
// model kept acting after writing it) is intra-turn narration by construction,
|
|
217
|
+
// never the terminal answer — so it must not be delivered as one. This is the
|
|
218
|
+
// primary fix for the observed leak ("Now checking the gateway logs…"),
|
|
219
|
+
// wording-independent where the opener regex fails. It stays substance-gated
|
|
220
|
+
// (correction 3): only a SHORT or narration-shaped block that a tool followed
|
|
221
|
+
// is suppressed — a SUBSTANTIVE, non-narration block followed by a
|
|
222
|
+
// non-delivering tool (react / pin / typing / edit) is NOT structural
|
|
223
|
+
// narration and still delivers.
|
|
224
|
+
//
|
|
225
|
+
// Only the STRUCTURAL signal (`followedByToolUse === true`) may strip the
|
|
226
|
+
// terminal block; the opener/trailer heuristic remains a residual tie-breaker
|
|
227
|
+
// for PRECEDING blocks only, so a provenance-less single block still delivers
|
|
228
|
+
// verbatim (#3276 finding 7 — a colon-terminated line that IS the whole answer
|
|
229
|
+
// is never dropped).
|
|
230
|
+
let cs = candidates
|
|
231
|
+
while (cs.length > 1 && isStructuralNarration(cs[cs.length - 1].text, cs[cs.length - 1].followedByToolUse)) {
|
|
232
|
+
cs = cs.slice(0, -1)
|
|
233
|
+
}
|
|
234
|
+
if (cs.length === 1) {
|
|
235
|
+
return isStructuralNarration(cs[0].text, cs[0].followedByToolUse) ? '' : cs[0].text
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const answer = cs[cs.length - 1].text
|
|
239
|
+
const preceding = cs.slice(0, -1)
|
|
209
240
|
// Deliver only the terminal answer when every earlier block is
|
|
210
241
|
// intent-narration. Per block:
|
|
211
242
|
// - structural flag TRUE ⇒ a tool_use followed this block, but that alone
|
|
@@ -228,50 +259,15 @@ export function selectFlushDeliveryText(
|
|
|
228
259
|
? false
|
|
229
260
|
: isNarrationBlock(c.text),
|
|
230
261
|
)
|
|
231
|
-
return allNarration ? answer :
|
|
262
|
+
return allNarration ? answer : cs.map(c => c.text).join('\n\n')
|
|
232
263
|
}
|
|
233
264
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
* legitimate multi-paragraph answer content — so gating on length either drops a
|
|
241
|
-
* short real answer or keeps a long narration. When earlier blocks don't match
|
|
242
|
-
* this opener we keep the full joined text (never drop content we can't
|
|
243
|
-
* confidently attribute to narration).
|
|
244
|
-
*/
|
|
245
|
-
const NARRATION_OPENER =
|
|
246
|
-
/^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* #3276 guard 8 — the `NARRATION_OPENER` regex alone misses common progress
|
|
250
|
-
* narration that opens with a gerund/present-continuous verb and trails off
|
|
251
|
-
* into an ellipsis or colon: "Checking now…", "Pulling the numbers:",
|
|
252
|
-
* "Looking into that…". Relying on the opener regex leaked those blocks into
|
|
253
|
-
* the delivered answer. This recognises a NON-terminal narration line
|
|
254
|
-
* deterministically, WITHOUT re-gating on a min-char floor (a short real
|
|
255
|
-
* answer like "Yes, done." must still deliver): a single short line that ends
|
|
256
|
-
* with an ellipsis or a colon is progress narration, not the terminal answer.
|
|
257
|
-
*
|
|
258
|
-
* Kept conservative on purpose — only a SINGLE-line block (no internal
|
|
259
|
-
* paragraph) under the substantive floor, ending in `…` / `...` / `:`, so a
|
|
260
|
-
* genuine multi-paragraph answer that happens to end a paragraph with a colon
|
|
261
|
-
* is never mistaken for narration.
|
|
262
|
-
*/
|
|
263
|
-
const NARRATION_TRAILER = /(?:\.{3}|…|:)\s*$/
|
|
264
|
-
|
|
265
|
-
function isTrailingNarrationLine(block: string): boolean {
|
|
266
|
-
const t = block.trim()
|
|
267
|
-
if (t.length === 0 || t.length >= FLUSH_SUBSTANTIVE_MIN_CHARS) return false
|
|
268
|
-
if (t.includes('\n')) return false
|
|
269
|
-
return NARRATION_TRAILER.test(t)
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function isNarrationBlock(block: string): boolean {
|
|
273
|
-
return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block)
|
|
274
|
-
}
|
|
265
|
+
// The narration heuristic (`isNarrationBlock` / openers / trailer) and the
|
|
266
|
+
// structural rule (`isStructuralNarration`) now live in the ONE shared
|
|
267
|
+
// classifier `hooks/narration-classify.mjs`, imported at the top of this file
|
|
268
|
+
// and by the unbundled Stop hook (`hooks/silent-end-scan.mjs`) alike — so the
|
|
269
|
+
// TS gateway side and the `.mjs` side can never drift (switchroom#3513
|
|
270
|
+
// correction 2; retires the old "MUST stay in sync" hand-synced copies).
|
|
275
271
|
|
|
276
272
|
export type FlushDecision =
|
|
277
273
|
| { kind: 'flush'; text: string }
|
|
@@ -283,6 +279,11 @@ export type FlushSkipReason =
|
|
|
283
279
|
| 'no-inbound-chat'
|
|
284
280
|
| 'empty-text'
|
|
285
281
|
| 'silent-marker'
|
|
282
|
+
// A prior BACKSTOP (E2 answer-ready flush, or an out-of-process E3/E4 machine
|
|
283
|
+
// across a crash/restart) already delivered this turn's answer — the durable
|
|
284
|
+
// exactly-once-among-backstops guard (#3513 follow-up, MF2). Set by the
|
|
285
|
+
// caller, not `decideTurnFlush`.
|
|
286
|
+
| 'already-delivered'
|
|
286
287
|
|
|
287
288
|
export interface FlushDecisionInput {
|
|
288
289
|
/** Inbound chat the turn was servicing. `null` means system-initiated /
|
|
@@ -375,14 +376,26 @@ export function decideTurnFlush(input: FlushDecisionInput): FlushDecision {
|
|
|
375
376
|
// sentinel — treat the whole turn as intentionally silent rather than
|
|
376
377
|
// flush the prose with the sentinel glued on.
|
|
377
378
|
if (endsWithSilentMarker(joined)) return { kind: 'skip', reason: 'silent-marker' }
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
379
|
+
// #3513 follow-up — deterministic, model-discipline-free coalescing. The
|
|
380
|
+
// DELIVERED text is the TERMINAL RUN (the suffix of blocks NOT followed by a
|
|
381
|
+
// turn-continuing tool_use); every tool-followed block is suppressed
|
|
382
|
+
// UNCONDITIONALLY (no length/wording gate), replacing #3515's substance-gated
|
|
383
|
+
// heuristic on this backstop path. `selectBackstopDelivery` returns null when
|
|
384
|
+
// nothing terminal survives (a short tool-followed fragment) — treat that as
|
|
385
|
+
// 'empty-text' so the turn routes to the Stop-hook re-prompt ladder rather
|
|
386
|
+
// than delivering an interim narration line. The silent-marker / empty guards
|
|
387
|
+
// above still run on the full `joined` string so a partly-silent turn is
|
|
388
|
+
// classified correctly.
|
|
389
|
+
const selected = selectBackstopDelivery(
|
|
390
|
+
input.capturedText.map((text, i) => ({
|
|
391
|
+
text,
|
|
392
|
+
followedByToolUse: input.capturedBlockMeta?.[i],
|
|
393
|
+
})),
|
|
394
|
+
)
|
|
395
|
+
if (selected == null || selected.text.trim().length === 0) {
|
|
396
|
+
return { kind: 'skip', reason: 'empty-text' }
|
|
385
397
|
}
|
|
398
|
+
return { kind: 'flush', text: selected.text }
|
|
386
399
|
}
|
|
387
400
|
|
|
388
401
|
/**
|