switchroom 0.18.26 → 0.18.27

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 (32) hide show
  1. package/README.md +6 -2
  2. package/dist/cli/ms-365-write-pretool.mjs +4953 -14
  3. package/dist/cli/switchroom.js +1 -1
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +16 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +494 -36
  8. package/telegram-plugin/flushed-turn-supersede.ts +58 -0
  9. package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +305 -41
  11. package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
  12. package/telegram-plugin/gateway/model-command.ts +68 -0
  13. package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
  14. package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
  15. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
  16. package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
  17. package/telegram-plugin/send-gate.test.ts +138 -0
  18. package/telegram-plugin/send-gate.ts +104 -1
  19. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
  20. package/telegram-plugin/tests/effort-command.test.ts +47 -0
  21. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
  22. package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
  23. package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
  24. package/telegram-plugin/tests/model-command.test.ts +112 -0
  25. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
  26. package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
  27. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
  28. package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
  29. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
  30. package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
  31. package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
  32. package/telegram-plugin/worker-activity-feed.ts +91 -1
@@ -17,6 +17,7 @@ import { describe, it, expect, beforeAll } from "vitest";
17
17
  import {
18
18
  parseModelCommand,
19
19
  planModelCommand,
20
+ resolveStaleAwareBusy,
20
21
  isModelCommandBusy,
21
22
  modelCommandReceiptLine,
22
23
  handleModelCommand,
@@ -1622,6 +1623,117 @@ describe("#3177 typed /model never silently swallowed mid-turn", () => {
1622
1623
  });
1623
1624
  });
1624
1625
 
