switchroom 0.19.43 → 0.19.45

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 (29) hide show
  1. package/dist/agent-scheduler/index.js +4 -1
  2. package/dist/auth-broker/index.js +254 -119
  3. package/dist/cli/notion-write-pretool.mjs +4 -1
  4. package/dist/cli/switchroom.js +3995 -3196
  5. package/dist/host-control/main.js +270 -130
  6. package/dist/vault/approvals/kernel-server.js +201 -66
  7. package/dist/vault/broker/server.js +263 -128
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +11 -4
  10. package/telegram-plugin/dist/gateway/gateway.js +945 -638
  11. package/telegram-plugin/flushed-turn-supersede.ts +190 -16
  12. package/telegram-plugin/gateway/cron-session.ts +31 -0
  13. package/telegram-plugin/gateway/gateway.ts +66 -65
  14. package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
  15. package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
  16. package/telegram-plugin/gateway/stream-render.ts +91 -1
  17. package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
  18. package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
  19. package/telegram-plugin/gateway/turn-end.ts +9 -1
  20. package/telegram-plugin/reply-owner-resolve.ts +102 -26
  21. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
  22. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
  23. package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
  24. package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
  25. package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
  26. package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
  27. package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
  29. package/vendor/hindsight-memory/settings.json +1 -1
@@ -47,12 +47,15 @@ import {
47
47
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
48
48
  import {
49
49
  FlushedTurnSupersedeRegistry,
50
- DEFAULT_SUPERSEDE_TTL_MS,
50
+ SUPERSEDE_OPEN_WINDOW_CAP_MS,
51
+ SUPERSEDE_COMPLETED_GRACE_MS,
51
52
  flushedAnswerMatchesReply,
52
53
  } from '../flushed-turn-supersede.js'
53
54
  import type { ReplyOwnerTier, ReplyOwnerCandidates } from '../reply-owner-resolve.js'
55
+ import { resolveReplyOwnerTier, resolveReplyOwnerTurnId } from '../reply-owner-resolve.js'
54
56
  import { SubagentHandbackMarker, stampsHandbackMarker } from '../gateway/subagent-handback-marker.js'
55
57
  import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
58
+ import { SubagentReplyAuthority } from '../gateway/subagent-reply-authority.js'
56
59
  import { redact } from '../secret-detect/redact.js'
57
60
  import type { CurrentTurn, Access } from '../gateway/gateway.js'
58
61
 
@@ -83,7 +86,7 @@ function ownerRes(
83
86
  quotedTurnId: tier === 'quoted' ? id : null,
84
87
  latestEndedTurnId: id,
85
88
  latestEndedAgeMs: 30_000,
86
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
89
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
87
90
  ...over,
88
91
  },
89
92
  }
