switchroom 0.18.20 → 0.18.22
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 +24 -1
- 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/dist/gateway/gateway.js +268 -61
- package/telegram-plugin/flushed-turn-supersede.ts +230 -0
- package/telegram-plugin/gateway/gateway.ts +88 -1
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/subagent-watcher.ts +86 -1
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +206 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- 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 +7 -5
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +71 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +13 -8
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +196 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +40 -0
- package/telegram-plugin/turn-flush-safety.ts +74 -1
- package/telegram-plugin/worker-activity-feed.ts +155 -45
|
@@ -1513,7 +1513,7 @@ describe('worker-feed send-gate shed contract', () => {
|
|
|
1513
1513
|
expect(bot.edits[0].text).toContain('2 tools')
|
|
1514
1514
|
})
|
|
1515
1515
|
|
|
1516
|
-
it('
|
|
1516
|
+
it('a shed terminal edit drops the finished row immediately but stages the recap for a heartbeat re-drive (#3207)', async () => {
|
|
1517
1517
|
let clock = 10_000
|
|
1518
1518
|
const bot = makeGateBot(() => 0)
|
|
1519
1519
|
const feed = createWorkerActivityFeed({
|
|
@@ -1522,25 +1522,30 @@ describe('worker-feed send-gate shed contract', () => {
|
|
|
1522
1522
|
firstPaintMinMs: 0,
|
|
1523
1523
|
minEditIntervalMs: 0,
|
|
1524
1524
|
floodWaitRemainingMs: () => 0,
|
|
1525
|
+
setInterval: () => 1,
|
|
1526
|
+
clearInterval: () => {},
|
|
1525
1527
|
})
|
|
1526
1528
|
|
|
1527
1529
|
await feed.update('w1', 'chat', view({ toolCount: 1 }))
|
|
1528
1530
|
expect(bot.sent).toHaveLength(1)
|
|
1529
1531
|
|
|
1530
|
-
// Gate sheds the terminal edit (
|
|
1532
|
+
// Gate sheds the terminal edit (SEND_GATE_SHED sentinel).
|
|
1531
1533
|
clock = 20_000
|
|
1532
1534
|
bot.shedNextEdit = true
|
|
1533
1535
|
await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
|
|
1534
1536
|
expect(bot.editCalls).toBe(1)
|
|
1535
1537
|
expect(bot.edits).toHaveLength(0)
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
|
|
1538
|
+
// #3207: the finished row is dropped RIGHT AWAY — it is NOT kept alive to
|
|
1539
|
+
// leak the group/pin when the terminal edit fails. The recap is staged for
|
|
1540
|
+
// the heartbeat re-drive instead (a second finish() would be a no-op: the
|
|
1541
|
+
// agent is already finalized + removed).
|
|
1542
|
+
expect(feed.has('w1')).toBe(false)
|
|
1543
|
+
expect(feed.size).toBe(0)
|
|
1539
1544
|
|
|
1540
|
-
//
|
|
1541
|
-
// finalizes.
|
|
1545
|
+
// A heartbeat with the gate clear re-drives the staged recap → it lands.
|
|
1542
1546
|
clock = 30_000
|
|
1543
|
-
|
|
1547
|
+
feed.heartbeatTick()
|
|
1548
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
1544
1549
|
expect(bot.edits).toHaveLength(1)
|
|
1545
1550
|
expect(bot.edits[0].text).toContain('_done · 5 tools')
|
|
1546
1551
|
expect(feed.has('w1')).toBe(false)
|
|
@@ -443,6 +443,202 @@ describe('coalesced worker feed — GROUP-level pin lifecycle (#3207 review)', (
|
|
|
443
443
|
})
|
|
444
444
|
})
|
|
445
445
|
|
|
446
|
+
// ─── #3207 leaked-finished-row fix ────────────────────────────────────────────
|
|
447
|
+
//
|
|
448
|
+
// Root cause of the "progress card never unpins; later workers append to a
|
|
449
|
+
// stale pinned message" incident (a card observed pinned + edited for 2h12m):
|
|
450
|
+
// a finished worker row whose terminal edit hit a 429/flood window was left in
|
|
451
|
+
// `g.workers`, so the group never emptied → never deleted → never unpinned, and
|
|
452
|
+
// a later worker inherited the stale message. These outcome tests pin the fix:
|
|
453
|
+
// group emptiness / deletion / unpin are decoupled from terminal-edit success,
|
|
454
|
+
// the finalize staging is per-agent (no single-slot overwrite), and a later
|
|
455
|
+
// worker never reuses a terminated group's message.
|
|
456
|
+
|
|
457
|
+
describe('coalesced worker feed — leaked finished-row fix (#3207)', () => {
|
|
458
|
+
interface PinCall {
|
|
459
|
+
feedKey: string
|
|
460
|
+
chatId: string
|
|
461
|
+
messageId: number | null
|
|
462
|
+
}
|
|
463
|
+
function leakHarness(opts: { failEdits?: () => unknown } = {}) {
|
|
464
|
+
const pins: PinCall[] = []
|
|
465
|
+
const sent: { chatId: string; text: string; messageId: number }[] = []
|
|
466
|
+
const edits: { messageId: number; text: string }[] = []
|
|
467
|
+
let seq = 700
|
|
468
|
+
let clock = 0
|
|
469
|
+
const bot: BotApiForWorkerFeed = {
|
|
470
|
+
sendMessage: async (chatId, text) => {
|
|
471
|
+
const messageId = seq++
|
|
472
|
+
sent.push({ chatId, text, messageId })
|
|
473
|
+
return { message_id: messageId }
|
|
474
|
+
},
|
|
475
|
+
editMessageText: async (_chatId, messageId, text) => {
|
|
476
|
+
const err = opts.failEdits?.()
|
|
477
|
+
if (err != null) throw err
|
|
478
|
+
edits.push({ messageId, text })
|
|
479
|
+
return true
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
const feed = createWorkerActivityFeed({
|
|
483
|
+
bot,
|
|
484
|
+
now: () => clock,
|
|
485
|
+
minEditIntervalMs: 0,
|
|
486
|
+
firstPaintMinMs: 0,
|
|
487
|
+
heartbeatTickMs: 6000,
|
|
488
|
+
setInterval: () => 0,
|
|
489
|
+
clearInterval: () => {},
|
|
490
|
+
reconcilePin: ({ feedKey, chatId, messageId }) => pins.push({ feedKey, chatId, messageId }),
|
|
491
|
+
})
|
|
492
|
+
return { feed, pins, sent, edits, setClock: (t: number) => (clock = t) }
|
|
493
|
+
}
|
|
494
|
+
const done = (desc: string, elapsedMs: number): WorkerActivityView => ({
|
|
495
|
+
description: desc,
|
|
496
|
+
lastTool: null,
|
|
497
|
+
toolCount: 3,
|
|
498
|
+
latestSummary: `${desc} result`,
|
|
499
|
+
elapsedMs,
|
|
500
|
+
state: 'done',
|
|
501
|
+
})
|
|
502
|
+
const drain = () => new Promise((r) => setTimeout(r, 0))
|
|
503
|
+
|
|
504
|
+
it('drops the finished row + requests unpin even when the last terminal edit floods (429)', async () => {
|
|
505
|
+
let flooding = true
|
|
506
|
+
const { feed, pins, sent, edits, setClock } = leakHarness({
|
|
507
|
+
failEdits: () => (flooding ? { error_code: 429, parameters: { retry_after: 2 } } : null),
|
|
508
|
+
})
|
|
509
|
+
setClock(1000)
|
|
510
|
+
await feed.update('w1', 'chat', view('task 1', 'doing', 1000))
|
|
511
|
+
expect(sent).toHaveLength(1)
|
|
512
|
+
const feedKey = pins[pins.length - 1].feedKey
|
|
513
|
+
|
|
514
|
+
// The LAST worker finishes but the terminal recap edit 429s.
|
|
515
|
+
setClock(2000)
|
|
516
|
+
await feed.finish('w1', done('task 1', 2000))
|
|
517
|
+
await drain()
|
|
518
|
+
|
|
519
|
+
// Outcome: the finished row is gone and the pin is RELEASED immediately —
|
|
520
|
+
// neither is blocked by the failed terminal edit.
|
|
521
|
+
expect(feed.has('w1')).toBe(false)
|
|
522
|
+
expect(feed.size).toBe(0)
|
|
523
|
+
// Pin-reaper backstop: no running rows remain → the group is not "running".
|
|
524
|
+
expect(feed.hasRunningInFeed(feedKey)).toBe(false)
|
|
525
|
+
expect(pins[pins.length - 1].messageId).toBeNull()
|
|
526
|
+
// The terminal recap has NOT landed yet (still flooding) — proves the unpin
|
|
527
|
+
// did not wait on it.
|
|
528
|
+
expect(edits.some((e) => e.text.includes('_done ·'))).toBe(false)
|
|
529
|
+
|
|
530
|
+
// Flood clears; a heartbeat past the cooldown re-drives the staged recap.
|
|
531
|
+
flooding = false
|
|
532
|
+
setClock(5000)
|
|
533
|
+
feed.heartbeatTick()
|
|
534
|
+
await drain()
|
|
535
|
+
expect(edits.some((e) => e.text.includes('_done ·'))).toBe(true)
|
|
536
|
+
|
|
537
|
+
// The group is fully reaped: a later worker in the SAME chat paints a FRESH
|
|
538
|
+
// message — it does NOT reuse/edit the terminated group's message id.
|
|
539
|
+
setClock(6000)
|
|
540
|
+
await feed.update('w2', 'chat', view('task 2', 'doing2', 6000))
|
|
541
|
+
await drain()
|
|
542
|
+
expect(sent).toHaveLength(2)
|
|
543
|
+
expect(feed.messageIdOf('w2')).toBe(sent[1].messageId)
|
|
544
|
+
expect(feed.messageIdOf('w2')).not.toBe(sent[0].messageId)
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('TWO workers finishing in the same flood window are BOTH reaped (no single-slot overwrite)', async () => {
|
|
548
|
+
let flooding = true
|
|
549
|
+
const { feed, pins, sent, edits, setClock } = leakHarness({
|
|
550
|
+
failEdits: () => (flooding ? { error_code: 429, parameters: { retry_after: 2 } } : null),
|
|
551
|
+
})
|
|
552
|
+
setClock(1000)
|
|
553
|
+
await feed.update('a', 'chat', view('task A', 'a-doing', 1000))
|
|
554
|
+
await feed.update('b', 'chat', view('task B', 'b-doing', 1000))
|
|
555
|
+
expect(feed.size).toBe(2)
|
|
556
|
+
const feedKey = pins[pins.length - 1].feedKey
|
|
557
|
+
|
|
558
|
+
// Both finish before either finalize chain drains, so both latch terminal in
|
|
559
|
+
// the SAME cooldown window. The old single `pendingFinalize` slot dropped all
|
|
560
|
+
// but one → the other row leaked forever. The per-agent map keeps both.
|
|
561
|
+
setClock(2000)
|
|
562
|
+
const f1 = feed.finish('a', done('task A', 2000))
|
|
563
|
+
const f2 = feed.finish('b', done('task B', 2000))
|
|
564
|
+
await Promise.all([f1, f2])
|
|
565
|
+
await drain()
|
|
566
|
+
|
|
567
|
+
// NEITHER finished row leaks — the group is empty and unpinned.
|
|
568
|
+
expect(feed.size).toBe(0)
|
|
569
|
+
expect(feed.hasRunningInFeed(feedKey)).toBe(false)
|
|
570
|
+
expect(pins[pins.length - 1].messageId).toBeNull()
|
|
571
|
+
|
|
572
|
+
// Flood clears; one heartbeat drains ALL staged recaps and reaps the group.
|
|
573
|
+
flooding = false
|
|
574
|
+
setClock(5000)
|
|
575
|
+
feed.heartbeatTick()
|
|
576
|
+
await drain()
|
|
577
|
+
|
|
578
|
+
// BOTH staged terminal recaps must actually repaint — not just one. This is
|
|
579
|
+
// the contract the per-agent `pendingFinalize` map exists to hold: a single-
|
|
580
|
+
// slot `pendingFinalize` would have let worker B's stage overwrite worker A's,
|
|
581
|
+
// so only ONE recap edit would land and this assertion would FAIL. The
|
|
582
|
+
// decoupled row-removal (fix 1) alone would pass size===0 + unpin above WITHOUT
|
|
583
|
+
// repainting either recap, so those assertions do not pin this contract — these
|
|
584
|
+
// do. Each recap carries its own worker's description ('task A' / 'task B').
|
|
585
|
+
const doneEdits = edits.filter((e) => e.text.includes('_done ·'))
|
|
586
|
+
expect(doneEdits.some((e) => e.text.includes('task A'))).toBe(true)
|
|
587
|
+
expect(doneEdits.some((e) => e.text.includes('task B'))).toBe(true)
|
|
588
|
+
|
|
589
|
+
// The group is gone: a later worker paints a FRESH message, never editing a
|
|
590
|
+
// terminated sibling's message.
|
|
591
|
+
setClock(6000)
|
|
592
|
+
await feed.update('c', 'chat', view('task C', 'c-doing', 6000))
|
|
593
|
+
await drain()
|
|
594
|
+
const firstMsgId = sent[0].messageId
|
|
595
|
+
expect(feed.messageIdOf('c')).toBe(sent[sent.length - 1].messageId)
|
|
596
|
+
expect(feed.messageIdOf('c')).not.toBe(firstMsgId)
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
it('a later worker after all prior workers finished does NOT reuse the prior message id', async () => {
|
|
600
|
+
const { feed, sent, setClock } = leakHarness()
|
|
601
|
+
setClock(1000)
|
|
602
|
+
await feed.update('w1', 'chat', view('task 1', 'doing', 1000))
|
|
603
|
+
setClock(2000)
|
|
604
|
+
await feed.finish('w1', done('task 1', 2000))
|
|
605
|
+
await drain()
|
|
606
|
+
expect(feed.size).toBe(0)
|
|
607
|
+
|
|
608
|
+
// A brand-new worker in the same chat gets its OWN fresh message.
|
|
609
|
+
setClock(3000)
|
|
610
|
+
await feed.update('w2', 'chat', view('task 2', 'doing2', 3000))
|
|
611
|
+
await drain()
|
|
612
|
+
expect(sent).toHaveLength(2)
|
|
613
|
+
expect(feed.messageIdOf('w2')).toBe(sent[1].messageId)
|
|
614
|
+
expect(feed.messageIdOf('w2')).not.toBe(sent[0].messageId)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it('hasRunningInFeed is false once only finished rows remain (pin-reaper backstop fires)', async () => {
|
|
618
|
+
// A sibling keeps the group running; when the LAST running worker finishes,
|
|
619
|
+
// no running row remains → hasRunningInFeed flips to false so the gateway's
|
|
620
|
+
// wk:group: reaper is free to unpin (finished rows never count as running).
|
|
621
|
+
const { feed, pins, setClock } = leakHarness()
|
|
622
|
+
setClock(1000)
|
|
623
|
+
await feed.update('a', 'chat', view('task A', 'a-doing', 1000))
|
|
624
|
+
await feed.update('b', 'chat', view('task B', 'b-doing', 1000))
|
|
625
|
+
const feedKey = pins[pins.length - 1].feedKey
|
|
626
|
+
expect(feed.hasRunningInFeed(feedKey)).toBe(true)
|
|
627
|
+
|
|
628
|
+
setClock(2000)
|
|
629
|
+
await feed.finish('a', done('task A', 2000))
|
|
630
|
+
await drain()
|
|
631
|
+
// One still runs → still counts.
|
|
632
|
+
expect(feed.hasRunningInFeed(feedKey)).toBe(true)
|
|
633
|
+
|
|
634
|
+
setClock(3000)
|
|
635
|
+
await feed.finish('b', done('task B', 3000))
|
|
636
|
+
await drain()
|
|
637
|
+
// Only finished work remains → NOT running.
|
|
638
|
+
expect(feed.hasRunningInFeed(feedKey)).toBe(false)
|
|
639
|
+
})
|
|
640
|
+
})
|
|
641
|
+
|
|
446
642
|
describe('renderCombinedWorkerFeed (pure)', () => {
|
|
447
643
|
const row = (i: number, step: string) => ({
|
|
448
644
|
description: `task number ${i}`,
|
|
@@ -87,6 +87,46 @@ describe('worker terminal state is truthful for reaped workers (Residual B)', ()
|
|
|
87
87
|
expect(last).not.toContain('incomplete')
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
// #3233 terminal-window race: the skeleton first-paint cue fires on EVERY
|
|
91
|
+
// no-growth poll, so one can land on a row that `finish()` just finalized
|
|
92
|
+
// (watcher poll cadence ≈ seconds). The `finalized` latch must absorb it —
|
|
93
|
+
// a late skeleton `update()` must NOT resurrect a fresh `running` card on an
|
|
94
|
+
// already-done worker. This exercises that path with a skeleton-shaped cue
|
|
95
|
+
// (empty step line, running state) arriving after finish.
|
|
96
|
+
it('a late skeleton update() after finish() is absorbed by the finalized latch (no resurrection)', async () => {
|
|
97
|
+
let clock = 1000
|
|
98
|
+
const { feed, edits } = makeFeed(() => clock)
|
|
99
|
+
await feed.update('w', 'chat', runningView('background job', 'doing work', 1000))
|
|
100
|
+
await drain()
|
|
101
|
+
clock = 2000
|
|
102
|
+
await feed.finish('w', {
|
|
103
|
+
description: 'background job',
|
|
104
|
+
lastTool: null,
|
|
105
|
+
toolCount: 5,
|
|
106
|
+
latestSummary: 'the delivered result paragraph',
|
|
107
|
+
elapsedMs: 2000,
|
|
108
|
+
state: 'done',
|
|
109
|
+
})
|
|
110
|
+
await drain()
|
|
111
|
+
const editsAfterFinish = edits.length
|
|
112
|
+
// Late skeleton cue (empty latestSummary, running state) — the shape the
|
|
113
|
+
// watcher emits on a no-growth poll — lands AFTER finalization.
|
|
114
|
+
clock = 2500
|
|
115
|
+
await feed.update('w', 'chat', {
|
|
116
|
+
description: 'background job',
|
|
117
|
+
lastTool: null,
|
|
118
|
+
toolCount: 5,
|
|
119
|
+
latestSummary: '',
|
|
120
|
+
elapsedMs: 2500,
|
|
121
|
+
state: 'running',
|
|
122
|
+
})
|
|
123
|
+
await drain()
|
|
124
|
+
// No further edit, no resurrection: the terminal card stays `done`.
|
|
125
|
+
expect(edits.length).toBe(editsAfterFinish)
|
|
126
|
+
expect(edits[edits.length - 1].text).toContain('done')
|
|
127
|
+
expect(feed.has('w')).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
90
130
|
it('renderWorkerActivity renders the `incomplete` state as a finished card without a fabricated result', () => {
|
|
91
131
|
const card = renderWorkerActivity({
|
|
92
132
|
description: 'background job',
|
|
@@ -128,6 +128,75 @@ export function endsWithSilentMarker(text: string | undefined): boolean {
|
|
|
128
128
|
return isSilentFlushMarker(lines[lines.length - 1])
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Substantive-answer floor (chars, trimmed). Mirrors
|
|
133
|
+
* `final-answer-detect.ts` `FINAL_ANSWER_MIN_CHARS` and
|
|
134
|
+
* `hooks/silent-end-scan.mjs` — the same bar the codebase uses everywhere to
|
|
135
|
+
* recognise "this text block is a real answer, not a short narration/closer".
|
|
136
|
+
*/
|
|
137
|
+
export const FLUSH_SUBSTANTIVE_MIN_CHARS = 200
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Pick the text a turn-flush should actually DELIVER from the captured
|
|
141
|
+
* assistant blocks.
|
|
142
|
+
*
|
|
143
|
+
* The duplicate-reply bug (2026-07) had the answer-ready flush fire while the
|
|
144
|
+
* model was still composing its `reply` tool call: `capturedText` at that
|
|
145
|
+
* moment holds intent-narration blocks ("Let me check X", "I'll now …") FOLLOWED
|
|
146
|
+
* by the composed answer block. Flushing `capturedText.join('\n\n')` dumped the
|
|
147
|
+
* whole narration+answer blob into chat — which then never matched the clean
|
|
148
|
+
* answer-only `reply` on the outbound dedup (containment, not equality), so the
|
|
149
|
+
* user got a narration-laden duplicate.
|
|
150
|
+
*
|
|
151
|
+
* The answer is the TERMINAL block — the last thing the model wrote is what it
|
|
152
|
+
* settled on, REGARDLESS of length. A real dropped answer is often short
|
|
153
|
+
* (a one-line confirmation, a two-sentence reply), so gating delivery on the
|
|
154
|
+
* 200-char substantive floor was wrong: with blocks
|
|
155
|
+
* `[verboseNarration(250), realAnswer(150)]` a reversed length scan returns the
|
|
156
|
+
* 250-char narration and DROPS the 150-char real answer. Instead we take the
|
|
157
|
+
* last non-empty block as the answer and strip only the EARLIER blocks — and
|
|
158
|
+
* only when they look like intent-narration (short, or the classic "Let me…" /
|
|
159
|
+
* "I'll…" openers). If the earlier blocks are themselves substantial (a genuine
|
|
160
|
+
* multi-paragraph answer written as several blocks) we keep the whole thing
|
|
161
|
+
* joined, so we never truncate a real long answer down to its last paragraph.
|
|
162
|
+
*
|
|
163
|
+
* `blocks` are already trimmed/non-empty candidates (silent markers removed by
|
|
164
|
+
* the caller's guards). Returns the chosen delivery text.
|
|
165
|
+
*/
|
|
166
|
+
export function selectFlushDeliveryText(blocks: string[]): string {
|
|
167
|
+
const candidates = blocks
|
|
168
|
+
.map(b => b.trim())
|
|
169
|
+
.filter(b => b.length > 0)
|
|
170
|
+
if (candidates.length === 0) return ''
|
|
171
|
+
if (candidates.length === 1) return candidates[0]
|
|
172
|
+
const answer = candidates[candidates.length - 1]
|
|
173
|
+
const preceding = candidates.slice(0, -1)
|
|
174
|
+
// Deliver only the terminal answer when every earlier block is
|
|
175
|
+
// intent-narration (a short block, or a "Let me…/I'll…/I'm going to…" opener).
|
|
176
|
+
// Otherwise the earlier blocks carry real content — keep the full joined text
|
|
177
|
+
// so a legitimate multi-block answer is never truncated to its last paragraph.
|
|
178
|
+
const allNarration = preceding.every(isNarrationBlock)
|
|
179
|
+
return allNarration ? answer : candidates.join('\n\n')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Narration heuristic: a block that opens with a first-person "about to do X"
|
|
184
|
+
* phrase the model emits BEFORE composing its real answer ("Let me check…",
|
|
185
|
+
* "I'll look it up…", "Now let me…"). Deliberately NOT length-based — the
|
|
186
|
+
* narration that shadowed the real answer in the observed bug was a LONG
|
|
187
|
+
* (≥200-char) "Let me pull the numbers…" block, and short blocks are frequently
|
|
188
|
+
* legitimate multi-paragraph answer content — so gating on length either drops a
|
|
189
|
+
* short real answer or keeps a long narration. When earlier blocks don't match
|
|
190
|
+
* this opener we keep the full joined text (never drop content we can't
|
|
191
|
+
* confidently attribute to narration).
|
|
192
|
+
*/
|
|
193
|
+
const NARRATION_OPENER =
|
|
194
|
+
/^(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
|
|
195
|
+
|
|
196
|
+
function isNarrationBlock(block: string): boolean {
|
|
197
|
+
return NARRATION_OPENER.test(block.trimStart())
|
|
198
|
+
}
|
|
199
|
+
|
|
131
200
|
export type FlushDecision =
|
|
132
201
|
| { kind: 'flush'; text: string }
|
|
133
202
|
| { kind: 'skip'; reason: FlushSkipReason }
|
|
@@ -219,7 +288,11 @@ export function decideTurnFlush(input: FlushDecisionInput): FlushDecision {
|
|
|
219
288
|
// sentinel — treat the whole turn as intentionally silent rather than
|
|
220
289
|
// flush the prose with the sentinel glued on.
|
|
221
290
|
if (endsWithSilentMarker(joined)) return { kind: 'skip', reason: 'silent-marker' }
|
|
222
|
-
|
|
291
|
+
// Deliver only the substantive answer block, never the whole narration+answer
|
|
292
|
+
// blob (see `selectFlushDeliveryText`). The silent-marker / empty guards above
|
|
293
|
+
// still run on the full `joined` string so a partly-silent turn is classified
|
|
294
|
+
// correctly; only the DELIVERED text is narrowed to the answer.
|
|
295
|
+
return { kind: 'flush', text: selectFlushDeliveryText(input.capturedText) }
|
|
223
296
|
}
|
|
224
297
|
|
|
225
298
|
/**
|