switchroom 0.19.44 → 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.
- package/dist/cli/switchroom.js +940 -1006
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +486 -314
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +66 -65
- package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
- package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
- package/telegram-plugin/gateway/stream-render.ts +91 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
- package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
- package/telegram-plugin/gateway/turn-end.ts +9 -1
- package/telegram-plugin/reply-owner-resolve.ts +102 -26
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
- package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
|
@@ -47,12 +47,15 @@ import {
|
|
|
47
47
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
48
48
|
import {
|
|
49
49
|
FlushedTurnSupersedeRegistry,
|
|
50
|
-
|
|
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:
|
|
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
|
|
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(), {
|
|
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:
|
|
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
|
+
})
|