switchroom 0.18.25 → 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 (44) 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 +2 -2
  6. package/profiles/_base/start.sh.hbs +16 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +832 -38
  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/render/code-segments.ts +210 -0
  18. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  19. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  20. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  21. package/telegram-plugin/render/line-start-guard.ts +167 -0
  22. package/telegram-plugin/render/rich-render.ts +7 -0
  23. package/telegram-plugin/rich-send.ts +48 -2
  24. package/telegram-plugin/send-gate.test.ts +138 -0
  25. package/telegram-plugin/send-gate.ts +104 -1
  26. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
  27. package/telegram-plugin/tests/effort-command.test.ts +47 -0
  28. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
  29. package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
  30. package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
  31. package/telegram-plugin/tests/model-command.test.ts +112 -0
  32. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
  33. package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
  34. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
  35. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  36. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  37. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  38. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  39. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  40. package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
  41. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
  42. package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
  43. package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
  44. package/telegram-plugin/worker-activity-feed.ts +91 -1
@@ -137,6 +137,8 @@ describe('send-gate: SEND_GATE_DEFAULTS (compile-time default PIN)', () => {
137
137
  perGroupPerMin: 18,
138
138
  perGroupBurst: 2,
139
139
  editFloorMs: 1500,
140
+ perMessageEditWindowMs: 300_000,
141
+ perMessageEditMaxPerWindow: 150,
140
142
  })
141
143
  })
142
144
  })
@@ -157,6 +159,8 @@ describe('send-gate: sendGateConfigFromEnv (yaml → env → createSendGate)', (
157
159
  SWITCHROOM_TG_SEND_GATE_PER_GROUP_PER_MIN: '30',
158
160
  SWITCHROOM_TG_SEND_GATE_PER_GROUP_BURST: '4',
159
161
  SWITCHROOM_TG_SEND_GATE_EDIT_FLOOR_MS: '2000',
162
+ SWITCHROOM_TG_SEND_GATE_PER_MSG_EDIT_WINDOW_MS: '30000',
163
+ SWITCHROOM_TG_SEND_GATE_PER_MSG_EDIT_MAX: '8',
160
164
  }),
161
165
  )
162
166
  expect(cfg).toEqual({
@@ -168,9 +172,23 @@ describe('send-gate: sendGateConfigFromEnv (yaml → env → createSendGate)', (
168
172
  perGroupPerMin: 30,
169
173
  perGroupBurst: 4,
170
174
  editFloorMs: 2000,
175
+ perMessageEditWindowMs: 30000,
176
+ perMessageEditMaxPerWindow: 8,
171
177
  })
172
178
  })
173
179
 
180
+ it('per-message edit budget: MAX accepts 0 (disables the backstop); WINDOW must be positive', () => {
181
+ expect(
182
+ sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_PER_MSG_EDIT_MAX: '0' }))
183
+ .perMessageEditMaxPerWindow,
184
+ ).toBe(0)
185
+ // A zero/negative window is malformed → dropped so createSendGate's default applies.
186
+ expect(
187
+ sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_PER_MSG_EDIT_WINDOW_MS: '0' }))
188
+ .perMessageEditWindowMs,
189
+ ).toBeUndefined()
190
+ })
191
+
174
192
  it('accepts a fractional per-sec rate (rates are not integers)', () => {
175
193
  const cfg = sendGateConfigFromEnv(E({ SWITCHROOM_TG_SEND_GATE_PER_CHAT_PER_SEC: '0.5' }))
176
194
  expect(cfg.perChatPerSec).toBe(0.5)
@@ -857,3 +875,123 @@ describe('send-gate: flood windows + boot ramp (L3 / §7 hook)', () => {
857
875
  expect(c2.filter((c) => c.at === 11_000).length).toBe(10) // full burst, no ramp
858
876
  })
859
877
  })