1626
+ describe("resolveStaleAwareBusy — phantom 'active turn' fix (#3262)", () => {
1627
+ const HARD_TTL = 10 * 60_000;
1628
+
1629
+ it("APPLIES a set when the turn atom is DANGLING (older than the hard TTL)", () => {
1630
+ // A currentTurn atom whose turn_end never fired: non-null but its
1631
+ // liveness marker is far older than the ceiling. Discounted to idle,
1632
+ // and flagged for the gateway to clear.
1633
+ const r = resolveStaleAwareBusy({
1634
+ currentTurnActive: true,
1635
+ turnAgeMs: HARD_TTL + 1,
1636
+ machineInTurn: false,
1637
+ oldestPendingApprovalAgeMs: null,
1638
+ hardTtlMs: HARD_TTL,
1639
+ });
1640
+ expect(r.currentTurnActive).toBe(false);
1641
+ expect(r.turnInFlight).toBe(false);
1642
+ expect(r.clearStaleTurn).toBe(true);
1643
+ // Routing sees "idle" → applies, not queues.
1644
+ const d = planModelCommand(
1645
+ { kind: "set", model: "opus" },
1646
+ { currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
1647
+ );
1648
+ expect(d).toEqual({ kind: "apply", parsed: { kind: "set", model: "opus" } });
1649
+ });
1650
+
1651
+ it("QUEUES a set when the turn is FRESH (marker within the TTL) — no regression", () => {
1652
+ const r = resolveStaleAwareBusy({
1653
+ currentTurnActive: true,
1654
+ turnAgeMs: 5_000,
1655
+ machineInTurn: false,
1656
+ oldestPendingApprovalAgeMs: null,
1657
+ hardTtlMs: HARD_TTL,
1658
+ });
1659
+ expect(r.currentTurnActive).toBe(true);
1660
+ expect(r.clearStaleTurn).toBe(false);
1661
+ const d = planModelCommand(
1662
+ { kind: "set", model: "opus" },
1663
+ { currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
1664
+ );
1665
+ expect(d).toEqual({ kind: "queue", target: "opus" });
1666
+ });
1667
+
1668
+ it("APPLIES when only a WEDGED approval (older than the TTL) holds the gate on an idle session", () => {
1669
+ const r = resolveStaleAwareBusy({
1670
+ currentTurnActive: false,
1671
+ turnAgeMs: null,
1672
+ machineInTurn: false,
1673
+ oldestPendingApprovalAgeMs: HARD_TTL + 1,
1674
+ hardTtlMs: HARD_TTL,
1675
+ });
1676
+ expect(r.turnInFlight).toBe(false);
1677
+ expect(r.currentTurnActive).toBe(false);
1678
+ const d = planModelCommand(
1679
+ { kind: "set", model: "opus" },
1680
+ { currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
1681
+ );
1682
+ expect(d).toEqual({ kind: "apply", parsed: { kind: "set", model: "opus" } });
1683
+ });
1684
+
1685
+ it("QUEUES when a RECENT pending approval holds the gate — a real block is preserved", () => {
1686
+ const r = resolveStaleAwareBusy({
1687
+ currentTurnActive: false,
1688
+ turnAgeMs: null,
1689
+ machineInTurn: false,
1690
+ oldestPendingApprovalAgeMs: 3_000,
1691
+ hardTtlMs: HARD_TTL,
1692
+ });
1693
+ expect(r.turnInFlight).toBe(true);
1694
+ const d = planModelCommand(
1695
+ { kind: "set", model: "opus" },
1696
+ { currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
1697
+ );
1698
+ expect(d).toEqual({ kind: "queue", target: "opus" });
1699
+ });
1700
+
1701
+ it("QUEUES when the machine is genuinely in-turn regardless of atom age", () => {
1702
+ const r = resolveStaleAwareBusy({
1703
+ currentTurnActive: false,
1704
+ turnAgeMs: null,
1705
+ machineInTurn: true,
1706
+ oldestPendingApprovalAgeMs: null,
1707
+ hardTtlMs: HARD_TTL,
1708
+ });
1709
+ expect(r.turnInFlight).toBe(true);
1710
+ expect(r.clearStaleTurn).toBe(false);
1711
+ });
1712
+
1713
+ it("does NOT clear or discount when there is no turn atom", () => {
1714
+ const r = resolveStaleAwareBusy({
1715
+ currentTurnActive: false,
1716
+ turnAgeMs: null,
1717
+ machineInTurn: false,
1718
+ oldestPendingApprovalAgeMs: null,
1719
+ hardTtlMs: HARD_TTL,
1720
+ });
1721
+ expect(r).toEqual({ currentTurnActive: false, turnInFlight: false, clearStaleTurn: false });
1722
+ });
1723
+
1724
+ it("keeps a non-null atom busy when its age is exactly at the ceiling (strict >)", () => {
1725
+ const r = resolveStaleAwareBusy({
1726
+ currentTurnActive: true,
1727
+ turnAgeMs: HARD_TTL,
1728
+ machineInTurn: false,
1729
+ oldestPendingApprovalAgeMs: null,
1730
+ hardTtlMs: HARD_TTL,
1731
+ });
1732
+ expect(r.currentTurnActive).toBe(true);
1733
+ expect(r.clearStaleTurn).toBe(false);
1734
+ });
1735
+ });
1736
+
1625
1737
  describe("modelCommandReceiptLine — the durable, greppable entry trace", () => {
1626
1738
  it("stamps agent, kind, arg, and busy for a typed set", () => {
1627
1739
  const line = modelCommandReceiptLine("finn", { kind: "set", model: "opus" }, true);
@@ -20,6 +20,13 @@ const bridgeSrc = readFileSync(
20
20
  resolve(__dirname, '..', 'bridge', 'bridge.ts'),
21
21
  'utf-8',
22
22
  )
23
+ // #3268 — deriveTurnId was extracted from the gateway monolith into its own
24
+ // importable module so the enqueue seam and the handback round-trip test share
25
+ // ONE function. The body-shape assertion below now reads it from there.
26
+ const deriveTurnIdSrc = readFileSync(
27
+ resolve(__dirname, '..', 'gateway', 'derive-turn-id.ts'),
28
+ 'utf-8',
29
+ )
23
30
 
24
31
  describe('component 3 — turn-origin reply routing', () => {
25
32
  it('CurrentTurn carries a turnId, and the enqueue handler initialises it', () => {
@@ -35,10 +42,15 @@ describe('component 3 — turn-origin reply routing', () => {
35
42
 
36
43
  it('deriveTurnId is stable across inbound-build and enqueue (message-id based)', () => {
37
44
  // The id must be derivable identically at both sites — keyed on
38
- // chat/thread/messageId, NOT the not-yet-known startedAt.
39
- const fn = gatewaySrc.split('function deriveTurnId')[1]?.split('\nfunction ')[0] ?? ''
45
+ // chat/thread/messageId, NOT the not-yet-known startedAt. Lives in the
46
+ // extracted derive-turn-id.ts module (#3268); the gateway imports it under
47
+ // the same name (asserted below), so every enqueue callsite is unchanged.
48
+ const fn = deriveTurnIdSrc.split('export function deriveTurnId')[1]?.split('\nexport function ')[0] ?? ''
40
49
  expect(fn).toMatch(/chatKey\(chatId, threadId \?\? null\)/)
41
50
  expect(fn).toMatch(/messageId/)
51
+ // The gateway must import the shared function, not redefine it — so the
52
+ // enqueue seam and the round-trip test provably use the same identity.
53
+ expect(gatewaySrc).toMatch(/import \{ deriveTurnId \} from '\.\/derive-turn-id\.js'/)
42
54
  })
43
55
 
44
56
  it('executeReply resolves the answer thread via the origin turn, not the live currentTurn', () => {
@@ -302,3 +302,60 @@ describe('sendReplyChunks — preview edit-in-place', () => {
302
302
  expect(calls.filter((c) => c.kind === 'sendRich')).toHaveLength(0)
303
303
  })
304
304
  })
305
+
306
+ // Reply-flicker fix: the flushed-turn supersede edits the flushed message A in
307
+ // place instead of delete+resend, by re-pointing `previewMessageId` at A's id.
308
+ // These drive that exact execution lane through the fake bot to assert the
309
+ // user-visible OUTCOME (one message survives, no flicker) rather than the path.
310
+ describe('sendReplyChunks — flushed-turn supersede edit-in-place', () => {
311
+ it('a single-message text reply superseding a flushed message EDITS A in place — no delete', async () => {
312
+ // Gateway sets previewMessageId to the flushed message id when
313
+ // decideSupersedeCorrection returns edit-in-place (single chunk, no files/preview).
314
+ const { deps, calls, deleted } = makeFake()
315
+ const flushedId = 700
316
+ const state = baseState({ chunks: ['the real answer'], previewMessageId: flushedId })
317
+ const res = await sendReplyChunks(deps, state)
318
+ // A is edited in place with the canonical reply body — NOT deleted.
319
+ expect(calls).toHaveLength(1)
320
+ expect(calls[0].kind).toBe('editPreview')
321
+ expect(calls[0].messageId).toBe(flushedId)
322
+ expect(calls[0].body).toEqual({ rich: 'the real answer' })
323
+ expect(deleted).toEqual([])
324
+ expect(calls.some((c) => c.kind === 'sendRich')).toBe(false)
325
+ // Exactly ONE message survives, and it is the (edited) flushed message.
326
+ expect(state.sentIds).toEqual([flushedId])
327
+ expect(res.previewMessageId).toBeNull()
328
+ })
329
+
330
+ it('anti-duplicate: an edit-in-place supersede never leaves BOTH the flush and a fresh reply visible', async () => {
331
+ const { deps, calls, deleted } = makeFake()
332
+ const state = baseState({ chunks: ['answer'], previewMessageId: 710 })
333
+ await sendReplyChunks(deps, state)
334
+ // No fresh send happened alongside the edit, and nothing was deleted — the
335
+ // single surviving message is the flushed one, now carrying the reply.
336
+ const fresh = calls.filter((c) => c.kind === 'sendRich' || c.kind === 'sendLiteral')
337
+ expect(fresh).toHaveLength(0)
338
+ expect(deleted).toEqual([])
339
+ expect(state.sentIds).toEqual([710])
340
+ })
341
+
342
+ it('edit returns a 400 → falls back to delete+resend, exactly one clean message survives', async () => {
343
+ // Message too old / uneditable / gone: editPreview throws, the lane deletes
344
+ // A and sends the canonical reply fresh — never both, never neither.
345
+ const { deps, calls, deleted } = makeFake({
346
+ failNext: { editPreview: [grammy400("message can't be edited", 'editMessageText')] },
347
+ })
348
+ const flushedId = 720
349
+ const state = baseState({ chunks: ['the real answer'], previewMessageId: flushedId })
350
+ const res = await sendReplyChunks(deps, state)
351
+ // Fallback: stale flush deleted, canonical reply sent fresh.
352
+ expect(deleted).toEqual([flushedId])
353
+ const fresh = calls.filter((c) => c.kind === 'sendRich')
354
+ expect(fresh).toHaveLength(1)
355
+ expect(fresh[0].body).toEqual({ rich: 'the real answer' })
356
+ // Exactly one surviving message (the fresh send), and it is NOT the flushed id.
357
+ expect(state.sentIds).toHaveLength(1)
358
+ expect(state.sentIds[0]).not.toBe(flushedId)
359
+ expect(res.previewMessageId).toBeNull()
360
+ })
361
+ })
@@ -90,20 +90,27 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
90
90
  // even after releaseTurnBufferGate has cleared claudeBusyKeys/machine on the
91
91
  // first interim reply. Without this, a new inbound delivered in that window
92
92
  // displaces the approval context and orphans the pending MCP call.
93
- it('turnInFlightForGate includes pendingPermissions.size > 0 on the legacy path', () => {
94
- // span 2600: the function gained a ~1100-char note (#3084 follow-up) documenting
95
- // the one case with no timeout valve a HELD approval.
96
- const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2600)
97
- // Both paths (legacy claudeBusyKeys + machine-authoritative) must include the check.
98
- expect(fn).toMatch(/claudeBusyKeys\.size\s*>\s*0\s*\|\|\s*hasPendingApproval/)
93
+ it('turnInFlightForGate STILL composes the pending-approval hold (#2841 contract)', () => {
94
+ // #3262 factored the machine-in-turn leg into turnInFlightMachineOnly(), but
95
+ // the shared inbound gate must remain UNCONDITIONALLY busy while an approval
96
+ // card is outstanding. That contract now lives in the composition here.
97
+ // span 2800: the function carries a ~1100-char #2841/#3084 note before the
98
+ // composition line.
99
+ const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2800)
100
+ expect(fn).toMatch(/return\s+turnInFlightMachineOnly\(\)\s*\|\|\s*hasPendingApproval/)
101
+ expect(fn).toMatch(/hasPendingApproval\s*=\s*pendingPermissions\.size\s*>\s*0/)
99
102
  })
100
103
 
101
- it('turnInFlightForGate includes pendingPermissions.size > 0 on the machine path', () => {
102
- const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2700)
103
- // probeGateParity(...) || hasPendingApproval the inner arg contains its own
104
- // parens (isMachineInTurn()), so match the two tokens independently.
104
+ it('turnInFlightMachineOnly carries BOTH busy paths (legacy claudeBusyKeys + machine)', () => {
105
+ // #3262: the machine-in-turn signal moved here (read by the /model & /effort
106
+ // resolver without the approval hold). Both the legacy claudeBusyKeys path
107
+ // and the machine-authoritative probeGateParity path must be present — a
108
+ // dropped branch would silently change the busy read.
109
+ const fn = slice(GATEWAY, 'function turnInFlightMachineOnly()', 1400)
110
+ expect(fn).toMatch(/claudeBusyKeys\.size\s*>\s*0/)
105
111
  expect(fn).toContain('probeGateParity(')
106
- expect(fn).toMatch(/probeGateParity[\s\S]+?\|\|\s*hasPendingApproval/)
112
+ // NOT gated by the approval hold — that stays in turnInFlightForGate.
113
+ expect(fn).not.toContain('hasPendingApproval')
107
114
  })
108
115
 
109
116
  it('hasPendingApproval reads pendingPermissions.size', () => {
@@ -277,3 +277,93 @@ describe('F2 — recency-bound the destructive latest-ended supersede tier', ()
277
277
  ).toBe('turn-T')
278
278
  })
279
279
  })
280
+
281
+ /**
282
+ * Reply-flicker edit-in-place fix (PR #3266) — the deferred-correction
283
+ * resurrection window (adversarial-review L1).
284
+ *
285
+ * The fix defers the flushed-message correction (delete-resend vs edit-in-place)
286
+ * from the supersede site to the send site so a single-message reply can EDIT
287
+ * the flushed message A in place instead of delete+resend. But `take()` consumes
288
+ * the supersede record at the supersede site, and TWO arg-validation `throw`s
289
+ * (file too-large, invalid inline_keyboard) sit BETWEEN consumption and the
290
+ * correction. A late reply that supersedes a flush AND carries an oversized file
291
+ * / invalid keyboard throws before the correction runs: message A is neither
292
+ * deleted nor edited, the model retries `reply`, and — the record already
293
+ * consumed — the retry takes the no-record branch. Without a latch the retry
294
+ * would ship a fresh B alongside the stale narration A (both visible).
295
+ *
296
+ * The fix latches `ownerTurn.answerDelivered = true` at record consumption, so
297
+ * the retry (resolving the SAME ended owner turn) is caught by
298
+ * `decideAnswerLatchSuppression` and suppressed — exactly one message survives.
299
+ */
300
+ describe('reply-flicker edit-in-place — mid-path throw + retry never resurrects the duplicate (L1)', () => {
301
+ const CHAT = '636363'
302
+
303
+ /** Minimal model of the ended owner turn atom the gateway mutates + reads. */
304
+ interface OwnerTurn { turnId: string; answerDelivered: boolean }
305
+
306
+ /**
307
+ * Drive the two gateway `reply` calls the incident produces, threading the
308
+ * SAME registry + owner-turn atom. `setLatchOnSupersede` toggles the fix on
309
+ * (true = PR behaviour) vs off (false = pre-fix red-on-main contrast).
310
+ * Returns which messages are visible after the sequence.
311
+ */
312
+ function runSupersedeThenThrowRetry(setLatchOnSupersede: boolean): {
313
+ visible: string[]
314
+ firstSuperseded: boolean
315
+ retrySuppressed: boolean
316
+ } {
317
+ const reg = new FlushedTurnSupersedeRegistry()
318
+ const now = 2_000_000
319
+ const owner: OwnerTurn = { turnId: 'turn-A', answerDelivered: false }
320
+ // The flush posted message A for turn-A and recorded it.
321
+ reg.record(CHAT, undefined, { turnId: owner.turnId, messageIds: [8001], text: 'narration\n\nthe answer' }, now)
322
+ const visible = ['A(flush-narration)']
323
+
324
+ // --- Call 1: the model's real `reply` lands and SUPERSEDES the flush. ---
325
+ const d1 = reg.take(CHAT, undefined, { liveTurnId: owner.turnId, now: now + 10 })
326
+ const firstSuperseded = d1.supersede
327
+ if (d1.supersede && setLatchOnSupersede) {
328
+ // The fix: latch at consumption, BEFORE the arg-validation throw.
329
+ owner.answerDelivered = true
330
+ }
331
+ // Arg-validation throw fires here (oversized file / invalid keyboard):
332
+ // the correction never runs → A is neither deleted nor edited, B not sent.
333
+ // (Modelled by simply NOT applying the correction and NOT pushing B.)
334
+
335
+ // --- Call 2: the model retries `reply` after seeing the MCP error. ---
336
+ // The record was consumed by Call 1's take() → no-record now.
337
+ const d2 = reg.take(CHAT, undefined, { liveTurnId: owner.turnId, now: now + 20 })
338
+ let retrySuppressed = false
339
+ if (!d2.supersede) {
340
+ // else/no-record branch: the retry is a late substantive reply.
341
+ retrySuppressed = decideAnswerLatchSuppression({
342
+ superseded: false,
343
+ replySubstantive: true,
344
+ isLateReply: true,
345
+ ownerAnswerDelivered: owner.answerDelivered,
346
+ })
347
+ }
348
+ if (!retrySuppressed) visible.push('B(retry-reply)')
349
+ return { visible, firstSuperseded, retrySuppressed }
350
+ }
351
+
352
+ it('WITH the fix: the retry is latch-suppressed → exactly ONE surviving message (never A+B)', () => {
353
+ const r = runSupersedeThenThrowRetry(true)
354
+ expect(r.firstSuperseded).toBe(true)
355
+ expect(r.retrySuppressed).toBe(true)
356
+ expect(r.visible).toHaveLength(1)
357
+ // The single surviving message is the flushed A (the correction throw left it
358
+ // in place); the retry B was suppressed — no stale-narration + reply duplicate.
359
+ expect(r.visible).toEqual(['A(flush-narration)'])
360
+ })
361
+
362
+ it('WITHOUT the fix (contrast): the retry is NOT suppressed → BOTH A and B ship (the resurrected duplicate)', () => {
363
+ const r = runSupersedeThenThrowRetry(false)
364
+ expect(r.firstSuperseded).toBe(true)
365
+ expect(r.retrySuppressed).toBe(false)
366
+ // The exact regression: stale narration A AND the retry reply B are both visible.
367
+ expect(r.visible).toEqual(['A(flush-narration)', 'B(retry-reply)'])
368
+ })
369
+ })
@@ -37,6 +37,11 @@ describe('buildSubagentHandbackInbound', () => {
37
37
  // The wake-up contract: bridge renders <channel source="subagent_handback">.
38
38
  expect(inbound.meta.source).toBe('subagent_handback')
39
39
  expect(inbound.meta.outcome).toBe('completed')
40
+ // #3268 — the fabricated ts rounds-trips through meta.message_id (the only
41
+ // channel-rendered id) so enqueue's deriveTurnId matches the pre-turn seam's
42
+ // adopt id. Must equal the top-level messageId as a string.
43
+ expect(inbound.meta.message_id).toBe(String(FIXED_NOW))
44
+ expect(inbound.meta.message_id).toBe(String(inbound.messageId))
40
45
  // Text carries the task, the result, and the beat-4 steer.
41
46
  expect(inbound.text).toContain('Refactor the auth module')
42
47
  expect(inbound.text).toContain('4 tests added, all green')
@@ -15,6 +15,9 @@ import {
15
15
  removeTurnActiveMarker,
16
16
  sweepStaleTurnActiveMarker,
17
17
  readTurnActiveMarkerAgeMs,
18
+ effectiveTurnAgeMs,
19
+ TURN_ACTIVE_HARD_TTL_MS,
20
+ TURN_ACTIVE_IDLE_SWEEP_MS,
18
21
  } from '../gateway/turn-active-marker.js'
19
22
 
20
23
  describe('turn-active-marker (#412)', () => {
@@ -28,6 +31,32 @@ describe('turn-active-marker (#412)', () => {
28
31
  rmSync(tmp, { recursive: true, force: true })
29
32
  })
30
33
 
34
+ it('exports the sweep TTL bounds as shared constants (#3262 single source of truth)', () => {
35
+ // The /model & /effort phantom-turn cross-check reuses these SAME bounds —
36
+ // guarding against a drift where the atom ceiling and the marker ceiling
37
+ // diverge into two different magic numbers.
38
+ expect(TURN_ACTIVE_HARD_TTL_MS).toBe(10 * 60_000)
39
+ expect(TURN_ACTIVE_IDLE_SWEEP_MS).toBe(60_000)
40
+ expect(TURN_ACTIVE_HARD_TTL_MS).toBeGreaterThan(TURN_ACTIVE_IDLE_SWEEP_MS)
41
+ })
42
+
43
+ it('effectiveTurnAgeMs prefers the marker age when present (#3262)', () => {
44
+ // Marker present (touched on tool_use) → its age wins over the fixed
45
+ // turn.startedAt, so a legitimately LONG turn that keeps touching the
46
+ // marker reads fresh and is never mis-swept as a phantom.
47
+ const now = 1_000_000
48
+ expect(effectiveTurnAgeMs(5_000, now - 9_999_999, now)).toBe(5_000)
49
+ })
50
+
51
+ it('effectiveTurnAgeMs falls back to now - startedAt when the marker is absent (#3262)', () => {
52
+ // Marker already swept (null age) → fall back to the turn-start timestamp,
53
+ // so a dangling atom whose marker was reaped still reports a large age and
54
+ // is recognised as stale by the /model & /effort cross-check.
55
+ const now = 1_000_000
56
+ const startedAt = now - 42_000
57
+ expect(effectiveTurnAgeMs(null, startedAt, now)).toBe(42_000)
58
+ })
59
+
31
60
  it('writeTurnActiveMarker creates a JSON file with the expected payload', () => {
32
61
  writeTurnActiveMarker(tmp, {
33
62
  turnKey: 'chat:1:1700000000000',
@@ -659,6 +659,10 @@ describe('createWorkerActivityFeed — heartbeat', () => {
659
659
  bot,
660
660
  now: () => clock,
661
661
  minEditIntervalMs: 2500,
662
+ // Suffix-mechanics test: pin the elapsed-refresh cadence to the edit floor
663
+ // so this exercises the classic 6s heartbeat suffix, not the coarse
664
+ // flood-pacing cadence (covered by its own tests below).
665
+ elapsedRefreshMs: 2500,
662
666
  heartbeatTickMs: 6000,
663
667
  // No real timer: drive ticks manually.
664
668
  setInterval: () => 1,
@@ -862,6 +866,10 @@ describe('createWorkerActivityFeed — heartbeat', () => {
862
866
  firstPaintMinMs: 8000,
863
867
  heartbeatTickMs: 6000,
864
868
  minEditIntervalMs: 2500,
869
+ // Heartbeat-liveness test: pin the elapsed-refresh cadence to the edit
870
+ // floor so a later heartbeat repaints the climbing clock under the classic
871
+ // 6s cadence (coarse flood-pacing is covered by its own tests below).
872
+ elapsedRefreshMs: 2500,
865
873
  setInterval: () => 1,
866
874
  clearInterval: () => {},
867
875
  })
@@ -1551,3 +1559,116 @@ describe('worker-feed send-gate shed contract', () => {
1551
1559
  expect(feed.has('w1')).toBe(false)
1552
1560
  })
1553
1561
  })
1562
+
1563
+ // ─── elapsed-only edit pacing (flood-ban defense, Part 1) ────────────────────
1564
+
1565
+ describe('createWorkerActivityFeed — elapsed-only edit pacing', () => {
1566
+ const drain = () => new Promise((r) => setTimeout(r, 0))
1567
+
1568
+ function mkFeed(bot: FakeBot, nowRef: { t: number }) {
1569
+ return createWorkerActivityFeed({
1570
+ bot,
1571
+ now: () => nowRef.t,
1572
+ minEditIntervalMs: 2500,
1573
+ elapsedRefreshMs: 15_000,
1574
+ firstPaintMinMs: 8000,
1575
+ heartbeatTickMs: 6000,
1576
+ setInterval: () => 1,
1577
+ clearInterval: () => {},
1578
+ })
1579
+ }
1580
+
1581
+ it('suppresses clock-only churn: elapsed advancing within elapsedRefreshMs emits NO edit', async () => {
1582
+ const bot = makeFakeBot()
1583
+ const ref = { t: 10_000 }
1584
+ const feed = mkFeed(bot, ref)
1585
+
1586
+ // First paint.
1587
+ ref.t = 20_000
1588
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'scanning', toolCount: 3 }))
1589
+ expect(bot.sent).toHaveLength(1)
1590
+
1591
+ // A stream of ticks where ONLY the elapsed clock advances (same step, same
1592
+ // toolCount). Every one is past the 2.5s edit floor but under the 15s
1593
+ // elapsed-refresh cadence measured from the last edit (paint @20_000) — so
1594
+ // the send-gate-evading clock-only edit that earns a flood ban never fires.
1595
+ for (const t of [23_000, 26_000, 30_000, 34_000]) {
1596
+ ref.t = t
1597
+ await feed.update('w1', 'chat', view({ elapsedMs: t - 11_000, latestSummary: 'scanning', toolCount: 3 }))
1598
+ }
1599
+ await drain()
1600
+ expect(bot.edits).toHaveLength(0)
1601
+ })
1602
+
1603
+ it('renders a substantive step change PROMPTLY (before the elapsed-refresh cadence)', async () => {
1604
+ const bot = makeFakeBot()
1605
+ const ref = { t: 10_000 }
1606
+ const feed = mkFeed(bot, ref)
1607
+
1608
+ ref.t = 20_000
1609
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'scanning', toolCount: 3 }))
1610
+ expect(bot.sent).toHaveLength(1)
1611
+
1612
+ // 6s later: still under the 15s elapsed-refresh cadence, but the STEP and
1613
+ // toolCount changed — a real update. It renders immediately (only the 2.5s
1614
+ // floor applies), not held for the coarse clock cadence.
1615
+ ref.t = 26_000
1616
+ await feed.update('w1', 'chat', view({ elapsedMs: 15_000, latestSummary: 'writing report', toolCount: 4 }))
1617
+ await drain()
1618
+ expect(bot.edits.length).toBeGreaterThanOrEqual(1)
1619
+ expect(bot.edits[bot.edits.length - 1].text).toContain('writing report')
1620
+ })
1621
+
1622
+ it('forces the terminal edit through and renders the honest final elapsed', async () => {
1623
+ const bot = makeFakeBot()
1624
+ const ref = { t: 10_000 }
1625
+ const feed = mkFeed(bot, ref)
1626
+
1627
+ ref.t = 20_000
1628
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'scanning', toolCount: 3 }))
1629
+ expect(bot.sent).toHaveLength(1)
1630
+
1631
+ // Completion within the elapsed-refresh window still finalizes immediately
1632
+ // (terminal edits bypass the pacing) and shows the correct final elapsed.
1633
+ ref.t = 28_000
1634
+ await feed.finish('w1', view({ state: 'done', toolCount: 6, elapsedMs: 31_000, latestSummary: 'all done' }))
1635
+ await drain()
1636
+ expect(bot.edits.length).toBeGreaterThanOrEqual(1)
1637
+ const last = bot.edits[bot.edits.length - 1].text
1638
+ expect(last).toContain('done · 6 tools')
1639
+ expect(last).toContain('31s')
1640
+ })
1641
+
1642
+ it('does not over-suppress a substantive change that would field-boundary-collide under naive concatenation', async () => {
1643
+ const bot = makeFakeBot()
1644
+ const ref = { t: 10_000 }
1645
+ const feed = mkFeed(bot, ref)
1646
+
1647
+ // Paint: toolCount 1, totalTokens 23. Under a delimiter-less substance key
1648
+ // the fields `1` and `23` concatenate to `123`.
1649
+ ref.t = 20_000
1650
+ await feed.update(
1651
+ 'w1',
1652
+ 'chat',
1653
+ view({ elapsedMs: 9000, latestSummary: 'scanning', toolCount: 1, totalTokens: 23 }),
1654
+ )
1655
+ expect(bot.sent).toHaveLength(1)
1656
+
1657
+ // A REAL substantive change (toolCount 1→12, totalTokens 23→3) that collides
1658
+ // to the SAME `123` under naive concatenation. It lands within the 15s
1659
+ // elapsed-refresh window (past the 2.5s floor), so ONLY a substance-key
1660
+ // difference can drive the edit. A colliding key would wrongly treat this as
1661
+ // elapsed-only churn and suppress it; the NUL/RS-delimited key keeps them
1662
+ // distinct, so the edit fires promptly.
1663
+ ref.t = 22_500
1664
+ await feed.update(
1665
+ 'w1',
1666
+ 'chat',
1667
+ view({ elapsedMs: 11_500, latestSummary: 'scanning', toolCount: 12, totalTokens: 3 }),
1668
+ )
1669
+ await drain()
1670
+ expect(bot.edits.length).toBeGreaterThanOrEqual(1)
1671
+ // The rendered card reflects the new tool count, proving the change landed.
1672
+ expect(bot.edits[bot.edits.length - 1].text).toContain('12 tools')
1673
+ })
1674
+ })