@@ -252,6 +255,9 @@ function makeHarness(opts?: {
252
255
  streamKey: key,
253
256
  resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
254
257
  getLastSubagentHandbackAt: () => null,
258
+ // #4176 — default: no sub-agent live (the non-delegating case). Tests that
259
+ // exercise the gate override `h.deps.subagentReplyAuthority`.
260
+ subagentReplyAuthority: { subagentCouldOwnReply: () => false },
255
261
  findTurnByOriginId: () => null,
256
262
  findTurnByQuotedMessageId: () => null,
257
263
  resolveAnswerThreadWithLog: (_c, explicit) => explicit,
@@ -624,10 +630,35 @@ describe('structural wiring — the singleton + the gateway entry point (#2996 P
624
630
  expect(moduleSrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(0)
625
631
  })
626
632
 
627
- it('executeReply is a thin wrapper: pins the turn and delegates to sendReply', () => {
633
+ it('executeReply is a thin wrapper: pins the turn, computes the caller-identity ' +
634
+ 'gate (#4172), and delegates to sendReply', () => {
628
635
  const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
629
636
  const body = after.split('\nasync function ')[0] ?? after
630
- expect(body).toContain('return sendReply(gatewaySendReplyDeps(), { args, turn: currentTurn })')
637
+ expect(body).toContain('return sendReply(gatewaySendReplyDeps(), {')
638
+ expect(body).toContain('turn: currentTurn')
639
+ // #4172 wiring pin — dropping the identity plumbing silently re-opens the
640
+ // cron-edits-over-flushed-answer hole, so its presence is pinned here.
641
+ expect(body).toContain('callerIsForeignSession: replyCallerIsForeignSession(callerAgentName)')
642
+ })
643
+
644
+ it('#4172 wiring — onToolCall threads the calling client identity into the dispatch', () => {
645
+ expect(gatewaySrc).toContain('executeToolCall(msg.tool, msg.args, client.agentName)')
646
+ })
647
+
648
+ it('#4173/#4175 wiring — the owner-resolver candidates carry the completion ' +
649
+ 'window bounds (dropping them now fails CLOSED, not open)', () => {
650
+ // The composition lives in reply-owner-wiring.ts (anti-inflation ratchet);
651
+ // the gateway keeps a thin wrapper that binds its stateful lookups.
652
+ const wiringSrc = readFileSync(
653
+ new URL('../gateway/reply-owner-wiring.ts', import.meta.url),
654
+ 'utf8',
655
+ )
656
+ expect(wiringSrc).toContain('latestEndedTtlMs: SUPERSEDE_OPEN_CAP_MS')
657
+ expect(wiringSrc).toContain('latestEndedRealEndAgeMs')
658
+ expect(wiringSrc).toContain('latestEndedCompletedGraceMs: SUPERSEDE_GRACE_MS')
659
+ const after = gatewaySrc.split('function resolveReplyOwnerTurn(')[1] ?? ''
660
+ const body = after.split('\nfunction ')[0] ?? after
661
+ expect(body).toContain('resolveReplyOwnerTurnWith(')
631
662
  })
632
663
 
633
664
  it('the injected deps carry the singleton + BOTH turn accessors (Amendment 9)', () => {
@@ -1455,7 +1486,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
1455
1486
  const owner = makeFlushDeliveredEndedTurn()
1456
1487
  seedRecord(h, owner)
1457
1488
  h.deps.resolveReplyOwnerTurn = () =>
1458
- ownerRes(owner, 'origin', { latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 1 })
1489
+ ownerRes(owner, 'origin', { latestEndedAgeMs: SUPERSEDE_OPEN_WINDOW_CAP_MS + 1 })
1459
1490
  h.deps.getLastSubagentHandbackAt = () => null
1460
1491
 
1461
1492
  const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
@@ -1467,3 +1498,534 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
1467
1498
  expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1468
1499
  })
1469
1500
  })
1501
+
1502
+ describe('2026-08-01 klanker incident — slow flush→reply gap through the REAL send path', () => {
1503
+ // gateway-supervisor.log, chat 12345: the answer-ready quiescence flush
1504
+ // delivered msg 25680 at 21:17:04, a proactive /compact ran, the Stop hook
1505
+ // nudged the model, and the canonical `reply` landed at 21:19:28 — 143 s
1506
+ // after the flush, REWORDED post-compact (1770 vs 1563 chars). Under the old
1507
+ // 60 s SUPERSEDE_OPEN_WINDOW_CAP_MS the registry record was 'expired' AND the
1508
+ // latest-ended owner tier rejected the 143 s-old turn, so msg 25682 shipped
1509
+ // as a visible duplicate. This drives the REAL sendReply with the incident
1510
+ // timeline and asserts the outcome: the late reply corrects the flushed
1511
+ // message in place — never a second bubble.
1512
+ const OWNER_TURN_ID = `${CHAT}:_#25674`
1513
+ const FLUSH_MSG_ID = 25680
1514
+ const GAP_MS = 143_000
1515
+ const FLUSHED_TEXT =
1516
+ 'Pulled the live numbers from the usage table. Two findings, and the second one matters: ' +
1517
+ 'the per-turn cost is dominated by cache writes, not output tokens, so trimming the reply ' +
1518
+ 'length will not move the bill much.'
1519
+ // Post-compact the model REGENERATED the answer — same substance, different
1520
+ // bytes. Neither exact-text dedup nor containment matching can catch this;
1521
+ // only turnId identity + the content-gate bypass (no handback in window) can.
1522
+ const REWORDED_REPLY =
1523
+ 'I pulled the live numbers out of the usage table. Two findings — and the second is the one ' +
1524
+ 'that matters: cache writes, not output tokens, dominate the per-turn cost, so shortening ' +
1525
+ 'replies will barely move the bill.'
1526
+
1527
+ function makeStaleFlushDeliveredTurn(): CurrentTurn {
1528
+ return {
1529
+ turnId: OWNER_TURN_ID,
1530
+ sessionChatId: CHAT,
1531
+ answerDelivered: 'flush',
1532
+ flushedAnswerText: FLUSHED_TEXT,
1533
+ endedAt: Date.now() - GAP_MS,
1534
+ replyCalled: false,
1535
+ finalAnswerDelivered: true,
1536
+ finalAnswerSubstantive: true,
1537
+ } as unknown as CurrentTurn
1538
+ }
1539
+
1540
+ it('outcome: the reworded reply landing 143 s after the flush edits the flushed ' +
1541
+ 'message in place — exactly one bubble, no duplicate', async () => {
1542
+ const h = makeHarness()
1543
+ const owner = makeStaleFlushDeliveredTurn()
1544
+ // The flush recorded its message 143 s ago (NOT freshly — the incident gap).
1545
+ h.deps.flushedTurnSupersede.record(
1546
+ CHAT,
1547
+ undefined,
1548
+ { turnId: OWNER_TURN_ID, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
1549
+ Date.now() - GAP_MS,
1550
+ )
1551
+ // Late DM reply: no live turn, no origin echo, no quote → latest-ended tier,
1552
+ // with the REAL 143 s age the gateway would compute from `endedAt`.
1553
+ h.deps.resolveReplyOwnerTurn = () =>
1554
+ ownerRes(owner, 'latest-ended', { latestEndedAgeMs: GAP_MS })
1555
+ // No subagent handback anywhere in the window (the incident had none).
1556
+ h.deps.getLastSubagentHandbackAt = () => null
1557
+
1558
+ const res = await sendReply(h.deps, req(REWORDED_REPLY))
1559
+
1560
+ // The flushed message was edited into the canonical reply…
1561
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1562
+ expect(edits).toHaveLength(1)
1563
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1564
+ expect(edits[0]!.text).toContain('cache writes')
1565
+ // …and NO second bubble shipped — the exact duplicate the incident produced.
1566
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1567
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1568
+ expect(res.content[0]!.text).toMatch(/^sent/)
1569
+ })
1570
+ })
1571
+
1572
+
1573
+ describe('#4172 — a foreign-session (cheap-cron) reply can never touch a flushed answer', () => {
1574
+ const OWNER_TURN_ID = `${CHAT}:_#flushed-90`
1575
+ const FLUSH_MSG_ID = 25680
1576
+ const FLUSHED_TEXT =
1577
+ 'Pulled the live numbers from the usage table. Two findings, and the second one matters: ' +
1578
+ 'the per-turn cost is dominated by cache writes, not output tokens, so trimming reply ' +
1579
+ 'length will not move the bill much.'
1580
+ // The measured #4172 shape: an unrelated Tier-1 cron digest landing 90 s
1581
+ // after the flush — decoupled (no live turn), foreign content, NO handback
1582
+ // marker (a cheap-cron fire never traverses pendingInboundBuffer.push).
1583
+ const CRON_DIGEST =
1584
+ 'Morning digest: 3 PRs merged overnight, CI green on main, no pages. ' +
1585
+ 'Standup is at 9:30 and the vault broker cert renews tomorrow.'
1586
+
1587
+ function makeFlushedOwner(): CurrentTurn {
1588
+ return {
1589
+ turnId: OWNER_TURN_ID,
1590
+ sessionChatId: CHAT,
1591
+ answerDelivered: 'flush',
1592
+ flushedAnswerText: FLUSHED_TEXT,
1593
+ endedAt: Date.now() - 90_000,
1594
+ // #4173 — window OPEN: the flush ended the turn synthetically and the
1595
+ // session's real turn_end has not been observed.
1596
+ realEndObservedAt: null,
1597
+ replyCalled: false,
1598
+ finalAnswerDelivered: true,
1599
+ finalAnswerSubstantive: true,
1600
+ } as unknown as CurrentTurn
1601
+ }
1602
+
1603
+ function seed(h: ReturnType<typeof makeHarness>): CurrentTurn {
1604
+ const owner = makeFlushedOwner()
1605
+ h.deps.flushedTurnSupersede.record(
1606
+ CHAT,
1607
+ undefined,
1608
+ { turnId: OWNER_TURN_ID, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
1609
+ Date.now() - 90_000,
1610
+ )
1611
+ // Latest-ended tier, corroborated, no marker → the bypass would apply for
1612
+ // a MAIN-session reply. The identity gate is the only thing standing
1613
+ // between the cron digest and the flushed answer.
1614
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'latest-ended')
1615
+ h.deps.getLastSubagentHandbackAt = () => null
1616
+ return owner
1617
+ }
1618
+
1619
+ it('BLOCKER REPRO: the cron digest sends FRESH — zero edits/deletes of the ' +
1620
+ 'flushed message, record intact for the turn\'s own replay', async () => {
1621
+ const h = makeHarness()
1622
+ seed(h)
1623
+
1624
+ const res = await sendReply(h.deps, {
1625
+ args: { chat_id: CHAT, text: CRON_DIGEST },
1626
+ turn: null,
1627
+ callerIsForeignSession: true, // the `<agent>-cron` bridge (#4172)
1628
+ })
1629
+
1630
+ // OUTCOME: msg A untouched; the digest is a fresh, NOTIFYING message.
1631
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1632
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1633
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
1634
+ expect(fresh).toHaveLength(1)
1635
+ expect(fresh[0]!.text).toContain('Morning digest')
1636
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
1637
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1638
+ // The record survives for the flushed turn's own genuine late replay.
1639
+ expect(
1640
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
1641
+ liveTurnId: OWNER_TURN_ID,
1642
+ now: Date.now(),
1643
+ }).supersede,
1644
+ ).toBe(true)
1645
+ })
1646
+
1647
+ it('NEGATIVE CONTROL (the measured failure): the SAME reply on the main-bridge ' +
1648
+ 'path destroys the flushed answer by edit — proving the identity gate is ' +
1649
+ 'load-bearing, not incidental', async () => {
1650
+ const h = makeHarness()
1651
+ seed(h)
1652
+
1653
+ // Identical scenario, but the caller is treated as the main session (the
1654
+ // pre-fix behaviour): bypass applies → the digest edits over msg A — the
1655
+ // #4172 double loss (flushed answer destroyed, digest delivered silently).
1656
+ await sendReply(h.deps, {
1657
+ args: { chat_id: CHAT, text: CRON_DIGEST },
1658
+ turn: null,
1659
+ callerIsForeignSession: false,
1660
+ })
1661
+
1662
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1663
+ expect(edits).toHaveLength(1)
1664
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1665
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1666
+ })
1667
+
1668
+ it('a foreign-session reply is never swallowed by the flush-armed latch ' +
1669
+ '(pre-record race window)', async () => {
1670
+ // The flush FIRED but has not recorded yet (no supersede record, latch
1671
+ // armed, no flushed text stashed → replyMatchesFlushedAnswer resolves
1672
+ // null). A main-session late reply is suppressed here (the flush race);
1673
+ // the cron digest must NOT be — suppressing it silently drops the user's
1674
+ // scheduled output.
1675
+ const h = makeHarness()
1676
+ const owner = {
1677
+ ...makeFlushedOwner(),
1678
+ flushedAnswerText: null,
1679
+ } as unknown as CurrentTurn
1680
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'latest-ended')
1681
+ h.deps.getLastSubagentHandbackAt = () => null
1682
+
1683
+ const res = await sendReply(h.deps, {
1684
+ args: { chat_id: CHAT, text: CRON_DIGEST.repeat(2) }, // ≥200 chars → substantive
1685
+ turn: null,
1686
+ callerIsForeignSession: true,
1687
+ })
1688
+
1689
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1690
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
1691
+ })
1692
+ })
1693
+
1694
+ describe('#4173 — the completed window closes the same-bridge decoupled-reply hole ' +
1695
+ '(Task sub-agent replying after the parent turn ended)', () => {
1696
+ const OWNER_TURN_ID = `${CHAT}:_#flushed-77`
1697
+ const FLUSH_MSG_ID = 31337
1698
+ const FLUSHED_TEXT =
1699
+ 'Deployment review finished: the canary is healthy, error rates are flat, and the ' +
1700
+ 'rollout can proceed to the next ring this afternoon.'
1701
+ const LATE_WORKER_REPLY =
1702
+ 'Background worker done: the log-archive sweep compressed 14 GB across three agents ' +
1703
+ 'and freed the disk pressure alert. Full manifest is in the shared drive.'
1704
+
1705
+ it('a foreign-content reply landing 90 s AFTER the real turn_end resolves NO owner ' +
1706
+ 'through the REAL tier rule → sends fresh, flushed answer untouched', async () => {
1707
+ const h = makeHarness()
1708
+ const realEndAgo = 90_000 // > SUPERSEDE_COMPLETED_GRACE_MS
1709
+ const owner = {
1710
+ turnId: OWNER_TURN_ID,
1711
+ sessionChatId: CHAT,
1712
+ answerDelivered: 'flush',
1713
+ flushedAnswerText: FLUSHED_TEXT,
1714
+ endedAt: Date.now() - realEndAgo,
1715
+ realEndObservedAt: Date.now() - realEndAgo, // real turn_end OBSERVED
1716
+ replyCalled: false,
1717
+ finalAnswerDelivered: true,
1718
+ finalAnswerSubstantive: true,
1719
+ } as unknown as CurrentTurn
1720
+ h.deps.flushedTurnSupersede.record(
1721
+ CHAT,
1722
+ undefined,
1723
+ {
1724
+ turnId: OWNER_TURN_ID,
1725
+ messageIds: [FLUSH_MSG_ID],
1726
+ text: FLUSHED_TEXT,
1727
+ completedAt: Date.now() - realEndAgo,
1728
+ },
1729
+ Date.now() - realEndAgo,
1730
+ )
1731
+ // Drive the REAL acceptance rule (the exact composition the gateway runs):
1732
+ // the candidates carry the completed-window signal, so the latest-ended
1733
+ // tier REJECTS the 90 s-stale turn and the owner resolves null.
1734
+ const candidates: ReplyOwnerCandidates = {
1735
+ liveTurnId: null,
1736
+ originTurnId: null,
1737
+ quotedTurnId: null,
1738
+ latestEndedTurnId: OWNER_TURN_ID,
1739
+ latestEndedAgeMs: realEndAgo,
1740
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
1741
+ latestEndedRealEndAgeMs: realEndAgo,
1742
+ latestEndedCompletedGraceMs: SUPERSEDE_COMPLETED_GRACE_MS,
1743
+ }
1744
+ const tier = resolveReplyOwnerTier(candidates)
1745
+ expect(tier).toBe('none') // the completed window did its job
1746
+ const winnerId = resolveReplyOwnerTurnId(candidates)
1747
+ h.deps.resolveReplyOwnerTurn = () => ({
1748
+ turn: winnerId != null ? owner : null,
1749
+ tier,
1750
+ candidates,
1751
+ })
1752
+ h.deps.getLastSubagentHandbackAt = () => null
1753
+
1754
+ const res = await sendReply(h.deps, {
1755
+ args: { chat_id: CHAT, text: LATE_WORKER_REPLY },
1756
+ turn: null, // decoupled — the parent turn ended long ago
1757
+ })
1758
+
1759
+ // OUTCOME: fresh send; the flushed answer is not edited or deleted.
1760
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1761
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1762
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
1763
+ expect(fresh).toHaveLength(1)
1764
+ expect(fresh[0]!.text).toContain('log-archive sweep')
1765
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1766
+ })
1767
+ })
1768
+
1769
+
1770
+ describe('#4174 — the handback gate-hold is bounded by its OWN 60 s recency window', () => {
1771
+ const OWNER_TURN_ID = `${CHAT}:_#flushed-55`
1772
+ const FLUSH_MSG_ID = 8181
1773
+ const FLUSHED_TEXT =
1774
+ 'Checked the backup job: last night\'s run completed in 41 minutes, all four volumes ' +
1775
+ 'verified clean, and the retention sweep pruned the January snapshots as scheduled.'
1776
+ const REWORDED_OWN_REPLY =
1777
+ 'Backup status: the overnight run finished in about forty minutes, every volume passed ' +
1778
+ 'verification, and the retention sweep removed the January snapshots on schedule.'
1779
+
1780
+ it('a handback enqueued 90 s ago (outside HANDBACK_RECENCY_WINDOW_MS) no longer ' +
1781
+ 'holds the content gate — the turn\'s own reworded reply still collapses to ONE message', async () => {
1782
+ const h = makeHarness()
1783
+ const owner = {
1784
+ turnId: OWNER_TURN_ID,
1785
+ sessionChatId: CHAT,
1786
+ answerDelivered: 'flush',
1787
+ flushedAnswerText: FLUSHED_TEXT,
1788
+ endedAt: Date.now() - 120_000,
1789
+ realEndObservedAt: null, // window open — session still composing
1790
+ replyCalled: false,
1791
+ finalAnswerDelivered: true,
1792
+ finalAnswerSubstantive: true,
1793
+ } as unknown as CurrentTurn
1794
+ h.deps.flushedTurnSupersede.record(
1795
+ CHAT,
1796
+ undefined,
1797
+ { turnId: OWNER_TURN_ID, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
1798
+ Date.now() - 120_000,
1799
+ )
1800
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'latest-ended', {
1801
+ latestEndedAgeMs: 120_000,
1802
+ latestEndedRealEndAgeMs: null,
1803
+ })
1804
+ // A handback WAS enqueued after the turn ended — but 90 s ago, outside the
1805
+ // 60 s recency window. Tying this read to the (minutes-long) supersede
1806
+ // bound would hold the gate here and ship a visible duplicate — the exact
1807
+ // #4174 regression; this test goes red under that mutation.
1808
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 90_000
1809
+
1810
+ const res = await sendReply(h.deps, {
1811
+ args: { chat_id: CHAT, text: REWORDED_OWN_REPLY },
1812
+ turn: null,
1813
+ })
1814
+
1815
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1816
+ expect(edits).toHaveLength(1)
1817
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1818
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1819
+ expect(res.content[0]!.text).toMatch(/^sent/)
1820
+ })
1821
+ })
1822
+
1823
+ describe('#4176 — a BACKGROUND SUB-AGENT reply can never silently edit over a flushed answer', () => {
1824
+ // The MAJOR from the #4167 review. The #4173 window rests on "a reply landing
1825
+ // between the synthetic and the real turn_end is this turn's own answer,
1826
+ // because nothing else can run on the serial session". A background `Task`
1827
+ // sub-agent falsifies that: it runs CONCURRENTLY and calls `reply` over the
1828
+ // PARENT's IPC bridge, so #4172's identity gate cannot see it and no
1829
+ // `subagent_handback` marker is stamped (a direct tool call never traverses
1830
+ // pendingInboundBuffer). Owner resolution then lands on the flush-ended turn
1831
+ // (OPEN is accepted at any age up to the crash cap), the bypass grants
1832
+ // positive attribution, and the sub-agent's FOREIGN content supersedes —
1833
+ // editing over the user's delivered answer with no notification for either.
1834
+ const OWNER_TURN_ID = `${CHAT}:_#flushed-4176`
1835
+ const FLUSH_MSG_ID = 44176
1836
+ const FLUSHED_TEXT =
1837
+ 'Traced the wedge to the broker socket: the gateway reconnects but never re-arms the ' +
1838
+ 'heartbeat, so the supervisor sees it as healthy while every vault read times out.'
1839
+ // Genuinely foreign content — a researcher sub-agent reporting its own finding.
1840
+ // Defeats both arms of flushedAnswerMatchesReply, so nothing here can pass by
1841
+ // accident through the content matcher.
1842
+ const SUBAGENT_REPLY =
1843
+ 'Sub-agent report: the calendar scope audit found four call sites that request the ' +
1844
+ 'write scope where a read scope would do, all reachable from the OAuth consent screen.'
1845
+
1846
+ function makeFlushedOwner(): CurrentTurn {
1847
+ return {
1848
+ turnId: OWNER_TURN_ID,
1849
+ sessionChatId: CHAT,
1850
+ answerDelivered: 'flush',
1851
+ flushedAnswerText: FLUSHED_TEXT,
1852
+ endedAt: Date.now() - 90_000,
1853
+ // Window OPEN: the flush ended the turn synthetically, the session's real
1854
+ // turn_end has not been observed (the parent is still delegating).
1855
+ realEndObservedAt: null,
1856
+ replyCalled: false,
1857
+ finalAnswerDelivered: true,
1858
+ finalAnswerSubstantive: true,
1859
+ } as unknown as CurrentTurn
1860
+ }
1861
+
1862
+ function seed(h: ReturnType<typeof makeHarness>): void {
1863
+ h.deps.flushedTurnSupersede.record(
1864
+ CHAT,
1865
+ undefined,
1866
+ { turnId: OWNER_TURN_ID, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
1867
+ Date.now() - 90_000,
1868
+ )
1869
+ // Latest-ended tier, corroborated, no handback marker, MAIN bridge → every
1870
+ // other gate passes. The sub-agent liveness signal is the only thing left.
1871
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(makeFlushedOwner(), 'latest-ended')
1872
+ h.deps.getLastSubagentHandbackAt = () => null
1873
+ }
1874
+
1875
+ it('the content matcher cannot save this case (foreign content, both arms fail)', () => {
1876
+ expect(flushedAnswerMatchesReply(FLUSHED_TEXT, SUBAGENT_REPLY)).toBe(false)
1877
+ })
1878
+
1879
+ it('MAJOR REPRO: with a live sub-agent, its reply ships FRESH — zero edits/deletes ' +
1880
+ 'of the flushed message, record intact for the parent turn\'s own replay', async () => {
1881
+ const h = makeHarness()
1882
+ seed(h)
1883
+ // The REAL tracker, driven by the REAL framework-derived session events the
1884
+ // gateway feeds it — not a hand-set boolean.
1885
+ const authority = new SubagentReplyAuthority()
1886
+ authority.noteSessionEvent({ kind: 'sub_agent_started', agentId: 'researcher-1' })
1887
+ expect(authority.subagentCouldOwnReply()).toBe(true)
1888
+ h.deps.subagentReplyAuthority = authority
1889
+
1890
+ const res = await sendReply(h.deps, {
1891
+ args: { chat_id: CHAT, text: SUBAGENT_REPLY },
1892
+ turn: null, // decoupled — the parent turn was ended by the flush
1893
+ callerIsForeignSession: false, // SAME bridge: #4172 cannot see this caller
1894
+ })
1895
+
1896
+ // OUTCOME: msg A untouched; the sub-agent's report is a fresh, NOTIFYING
1897
+ // message the user actually receives.
1898
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1899
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1900
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
1901
+ expect(fresh).toHaveLength(1)
1902
+ expect(fresh[0]!.text).toContain('calendar scope audit')
1903
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
1904
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1905
+ // The flush record survives for the parent turn's own genuine late replay.
1906
+ expect(
1907
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
1908
+ liveTurnId: OWNER_TURN_ID,
1909
+ now: Date.now(),
1910
+ }).supersede,
1911
+ ).toBe(true)
1912
+ })
1913
+
1914
+ it('NEGATIVE CONTROL (the reviewed failure): the SAME reply with NO sub-agent live ' +
1915
+ 'destroys the flushed answer by edit — proving the liveness gate is load-bearing', async () => {
1916
+ const h = makeHarness()
1917
+ seed(h)
1918
+ // Identical scenario, gate disarmed (the pre-fix behaviour): the bypass
1919
+ // applies and the foreign report edits over msg A — the double loss (the
1920
+ // user's answer destroyed, the report delivered without a notification).
1921
+ h.deps.subagentReplyAuthority = new SubagentReplyAuthority()
1922
+
1923
+ await sendReply(h.deps, {
1924
+ args: { chat_id: CHAT, text: SUBAGENT_REPLY },
1925
+ turn: null,
1926
+ callerIsForeignSession: false,
1927
+ })
1928
+
1929
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1930
+ expect(edits).toHaveLength(1)
1931
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1932
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1933
+ })
1934
+
1935
+ it('liveness is released by sub_agent_turn_end: after the sub-agent finishes, the ' +
1936
+ 'parent\'s OWN reworded answer still collapses to ONE message (#4166 preserved)', async () => {
1937
+ const h = makeHarness()
1938
+ seed(h)
1939
+ const authority = new SubagentReplyAuthority()
1940
+ authority.noteSessionEvent({ kind: 'sub_agent_started', agentId: 'researcher-1' })
1941
+ authority.noteSessionEvent({ kind: 'sub_agent_tool_use', agentId: 'researcher-1' })
1942
+ authority.noteSessionEvent({ kind: 'sub_agent_turn_end', agentId: 'researcher-1' })
1943
+ expect(authority.liveCount()).toBe(0)
1944
+ h.deps.subagentReplyAuthority = authority
1945
+
1946
+ // The parent's own answer, reworded (defeats the content matcher) — this is
1947
+ // the collapse the #4173/#4166 work exists to preserve.
1948
+ const REWORDED_OWN =
1949
+ 'The wedge is the broker socket: the gateway does reconnect, but the heartbeat is ' +
1950
+ 'never re-armed, so the supervisor reports healthy while all vault reads time out.'
1951
+ expect(flushedAnswerMatchesReply(FLUSHED_TEXT, REWORDED_OWN)).toBe(false)
1952
+
1953
+ await sendReply(h.deps, { args: { chat_id: CHAT, text: REWORDED_OWN }, turn: null })
1954
+
1955
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1956
+ expect(edits).toHaveLength(1)
1957
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1958
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1959
+ })
1960
+
1961
+ it('a live sub-agent does NOT block a genuine same-content replay (supersede on ' +
1962
+ 'equality is content-preserving)', async () => {
1963
+ const h = makeHarness()
1964
+ seed(h)
1965
+ const authority = new SubagentReplyAuthority()
1966
+ authority.noteSessionEvent({ kind: 'sub_agent_started', agentId: 'worker-2' })
1967
+ h.deps.subagentReplyAuthority = authority
1968
+
1969
+ await sendReply(h.deps, { args: { chat_id: CHAT, text: FLUSHED_TEXT }, turn: null })
1970
+
1971
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1972
+ expect(edits).toHaveLength(1)
1973
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1974
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1975
+ })
1976
+
1977
+ it('an UNFINISHED sub-agent keeps the gate armed (missing turn_end fails SAFE), ' +
1978
+ 'and reset() on bridge death releases it', async () => {
1979
+ const authority = new SubagentReplyAuthority()
1980
+ authority.noteSessionEvent({ kind: 'sub_agent_started', agentId: 'reviewer-3' })
1981
+ authority.noteSessionEvent({ kind: 'sub_agent_started', agentId: 'researcher-4' })
1982
+ authority.noteSessionEvent({ kind: 'sub_agent_turn_end', agentId: 'reviewer-3' })
1983
+ // One still live → gate armed.
1984
+ expect(authority.subagentCouldOwnReply()).toBe(true)
1985
+
1986
+ const h = makeHarness()
1987
+ seed(h)
1988
+ h.deps.subagentReplyAuthority = authority
1989
+ await sendReply(h.deps, {
1990
+ args: { chat_id: CHAT, text: SUBAGENT_REPLY },
1991
+ turn: null,
1992
+ })
1993
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1994
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
1995
+
1996
+ // Bridge death: the claude session and every sub-agent under it are gone.
1997
+ authority.reset()
1998
+ expect(authority.subagentCouldOwnReply()).toBe(false)
1999
+ })
2000
+
2001
+ it('non-sub_agent events never arm the gate (the feed takes the WHOLE stream ' +
2002
+ 'un-filtered, so this is the discrimination that matters)', () => {
2003
+ const authority = new SubagentReplyAuthority()
2004
+ authority.noteSessionEvent({ kind: 'turn_end' })
2005
+ authority.noteSessionEvent({ kind: 'tool_use', agentId: 'not-a-subagent' })
2006
+ authority.noteSessionEvent({ kind: 'assistant_text' })
2007
+ authority.noteSessionEvent(null)
2008
+ authority.noteSessionEvent({ kind: 'sub_agent_started' }) // id-less → ignorable
2009
+ expect(authority.subagentCouldOwnReply()).toBe(false)
2010
+ })
2011
+
2012
+ // Structural pins: gateway.ts / stream-render.ts are not importable from a
2013
+ // test runner (the singleton + boot-gated lockedBot), so the wiring that makes
2014
+ // the gate reachable in production is pinned by source assertion — the same
2015
+ // accepted pattern as the #4172 pin above. Dropping any of these three lines
2016
+ // silently disarms the gate with every behavioural test above still green.
2017
+ it('#4176 wiring — the session-event feed, the bridge-death reset, and the ' +
2018
+ 'send-path dep are all present', () => {
2019
+ const streamSrc = readFileSync(new URL('../gateway/stream-render.ts', import.meta.url), 'utf8')
2020
+ // FEED: every event, before the kind switch (the `sub_agent_tool_use` case
2021
+ // early-returns when no turn is live, so the switch is not a usable site).
2022
+ expect(streamSrc).toContain('subagentReplyAuthority.noteSessionEvent(')
2023
+ const gwSrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
2024
+ // RESET on main-bridge disconnect + READ wired into the sendReply deps.
2025
+ expect(gwSrc).toContain('subagentReplyAuthority.reset()')
2026
+ expect(gwSrc).toMatch(/subagentReplyAuthority,/)
2027
+ const sendSrc = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
2028
+ expect(sendSrc).toContain('subagentReplyAuthority.subagentCouldOwnReply()')
2029
+ expect(sendSrc).toContain('subagentCouldOwnReply,')
2030
+ })
2031
+ })