878
+
879
+ describe('send-gate: long-horizon per-message edit budget (backstop)', () => {
880
+ it('paces + coalesces a sustained same-message cosmetic edit stream under the rolling cap', async () => {
881
+ const clock = new FakeClock()
882
+ const { calls, fn } = recorder(clock)
883
+ // Tight, fast budget for a deterministic test: 2 cosmetic edits / 5s window,
884
+ // 1s edit floor. A distinct edit every 1s would sail through the floor
885
+ // forever (this is exactly the sub-1/s flood the backstop exists to stop).
886
+ const gate = createSendGate({
887
+ enabled: true,
888
+ clock,
889
+ editFloorMs: 1000,
890
+ perMessageEditWindowMs: 5000,
891
+ perMessageEditMaxPerWindow: 2,
892
+ })
893
+ const msg = 77
894
+ const attempts = 20
895
+ const promises: Promise<unknown>[] = []
896
+ for (let i = 0; i < attempts; i++) {
897
+ promises.push(
898
+ gate.gate(fn(`v${i}`), { messageId: msg, editPayload: `v${i}`, priorityClass: 'cosmetic' }),
899
+ )
900
+ await flush()
901
+ await clock.advance(1000)
902
+ }
903
+ // Drain any final budget-deferred send (a full window is enough).
904
+ await clock.advance(5000)
905
+ await flush()
906
+ await Promise.allSettled(promises)
907
+
908
+ const stats = gate.stats().global
909
+ // The sustained ~1/s stream is paced FAR below the attempt count — bounded
910
+ // by ~2 sends per 5s over the ~25s simulated span, not 20 one-per-second.
911
+ expect(stats.sent).toBeLessThan(attempts)
912
+ expect(stats.sent).toBeLessThanOrEqual(10)
913
+ // The backstop actively deferred at least one edit (not just the 1s floor).
914
+ expect(stats.budgetDeferred).toBeGreaterThan(0)
915
+ // Coalescing collapsed the deferred edits (last-write-wins), so the FINAL
916
+ // send carries the newest payload — no stale body is shown.
917
+ expect(calls[calls.length - 1].label).toBe(`v${attempts - 1}`)
918
+ })
919
+
920
+ it('does NOT throttle distinct message_ids — each message gets its own budget', async () => {
921
+ const clock = new FakeClock()
922
+ const { calls, fn } = recorder(clock)
923
+ const gate = createSendGate({
924
+ enabled: true,
925
+ clock,
926
+ editFloorMs: 1000,
927
+ perMessageEditWindowMs: 5000,
928
+ perMessageEditMaxPerWindow: 1,
929
+ })
930
+ // One cosmetic edit to each of two distinct messages at t=0. A per-message
931
+ // budget of 1 must NOT make message B's first edit wait on message A's.
932
+ const pA = gate.gate(fn('A'), { messageId: 1, editPayload: 'A', priorityClass: 'cosmetic' })
933
+ const pB = gate.gate(fn('B'), { messageId: 2, editPayload: 'B', priorityClass: 'cosmetic' })
934
+ await flush()
935
+ await Promise.all([pA, pB])
936
+ expect(calls.map((c) => c.label).sort()).toEqual(['A', 'B'])
937
+ expect(calls.every((c) => c.at === 0)).toBe(true)
938
+ expect(gate.stats().global.budgetDeferred).toBe(0)
939
+ })
940
+
941
+ it('does NOT throttle non-cosmetic (useful/untagged) edits of the same message', async () => {
942
+ const clock = new FakeClock()
943
+ const { calls, fn } = recorder(clock)
944
+ // Budget so tight it would allow only ONE cosmetic edit ever in the span —
945
+ // yet untagged edits default to `useful` and must bypass the budget entirely
946
+ // (only the 1s floor applies). Legitimate stream/draft edits are untouched.
947
+ const gate = createSendGate({
948
+ enabled: true,
949
+ clock,
950
+ editFloorMs: 1000,
951
+ perMessageEditWindowMs: 100_000,
952
+ perMessageEditMaxPerWindow: 1,
953
+ })
954
+ const msg = 9
955
+ const promises: Promise<unknown>[] = []
956
+ for (let i = 0; i < 5; i++) {
957
+ promises.push(gate.gate(fn(`u${i}`), { messageId: msg, editPayload: `u${i}` }))
958
+ await flush()
959
+ await clock.advance(1000)
960
+ }
961
+ await clock.advance(1000)
962
+ await flush()
963
+ await Promise.allSettled(promises)
964
+ // All five distinct useful edits landed (floor-spaced), none budget-deferred.
965
+ expect(gate.stats().global.sent).toBe(5)
966
+ expect(gate.stats().global.budgetDeferred).toBe(0)
967
+ expect(calls.map((c) => c.label)).toEqual(['u0', 'u1', 'u2', 'u3', 'u4'])
968
+ })
969
+
970
+ it('disables the backstop when perMessageEditMaxPerWindow is 0', async () => {
971
+ const clock = new FakeClock()
972
+ const { calls, fn } = recorder(clock)
973
+ const gate = createSendGate({
974
+ enabled: true,
975
+ clock,
976
+ editFloorMs: 1000,
977
+ perMessageEditMaxPerWindow: 0,
978
+ })
979
+ const msg = 55
980
+ const promises: Promise<unknown>[] = []
981
+ for (let i = 0; i < 6; i++) {
982
+ promises.push(
983
+ gate.gate(fn(`c${i}`), { messageId: msg, editPayload: `c${i}`, priorityClass: 'cosmetic' }),
984
+ )
985
+ await flush()
986
+ await clock.advance(1000)
987
+ }
988
+ await clock.advance(1000)
989
+ await flush()
990
+ await Promise.allSettled(promises)
991
+ // With the budget off, only the 1s floor gates: every floor-spaced distinct
992
+ // edit lands and nothing is budget-deferred.
993
+ expect(gate.stats().global.sent).toBe(6)
994
+ expect(gate.stats().global.budgetDeferred).toBe(0)
995
+ expect(calls.map((c) => c.label)).toEqual(['c0', 'c1', 'c2', 'c3', 'c4', 'c5'])
996
+ })
997
+ })
@@ -197,6 +197,14 @@ export interface BucketCounters {
197
197
  * because the open window exceeded the fail-fast ceiling (part3-design §3).
198
198
  */
199
199
  failedFast: number
200
+ /**
201
+ * Cosmetic edits DEFERRED at least once by the long-horizon per-message edit
202
+ * budget (rolling-window cap). Counts driver loop iterations that slept on the
203
+ * budget, not distinct messages — a paced runaway stream increments this each
204
+ * time it waits out the window. A non-zero, climbing value means the backstop
205
+ * is actively pacing a sustained same-message edit stream.
206
+ */
207
+ budgetDeferred: number
200
208
  }
