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.
Files changed (25) hide show
  1. package/dist/cli/switchroom.js +24 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  5. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  6. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  7. package/profiles/coding/CLAUDE.md.hbs +1 -1
  8. package/telegram-plugin/dist/gateway/gateway.js +268 -61
  9. package/telegram-plugin/flushed-turn-supersede.ts +230 -0
  10. package/telegram-plugin/gateway/gateway.ts +88 -1
  11. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  12. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  13. package/telegram-plugin/subagent-watcher.ts +86 -1
  14. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +206 -0
  15. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  16. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  17. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  18. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +7 -5
  19. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  20. package/telegram-plugin/tests/turn-flush-safety.test.ts +71 -0
  21. package/telegram-plugin/tests/worker-activity-feed.test.ts +13 -8
  22. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +196 -0
  23. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +40 -0
  24. package/telegram-plugin/turn-flush-safety.ts +74 -1
  25. 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('does not falsely finalize on a shed terminal edit re-drives the finalize once the gate clears', async () => {
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 (undefined).
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
- // NOT finalized: the handle survives (pendingFinish staged) rather than
1537
- // being torn down with the card frozen on its last running render.
1538
- expect(feed.has('w1')).toBe(true)
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
- // Re-drive with the gate clear: the terminal recap lands and the handle
1541
- // finalizes.
1545
+ // A heartbeat with the gate clear re-drives the staged recap it lands.
1542
1546
  clock = 30_000
1543
- await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
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
- return { kind: 'flush', text: joined }
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
  /**