201
209
 
202
210
  export interface SendGateStats {
@@ -256,6 +264,35 @@ export interface SendGateConfig {
256
264
  perGroupBurst?: number
257
265
  /** Minimum ms between edits of the same message_id. Default 1500. */
258
266
  editFloorMs?: number
267
+ /**
268
+ * Long-horizon per-message edit budget (rolling window). DEFENSE-IN-DEPTH
269
+ * backstop for a runaway same-message edit stream (finn incident: sustained
270
+ * worker-card clock-only edits → ~88min 429 ban). It caps a SINGLE message to
271
+ * at most `perMessageEditMaxPerWindow` cosmetic edits per
272
+ * `perMessageEditWindowMs`; beyond that the driver DEFERS the next edit until
273
+ * the window slides, and newer edits coalesce (last-write-wins) onto the
274
+ * pending slot in the meantime — so a runaway is paced + collapsed rather than
275
+ * sustained.
276
+ *
277
+ * IMPORTANT — this is a coarse rate ceiling, NOT the primary fix. The gate
278
+ * cannot tell a SUBSTANTIVE cosmetic edit (a new worker step) from an
279
+ * elapsed-clock cosmetic edit; both carry a changed payload. So the primary
280
+ * cure for the clock-churn flood is at the source (`worker-activity-feed.ts`
281
+ * suppresses elapsed-only edits via a substance signature), and THIS backstop
282
+ * must sit ABOVE legitimate substantive cadence so it never throttles real
283
+ * updates. The worker feed's own min-edit interval is 2500ms (≤24 edits/min);
284
+ * the default here — 150 edits / 300_000ms ⟹ a 30 edits/min sustained ceiling
285
+ * — sits above that, so a normal (even continuously-updating) card never
286
+ * binds, while a stream that sustains faster than the feed's throttle for
287
+ * minutes (e.g. a future regression re-introducing per-tick churn, or a
288
+ * lowered floor) is paced back to 30/min. Scoped strictly to `cosmetic`-class
289
+ * edits of the SAME `${chat_id}:${messageId}` (worker-feed, typing,
290
+ * reactions); `useful` / `critical` edits and all non-edit sends are
291
+ * untouched, and distinct messages each get their own budget. Set
292
+ * `perMessageEditMaxPerWindow: 0` to disable the backstop.
293
+ */
294
+ perMessageEditWindowMs?: number
295
+ perMessageEditMaxPerWindow?: number
259
296
  /**
260
297
  * Flood windows to re-open at construction (part3-design §7). PR 2 loads
261
298
  * these from `flood-wait.json` BEFORE the first outbound call so a restart
@@ -433,6 +470,14 @@ interface MessageEditState {
433
470
  running: boolean
434
471
  /** Per-message flood suppression window (part3-design §7). */
435
472
  suppressedUntilMs: number
473
+ /**
474
+ * Send-START timestamps of recent COSMETIC edits to this message, kept within
475
+ * the long-horizon rolling window (`perMessageEditWindowMs`). Pruned to the
476
+ * window on each driver loop and appended at each send start; its length is
477
+ * the message's edit count over the trailing window and gates the per-message
478
+ * edit budget. Bounded by the budget cap; empty when the backstop is disabled.
479
+ */
480
+ editWindowTs: number[]
436
481
  }
437
482
 
438
483
  function hashPayload(payload: unknown): string {
@@ -517,6 +562,15 @@ export const SEND_GATE_DEFAULTS = {
517
562
  perGroupBurst: 2,
518
563
  /** Minimum ms between edits of the same message_id. */
519
564
  editFloorMs: 1500,
565
+ /** Long-horizon per-message edit budget: rolling window length (ms). */
566
+ perMessageEditWindowMs: 300_000,
567
+ /**
568
+ * Long-horizon per-message edit budget: max cosmetic edits per window.
569
+ * 150 / 300s ⟹ a 30 edits/min sustained ceiling — above the worker feed's
570
+ * own 24/min cadence so legitimate substantive updates never bind (see the
571
+ * SendGateConfig field doc).
572
+ */
573
+ perMessageEditMaxPerWindow: 150,
520
574
  } as const
521
575
 
522
576
  export function createSendGate(config: SendGateConfig): SendGate {
@@ -529,6 +583,14 @@ export function createSendGate(config: SendGateConfig): SendGate {
529
583
  const perGroupPerMin = config.perGroupPerMin ?? SEND_GATE_DEFAULTS.perGroupPerMin
530
584
  const perGroupBurst = config.perGroupBurst ?? SEND_GATE_DEFAULTS.perGroupBurst
531
585
  const editFloorMs = config.editFloorMs ?? SEND_GATE_DEFAULTS.editFloorMs
586
+ const perMessageEditWindowMs = Math.max(
587
+ 1,
588
+ Math.floor(config.perMessageEditWindowMs ?? SEND_GATE_DEFAULTS.perMessageEditWindowMs),
589
+ )
590
+ const perMessageEditMaxPerWindow = Math.max(
591
+ 0,
592
+ Math.floor(config.perMessageEditMaxPerWindow ?? SEND_GATE_DEFAULTS.perMessageEditMaxPerWindow),
593
+ )
532
594
  const messageStateTtlMs = config.messageStateTtlMs ?? 60_000
533
595
  const maxMessageStates = config.maxMessageStates ?? 5_000
534
596
  const usefulTtlMs = config.usefulTtlMs ?? 120_000
@@ -546,6 +608,7 @@ export function createSendGate(config: SendGateConfig): SendGate {
546
608
  shed: 0,
547
609
  expired: 0,
548
610
  failedFast: 0,
611
+ budgetDeferred: 0,
549
612
  }
550
613
 
551
614
  const bootStart = clock.now()
@@ -605,6 +668,7 @@ export function createSendGate(config: SendGateConfig): SendGate {
605
668
  pending: null,
606
669
  running: false,
607
670
  suppressedUntilMs: 0,
671
+ editWindowTs: [],
608
672
  }
609
673
  perMessage.set(key, state)
610
674
  }
@@ -902,7 +966,28 @@ export function createSendGate(config: SendGateConfig): SendGate {
902
966
  try {
903
967
  while (state.pending) {
904
968
  const now = clock.now()
905
- const readyAt = Math.max(state.lastSentMs + editFloorMs, state.suppressedUntilMs)
969
+ let readyAt = Math.max(state.lastSentMs + editFloorMs, state.suppressedUntilMs)
970
+ // Long-horizon per-message edit budget (backstop). Scoped to cosmetic
971
+ // edits of THIS message: prune the rolling window, and if it is already
972
+ // full, defer until the oldest in-window send ages out. Reading the
973
+ // pending edit's (possibly upgraded) class here means a critical edit
974
+ // that coalesced onto a cosmetic driver is NOT budget-capped — it must
975
+ // never block unbounded (part3-design §3). Newer edits keep coalescing
976
+ // into `state.pending` while we sleep, so pacing preserves last-write-
977
+ // wins rather than sending stale bodies.
978
+ if (perMessageEditMaxPerWindow > 0 && state.pending.priorityClass === 'cosmetic') {
979
+ const windowStart = now - perMessageEditWindowMs
980
+ while (state.editWindowTs.length > 0 && state.editWindowTs[0] <= windowStart) {
981
+ state.editWindowTs.shift()
982
+ }
983
+ if (state.editWindowTs.length >= perMessageEditMaxPerWindow) {
984
+ const budgetReadyAt = state.editWindowTs[0] + perMessageEditWindowMs
985
+ if (budgetReadyAt > readyAt) {
986
+ readyAt = budgetReadyAt
987
+ counters.budgetDeferred++
988
+ }
989
+ }
990
+ }
906
991
  const waitMs = readyAt - now
907
992
  if (waitMs > 0) {
908
993
  // Still inside the floor / an open window — sleep, then re-read
@@ -958,6 +1043,16 @@ export function createSendGate(config: SendGateConfig): SendGate {
958
1043
  // Reserve the send-start time BEFORE awaiting the network so the floor
959
1044
  // is measured from send start (matches the per-message serialization).
960
1045
  state.lastSentMs = clock.now()
1046
+ // Record this send against the long-horizon budget when it is a cosmetic
1047
+ // edit (the only class the budget gates). Recorded at send START so the
1048
+ // rolling window measures dispatch cadence, consistent with the floor.
1049
+ if (perMessageEditMaxPerWindow > 0 && p.priorityClass === 'cosmetic') {
1050
+ state.editWindowTs.push(state.lastSentMs)
1051
+ // Bound the array against pathological inputs (it is naturally ≈ the
1052
+ // budget cap since we prune to the window each loop).
1053
+ const overflow = state.editWindowTs.length - (perMessageEditMaxPerWindow + 1)
1054
+ if (overflow > 0) state.editWindowTs.splice(0, overflow)
1055
+ }
961
1056
  try {
962
1057
  // N3 (liveness): this awaits `p.fn()` with no watchdog. A `fn` that
963
1058
  // NEVER settles would keep `state.running` true forever, making the
@@ -1222,6 +1317,8 @@ export type SendGateEnvConfig = Pick<SendGateConfig, 'enabled'> &
1222
1317
  | 'perGroupPerMin'
1223
1318
  | 'perGroupBurst'
1224
1319
  | 'editFloorMs'
1320
+ | 'perMessageEditWindowMs'
1321
+ | 'perMessageEditMaxPerWindow'
1225
1322
  | 'conservativeGlobalFloodScope'
1226
1323
  >
1227
1324
  >
@@ -1270,6 +1367,12 @@ export function sendGateConfigFromEnv(
1270
1367
  if (perGroupBurst !== undefined) out.perGroupBurst = perGroupBurst
1271
1368
  const editFloorMs = parseNonNegativeInt(env.SWITCHROOM_TG_SEND_GATE_EDIT_FLOOR_MS)
1272
1369
  if (editFloorMs !== undefined) out.editFloorMs = editFloorMs
1370
+ // Long-horizon per-message edit budget (flood-ban backstop). Window ≥1ms is
1371
+ // enforced in createSendGate; MAX_PER_WINDOW=0 disables the backstop.
1372
+ const perMsgWindowMs = parsePositiveInt(env.SWITCHROOM_TG_SEND_GATE_PER_MSG_EDIT_WINDOW_MS)
1373
+ if (perMsgWindowMs !== undefined) out.perMessageEditWindowMs = perMsgWindowMs
1374
+ const perMsgMax = parseNonNegativeInt(env.SWITCHROOM_TG_SEND_GATE_PER_MSG_EDIT_MAX)
1375
+ if (perMsgMax !== undefined) out.perMessageEditMaxPerWindow = perMsgMax
1273
1376
  // #3111 break-glass: restore the pre-#3111 always-open-global flood posture.
1274
1377
  // Unset ⇒ scope-precise default (global opened only on genuinely global 429s).
1275
1378
  const conservativeGlobal = parseBoolFlag(env.SWITCHROOM_TG_SEND_GATE_CONSERVATIVE_GLOBAL)
@@ -10,8 +10,14 @@
10
10
  * resume-400 signature) from "feed opened + finalized".
11
11
  *
12
12
  * Load-bearing constraints:
13
- * 1. `activityEverOpened = true` is set exactly ONCE in gateway.ts (at the
14
- * send-message success site in drainActivitySummary).
13
+ * 1. `activityEverOpened = true` is set only at legitimate feed-OPEN signal
14
+ * sites in gateway.ts — the send-message success site in
15
+ * drainActivitySummary, AND the sub-agent-handback pre-turn ADOPTION site
16
+ * (#3268): an adopted turn inherits an already-open pre-turn card via a
17
+ * seeded `activityMessageId`, so it only ever EDITs the feed (never hits
18
+ * the open branch), and must stamp the flag itself so the turn-end
19
+ * DEGRADED check doesn't false-flag it as "feed never opened". Both are
20
+ * set-TRUE (never a reset), preserving the sticky-true invariant.
15
21
  * 2. `turn.activityEverOpened = false` NEVER appears in gateway.ts (it is only
16
22
  * initialised to `false` in the turn-initialiser object literal, never reset
17
23
  * via a standalone assignment).
@@ -28,9 +34,13 @@ const gatewaySrc = readFileSync(
28
34
  )
29
35
 
30
36
  describe('M-2: activityEverOpened sticky-true invariant', () => {
31
- it('activityEverOpened = true appears exactly once (set at send-message success)', () => {
37
+ it('activityEverOpened = true appears only at the two feed-OPEN signal sites', () => {
38
+ // Site 1: drainActivitySummary send-message success. Site 2: the #3268
39
+ // handback pre-turn ADOPTION seed (an adopted turn only edits, so it stamps
40
+ // the flag itself). Both are set-TRUE; the sticky invariant (no reset to
41
+ // false) is enforced by the next test.
32
42
  const setTrueMatches = [...gatewaySrc.matchAll(/activityEverOpened\s*=\s*true/g)]
33
- expect(setTrueMatches).toHaveLength(1)
43
+ expect(setTrueMatches).toHaveLength(2)
34
44
  })
35
45
 
36
46
  it('turn.activityEverOpened = false never appears (no standalone reset)', () => {
@@ -21,6 +21,7 @@ import {
21
21
  EFFORT_CALLBACK_PREFIX,
22
22
  type EffortCommandDeps,
23
23
  } from "../gateway/effort-command.js";
24
+ import { resolveStaleAwareBusy } from "../gateway/model-command.js";
24
25
  import type { EffortApplyResult } from "../../src/agents/effort-picker.js";
25
26
 
26
27
  function applyOk(level: string, confirmed = false): EffortApplyResult {
@@ -254,4 +255,50 @@ describe("effort-command: /effort default (#3039)", () => {
254
255
  expect(r.text).toContain("/effort default");
255
256
  expect(r.text).toContain("lasts until the agent’s next restart");
256
257
  });
258
+
259
+ // #3262 parity with /model: the gateway gates the effort apply-vs-queue on
260
+ // `resolveModelEffortBusy().currentTurnActive` (the turn-atom leg — /effort
261
+ // does not consult the delivery-machine/approval gate). These pin the SAME
262
+ // stale-aware decision the gateway feeds that gate: a dangling atom older than
263
+ // the hard TTL reads idle → APPLY; a fresh atom reads busy → QUEUE.
264
+ describe("phantom 'active turn' apply-vs-queue (#3262)", () => {
265
+ const HARD_TTL = 10 * 60_000;
266
+ // The gateway's effort queue predicate, reduced to the signal it reads.
267
+ const effortWouldQueue = (currentTurnActive: boolean): boolean => currentTurnActive;
268
+
269
+ it("APPLIES a set when the turn atom is DANGLING (older than the hard TTL)", () => {
270
+ const r = resolveStaleAwareBusy({
271
+ currentTurnActive: true,
272
+ turnAgeMs: HARD_TTL + 1,
273
+ machineInTurn: false,
274
+ oldestPendingApprovalAgeMs: null,
275
+ hardTtlMs: HARD_TTL,
276
+ });
277
+ expect(r.clearStaleTurn).toBe(true);
278
+ expect(effortWouldQueue(r.currentTurnActive)).toBe(false); // → applies now
279
+ });
280
+
281
+ it("QUEUES a set when the turn is FRESH (marker within the TTL) — no regression", () => {
282
+ const r = resolveStaleAwareBusy({
283
+ currentTurnActive: true,
284
+ turnAgeMs: 5_000,
285
+ machineInTurn: false,
286
+ oldestPendingApprovalAgeMs: null,
287
+ hardTtlMs: HARD_TTL,
288
+ });
289
+ expect(r.clearStaleTurn).toBe(false);
290
+ expect(effortWouldQueue(r.currentTurnActive)).toBe(true); // → queues
291
+ });
292
+
293
+ it("APPLIES when there is no turn atom at all", () => {
294
+ const r = resolveStaleAwareBusy({
295
+ currentTurnActive: false,
296
+ turnAgeMs: null,
297
+ machineInTurn: false,
298
+ oldestPendingApprovalAgeMs: null,
299
+ hardTtlMs: HARD_TTL,
300
+ });
301
+ expect(effortWouldQueue(r.currentTurnActive)).toBe(false);
302
+ });
303
+ });
257
304
  });
@@ -20,6 +20,7 @@
20
20
  import { describe, it, expect } from 'vitest'
21
21
  import {
22
22
  decideSupersede,
23
+ decideSupersedeCorrection,
23
24
  FlushedTurnSupersedeRegistry,
24
25
  DEFAULT_SUPERSEDE_TTL_MS,
25
26
  type FlushedTurnRecord,
@@ -33,6 +34,65 @@ const rec = (over: Partial<FlushedTurnRecord> = {}): FlushedTurnRecord => ({
33
34
  ...over,
34
35
  })
35
36
 
37
+ describe('decideSupersedeCorrection — edit-in-place vs delete+resend', () => {
38
+ const base = {
39
+ flushMessageIds: [500],
40
+ chunkCount: 1,
41
+ hasFiles: false,
42
+ suppressText: false,
43
+ hasOpenPreview: false,
44
+ }
45
+
46
+ it('edits in place when the flush posted ONE message and the reply fits one plain-text message', () => {
47
+ const c = decideSupersedeCorrection(base)
48
+ expect(c.mode).toBe('edit-in-place')
49
+ // edit target is the single flushed message; nothing is deleted (A becomes B).
50
+ expect(c).toMatchObject({ mode: 'edit-in-place', editMessageId: 500, deleteMessageIds: [] })
51
+ })
52
+
53
+ it('literal (format:text) single-message replies still edit in place (text is text)', () => {
54
+ // literalText is not an input to the decision — a literal reply is still a
55
+ // text message the edit lane renders identically.
56
+ const c = decideSupersedeCorrection({ ...base })
57
+ expect(c.mode).toBe('edit-in-place')
58
+ })
59
+
60
+ it('falls back to delete+resend for a MULTI-PART reply (edit can only carry one message)', () => {
61
+ const c = decideSupersedeCorrection({ ...base, chunkCount: 3 })
62
+ expect(c).toEqual({ mode: 'delete-resend', deleteMessageIds: [500] })
63
+ })
64
+
65
+ it('falls back to delete+resend when the flush posted MORE THAN ONE message', () => {
66
+ const c = decideSupersedeCorrection({ ...base, flushMessageIds: [500, 501] })
67
+ expect(c).toEqual({ mode: 'delete-resend', deleteMessageIds: [500, 501] })
68
+ })
69
+
70
+ it('falls back to delete+resend for a file/album reply (not a plain-text edit)', () => {
71
+ const c = decideSupersedeCorrection({ ...base, hasFiles: true })
72
+ expect(c.mode).toBe('delete-resend')
73
+ })
74
+
75
+ it('falls back to delete+resend for a voice-only reply (no text body to edit into)', () => {
76
+ const c = decideSupersedeCorrection({ ...base, suppressText: true })
77
+ expect(c.mode).toBe('delete-resend')
78
+ })
79
+
80
+ it('falls back to delete+resend when a draft-stream preview already owns the edit lane', () => {
81
+ const c = decideSupersedeCorrection({ ...base, hasOpenPreview: true })
82
+ expect(c.mode).toBe('delete-resend')
83
+ })
84
+
85
+ it('anti-duplicate invariant: every correction resolves to exactly one surviving message', () => {
86
+ // edit-in-place → A becomes B (1 message, 0 deletes); delete-resend → all
87
+ // flushed ids deleted then B sent fresh (1 message). Neither leaves A+B both
88
+ // visible, and neither loses the reply.
89
+ const editing = decideSupersedeCorrection(base)
90
+ expect(editing.mode === 'edit-in-place' && editing.deleteMessageIds.length).toBe(0)
91
+ const resending = decideSupersedeCorrection({ ...base, chunkCount: 2 })
92
+ expect(resending.deleteMessageIds).toEqual([500])
93
+ })
94
+ })
95
+
36
96
  describe('decideSupersede — the duplicate-reply decision core', () => {
37
97
  it('supersedes when the reply is attributed to the SAME turn as the flush', () => {
38
98
  // The common late-replay dup: the gateway resolves the reply's turnId (from