switchroom 0.19.43 → 0.19.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/agent-scheduler/index.js +4 -1
  2. package/dist/auth-broker/index.js +254 -119
  3. package/dist/cli/notion-write-pretool.mjs +4 -1
  4. package/dist/cli/switchroom.js +3995 -3196
  5. package/dist/host-control/main.js +270 -130
  6. package/dist/vault/approvals/kernel-server.js +201 -66
  7. package/dist/vault/broker/server.js +263 -128
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +11 -4
  10. package/telegram-plugin/dist/gateway/gateway.js +945 -638
  11. package/telegram-plugin/flushed-turn-supersede.ts +190 -16
  12. package/telegram-plugin/gateway/cron-session.ts +31 -0
  13. package/telegram-plugin/gateway/gateway.ts +66 -65
  14. package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
  15. package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
  16. package/telegram-plugin/gateway/stream-render.ts +91 -1
  17. package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
  18. package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
  19. package/telegram-plugin/gateway/turn-end.ts +9 -1
  20. package/telegram-plugin/reply-owner-resolve.ts +102 -26
  21. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
  22. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
  23. package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
  24. package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
  25. package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
  26. package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
  27. package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
  29. package/vendor/hindsight-memory/settings.json +1 -1
@@ -39,7 +39,7 @@ import {
39
39
  type ReplyOwnerTier,
40
40
  type AnswerDeliveredLatch,
41
41
  } from '../reply-owner-resolve.js'
42
- import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
42
+ import { FlushedTurnSupersedeRegistry, SUPERSEDE_OPEN_WINDOW_CAP_MS, SUPERSEDE_COMPLETED_GRACE_MS } from '../flushed-turn-supersede.js'
43
43
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
44
44
  import { latestTurnForChat } from '../gateway/latest-turn-lookup.js'
45
45
 
@@ -48,6 +48,10 @@ const NONE: ReplyOwnerCandidates = {
48
48
  originTurnId: null,
49
49
  quotedTurnId: null,
50
50
  latestEndedTurnId: null,
51
+ // #4175 — an omitted age/ttl now fails CLOSED, so precedence fixtures carry a
52
+ // fresh bound; the fail-closed default has its own dedicated tests below.
53
+ latestEndedAgeMs: 1_000,
54
+ latestEndedTtlMs: 60_000,
51
55
  }
52
56
 
53
57
  /** The OLD (pre-fix) resolver chain the gateway ran on main — the 2-tier
@@ -229,6 +233,8 @@ describe('F2 — recency-bound the destructive latest-ended supersede tier', ()
229
233
  originTurnId: null,
230
234
  quotedTurnId: null,
231
235
  latestEndedTurnId: null,
236
+ latestEndedAgeMs: 1_000,
237
+ latestEndedTtlMs: 60_000,
232
238
  }
233
239
 
234
240
  it('accepts a latest-ended turn that ended within the supersede TTL', () => {
@@ -236,8 +242,8 @@ describe('F2 — recency-bound the destructive latest-ended supersede tier', ()
236
242
  resolveReplyOwnerTurnId({
237
243
  ...base,
238
244
  latestEndedTurnId: 'turn-T',
239
- latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS - 1,
240
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
245
+ latestEndedAgeMs: SUPERSEDE_OPEN_WINDOW_CAP_MS - 1,
246
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
241
247
  }),
242
248
  ).toBe('turn-T')
243
249
  })
@@ -248,8 +254,8 @@ describe('F2 — recency-bound the destructive latest-ended supersede tier', ()
248
254
  resolveReplyOwnerTurnId({
249
255
  ...base,
250
256
  latestEndedTurnId: 'turn-STALE',
251
- latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 5_000,
252
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
257
+ latestEndedAgeMs: SUPERSEDE_OPEN_WINDOW_CAP_MS + 5_000,
258
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
253
259
  }),
254
260
  ).toBeNull()
255
261
  })
@@ -267,8 +273,8 @@ describe('F2 — recency-bound the destructive latest-ended supersede tier', ()
267
273
  const ownerId = resolveReplyOwnerTurnId({
268
274
  ...base,
269
275
  latestEndedTurnId: 'turn-STALE',
270
- latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 5_000,
271
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
276
+ latestEndedAgeMs: SUPERSEDE_OPEN_WINDOW_CAP_MS + 5_000,
277
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
272
278
  })
273
279
  expect(ownerId).toBeNull()
274
280
  const decision = reg.take(CHAT, undefined, { liveTurnId: ownerId, now: now + 10 })
@@ -277,10 +283,29 @@ describe('F2 — recency-bound the destructive latest-ended supersede tier', ()
277
283
  expect(reg.peek(CHAT, undefined, { liveTurnId: 'turn-T2', now: now + 10 }).supersede).toBe(true)
278
284
  })
279
285
 
280
- it('unbounded when no age is supplied (back-compat: pre-F2 precedence intact)', () => {
286
+ it('#4175 an OMITTED age fails CLOSED: dropped bound wiring degrades to a ' +
287
+ 'visible dup, never unbounded destructive authority', () => {
288
+ // Pre-#4175 this was the "unbounded back-compat escape": omitting the age
289
+ // granted UNBOUNDED deletion authority — i.e. the failure mode of DROPPING
290
+ // the gateway's `latestEndedAgeMs`/`latestEndedTtlMs` wiring was fail-OPEN.
291
+ // Restoring that escape (return true on undefined age/ttl) turns this red.
281
292
  expect(
282
- resolveReplyOwnerTurnId({ ...base, latestEndedTurnId: 'turn-T' }),
283
- ).toBe('turn-T')
293
+ resolveReplyOwnerTurnId({
294
+ ...base,
295
+ latestEndedTurnId: 'turn-T',
296
+ latestEndedAgeMs: undefined,
297
+ latestEndedTtlMs: undefined,
298
+ }),
299
+ ).toBeNull()
300
+ // ttl alone omitted → also closed.
301
+ expect(
302
+ resolveReplyOwnerTurnId({
303
+ ...base,
304
+ latestEndedTurnId: 'turn-T',
305
+ latestEndedAgeMs: 1_000,
306
+ latestEndedTtlMs: undefined,
307
+ }),
308
+ ).toBeNull()
284
309
  })
285
310
  })
286
311
 
@@ -475,7 +500,7 @@ describe('#3426 — async sub-agent handback after an interim-ack turn', () => {
475
500
  quotedTurnId: null,
476
501
  latestEndedTurnId: owner.turnId,
477
502
  latestEndedAgeMs: HANDBACK_AGE_MS,
478
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
503
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
479
504
  })
480
505
  expect(ownerId).toBe(ACK_TURN)
481
506
 
@@ -629,6 +654,8 @@ describe('resolveReplyOwnerTier — precedence + latest-ended TTL bound', () =>
629
654
  originTurnId: null,
630
655
  quotedTurnId: null,
631
656
  latestEndedTurnId: null,
657
+ latestEndedAgeMs: 1_000,
658
+ latestEndedTtlMs: 60_000,
632
659
  }
633
660
 
634
661
  it('live wins over every lower tier', () => {
@@ -657,8 +684,8 @@ describe('resolveReplyOwnerTier — precedence + latest-ended TTL bound', () =>
657
684
  const stale: ReplyOwnerCandidates = {
658
685
  ...base,
659
686
  latestEndedTurnId: 'E',
660
- latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 1,
661
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
687
+ latestEndedAgeMs: SUPERSEDE_OPEN_WINDOW_CAP_MS + 1,
688
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
662
689
  }
663
690
  expect(resolveReplyOwnerTier(stale)).toBe('none')
664
691
  // And the id resolver agrees (no destructive authority granted to a stale turn).
@@ -718,7 +745,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
718
745
  quotedTurnId: null,
719
746
  latestEndedTurnId: OWNER,
720
747
  latestEndedAgeMs: 30_000,
721
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
748
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
722
749
  ...over,
723
750
  })
724
751
 
@@ -730,6 +757,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
730
757
  resolvedTurnId: OWNER,
731
758
  candidates: cands({ originTurnId: OWNER }),
732
759
  handbackCouldOwnReply: false,
760
+ subagentCouldOwnReply: false,
733
761
  }),
734
762
  ).toBe(true)
735
763
  })
@@ -741,6 +769,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
741
769
  resolvedTurnId: OWNER,
742
770
  candidates: cands({ quotedTurnId: OWNER }),
743
771
  handbackCouldOwnReply: false,
772
+ subagentCouldOwnReply: false,
744
773
  }),
745
774
  ).toBe(true)
746
775
  })
@@ -758,6 +787,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
758
787
  latestEndedTurnId: OWNER,
759
788
  }),
760
789
  handbackCouldOwnReply: false,
790
+ subagentCouldOwnReply: false,
761
791
  }),
762
792
  ).toBe(false)
763
793
  }
@@ -774,6 +804,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
774
804
  quotedTurnId: tier === 'quoted' ? OWNER : null,
775
805
  }),
776
806
  handbackCouldOwnReply: true,
807
+ subagentCouldOwnReply: false,
777
808
  }),
778
809
  ).toBe(false)
779
810
  }
@@ -787,6 +818,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
787
818
  resolvedTurnId: OWNER,
788
819
  candidates: cands({ liveTurnId: OWNER }),
789
820
  handbackCouldOwnReply: true,
821
+ subagentCouldOwnReply: false,
790
822
  }),
791
823
  ).toBe(true)
792
824
  })
@@ -798,6 +830,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
798
830
  resolvedTurnId: null,
799
831
  candidates: cands({ latestEndedTurnId: null }),
800
832
  handbackCouldOwnReply: false,
833
+ subagentCouldOwnReply: false,
801
834
  }),
802
835
  ).toBe(false)
803
836
  })
@@ -806,7 +839,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
806
839
  'still denies a past-TTL turn any bypass authority', () => {
807
840
  const stale = cands({
808
841
  originTurnId: OWNER,
809
- latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 1,
842
+ latestEndedAgeMs: SUPERSEDE_OPEN_WINDOW_CAP_MS + 1,
810
843
  })
811
844
  expect(
812
845
  decideContentGateBypass({
@@ -814,6 +847,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
814
847
  resolvedTurnId: OWNER,
815
848
  candidates: stale,
816
849
  handbackCouldOwnReply: false,
850
+ subagentCouldOwnReply: false,
817
851
  }),
818
852
  ).toBe(false)
819
853
  })
@@ -825,6 +859,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
825
859
  resolvedTurnId: OWNER,
826
860
  candidates: cands(),
827
861
  handbackCouldOwnReply: false,
862
+ subagentCouldOwnReply: false,
828
863
  }),
829
864
  ).toBe(true)
830
865
  })
@@ -845,6 +880,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
845
880
  resolvedTurnId: OWNER,
846
881
  candidates: absent as unknown as ReplyOwnerCandidates,
847
882
  handbackCouldOwnReply: false,
883
+ subagentCouldOwnReply: false,
848
884
  }),
849
885
  ).toBe(false)
850
886
  }
@@ -871,8 +907,8 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
871
907
  const IDS = [null, OWNER, OTHER] as const
872
908
  /** fresh / stale / unbounded (no age+ttl — the documented back-compat path). */
873
909
  const AGES = [
874
- { label: 'fresh', ageMs: 30_000 as number | null, ttlMs: DEFAULT_SUPERSEDE_TTL_MS as number | undefined },
875
- { label: 'stale', ageMs: DEFAULT_SUPERSEDE_TTL_MS + 1, ttlMs: DEFAULT_SUPERSEDE_TTL_MS },
910
+ { label: 'fresh', ageMs: 30_000 as number | null, ttlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS as number | undefined },
911
+ { label: 'stale', ageMs: SUPERSEDE_OPEN_WINDOW_CAP_MS + 1, ttlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS },
876
912
  { label: 'unbounded', ageMs: null, ttlMs: undefined },
877
913
  ]
878
914
 
@@ -907,7 +943,11 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
907
943
  for (const quotedTurnId of IDS)
908
944
  for (const latestEndedTurnId of IDS)
909
945
  for (const age of AGES)
910
- for (const handbackCouldOwnReply of [false, true]) {
946
+ for (const handbackCouldOwnReply of [false, true])
947
+ // #4176 — the sweep now also spans sub-agent liveness, so a
948
+ // regression that ignores it on the steerable tiers is caught
949
+ // by the same zero-new-capability property.
950
+ for (const subagentCouldOwnReply of [false, true]) {
911
951
  cases++
912
952
  const c: ReplyOwnerCandidates = {
913
953
  liveTurnId,
@@ -923,6 +963,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
923
963
  resolvedTurnId: resolveReplyOwnerTurnId(c),
924
964
  candidates: c,
925
965
  handbackCouldOwnReply,
966
+ subagentCouldOwnReply,
926
967
  })
927
968
  // Strip the model-supplied ids: the FRAMEWORK alone resolves the
928
969
  // owner — the reach the model already had by staying silent.
@@ -936,6 +977,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
936
977
  resolvedTurnId: resolveReplyOwnerTurnId(stripped),
937
978
  candidates: stripped,
938
979
  handbackCouldOwnReply,
980
+ subagentCouldOwnReply,
939
981
  })
940
982
  if (withModelIds) {
941
983
  bypasses++
@@ -945,7 +987,8 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
945
987
  violations.push(
946
988
  `tier=${tier} live=${liveTurnId} origin=${originTurnId} ` +
947
989
  `quoted=${quotedTurnId} latestEnded=${latestEndedTurnId} ` +
948
- `age=${age.label} handback=${handbackCouldOwnReply}`,
990
+ `age=${age.label} handback=${handbackCouldOwnReply} ` +
991
+ `subagentLive=${subagentCouldOwnReply}`,
949
992
  )
950
993
  }
951
994
  }
@@ -959,7 +1002,7 @@ describe('decideContentGateBypass — corroborated bypass of the #3429 content g
959
1002
  expect(r.violations).toEqual([])
960
1003
  // Non-degeneracy: the sweep must actually cover the space and must actually
961
1004
  // reach the branch under test, so it can never quietly become a no-op.
962
- expect(r.cases).toBe(IDS.length ** 4 * AGES.length * 2)
1005
+ expect(r.cases).toBe(IDS.length ** 4 * AGES.length * 2 * 2)
963
1006
  expect(r.bypasses).toBeGreaterThan(0)
964
1007
  // …and specifically, many cases must win their bypass on the WIDENED,
965
1008
  // model-steerable `origin`/`quoted` tiers — the thing being vouched for.
@@ -1069,7 +1112,7 @@ describe('#3725 — a still-running turn is not a supersede anchor', () => {
1069
1112
  quotedTurnId: null,
1070
1113
  latestEndedTurnId: latestEnded?.turnId ?? null,
1071
1114
  latestEndedAgeMs: latestEnded?.endedAt != null ? NOW - latestEnded.endedAt : null,
1072
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
1115
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
1073
1116
  ...over,
1074
1117
  }
1075
1118
  }
@@ -1144,6 +1187,7 @@ describe('#3725 — a still-running turn is not a supersede anchor', () => {
1144
1187
  resolvedTurnId: resolveReplyOwnerTurnId(fixed),
1145
1188
  candidates: fixed,
1146
1189
  handbackCouldOwnReply: false,
1190
+ subagentCouldOwnReply: false,
1147
1191
  }),
1148
1192
  ).toBe(false)
1149
1193
  // Red-on-main contrast: the same reply DID bypass the #3429 content gate
@@ -1164,6 +1208,7 @@ describe('#3725 — a still-running turn is not a supersede anchor', () => {
1164
1208
  resolvedTurnId: null,
1165
1209
  candidates: fixed,
1166
1210
  handbackCouldOwnReply: false,
1211
+ subagentCouldOwnReply: false,
1167
1212
  }),
1168
1213
  ).toBe(false)
1169
1214
 
@@ -1172,14 +1217,14 @@ describe('#3725 — a still-running turn is not a supersede anchor', () => {
1172
1217
  expect(preFixOwnerId(prefix)).toBe(RUNNING)
1173
1218
  })
1174
1219
 
1175
- it('layer 2: an EXPLICIT null age is rejected outright, while an OMITTED age ' +
1176
- 'keeps the pre-F2 back-compat escape', () => {
1220
+ it('layer 2: an EXPLICIT null age is rejected outright, and an OMITTED age ' +
1221
+ 'fails closed too (#4175)', () => {
1177
1222
  const base: ReplyOwnerCandidates = {
1178
1223
  liveTurnId: null,
1179
1224
  originTurnId: null,
1180
1225
  quotedTurnId: null,
1181
1226
  latestEndedTurnId: ENDED,
1182
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
1227
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
1183
1228
  }
1184
1229
  // Computed-null (the caller's candidate turn has not ended) ⇒ closed.
1185
1230
  expect(resolveReplyOwnerTier({ ...base, latestEndedAgeMs: null })).toBe('none')
@@ -1190,11 +1235,13 @@ describe('#3725 — a still-running turn is not a supersede anchor', () => {
1190
1235
  resolvedTurnId: ENDED,
1191
1236
  candidates: { ...base, originTurnId: ENDED, latestEndedAgeMs: null },
1192
1237
  handbackCouldOwnReply: false,
1238
+ subagentCouldOwnReply: false,
1193
1239
  }),
1194
1240
  ).toBe(false)
1195
- // Property omitted entirely ⇒ unchanged pre-F2 behaviour.
1196
- expect(resolveReplyOwnerTier(base)).toBe('latest-ended')
1197
- expect(resolveReplyOwnerTurnId(base)).toBe(ENDED)
1241
+ // Property omitted entirely ⇒ fail CLOSED too (#4175 — the old unbounded
1242
+ // back-compat escape failed toward silent edit-over when wiring dropped).
1243
+ expect(resolveReplyOwnerTier(base)).toBe('none')
1244
+ expect(resolveReplyOwnerTurnId(base)).toBeNull()
1198
1245
  })
1199
1246
 
1200
1247
  it('end-to-end: a late reply while another topic is still running cannot ' +
@@ -1216,3 +1263,226 @@ describe('#3725 — a still-running turn is not a supersede anchor', () => {
1216
1263
  expect(reg.peek(CHAT, undefined, { liveTurnId: RUNNING, now: NOW + 10 }).supersede).toBe(true)
1217
1264
  })
1218
1265
  })
1266
+
1267
+ describe('2026-08-01 klanker incident — slow flush→reply gap (Stop-hook nudge + /compact)', () => {
1268
+ // The observed double-send (gateway-supervisor.log, chat 12345):
1269
+ // 21:17:04 answer-ready quiescence flush delivers msg 25680 (turn #25674)
1270
+ // 21:17:04 proactive /compact fires (occupancy ~393k)
1271
+ // 21:19:28 the model's canonical `reply` lands — 143 s AFTER the flush,
1272
+ // REWORDED post-compact (1770 vs 1563 chars), no live turn, no
1273
+ // origin echo (DM), no quote → latest-ended is the only tier.
1274
+ // 21:19:39 msg 25682 ships → the user sees the same answer twice.
1275
+ // Under the old 60 s window BOTH guards had expired: the latest-ended owner
1276
+ // tier rejected the 143 s-old turn (owner=null → the answer-delivered latch
1277
+ // was unreachable) and the registry record itself was 'expired'. These tests
1278
+ // replay the incident timeline through the exact pure cores the gateway wires
1279
+ // (`resolveReplyOwnerTurn` → `decideContentGateBypass` → registry.take`) and
1280
+ // assert the OUTCOME: the late reply collapses onto the flushed message —
1281
+ // one bubble, never two. Shrinking SUPERSEDE_OPEN_WINDOW_CAP_MS below the
1282
+ // observed gap turns these red.
1283
+ const CHAT = '424242'
1284
+ const TURN = 'turn-25674'
1285
+ const FLUSH_TS = 5_000_000
1286
+ const REPLY_TS = FLUSH_TS + 143_000 // the observed 2 m 23 s flush→reply gap
1287
+ const FLUSHED_TEXT =
1288
+ 'Pulled the live numbers from the usage table. Two findings, and the second one matters: ' +
1289
+ 'the per-turn cost is dominated by cache writes, not output tokens.'
1290
+ // Post-compact the model REGENERATED the answer — same substance, different
1291
+ // bytes — so neither the #546 exact-text dedup nor the containment matcher
1292
+ // can catch it; only turnId identity + the content-gate bypass can.
1293
+ const REWORDED_REPLY =
1294
+ 'I pulled the live numbers out of the usage table. Two findings — and the second is the ' +
1295
+ 'one that matters: cache writes, not output tokens, dominate per-turn cost.'
1296
+
1297
+ const incidentCandidates = (now: number): ReplyOwnerCandidates => ({
1298
+ liveTurnId: null, // flush's synthetic turn_end already tore the atom down
1299
+ originTurnId: null, // DM — no origin_turn_id echo
1300
+ quotedTurnId: null, // no quote linkage
1301
+ latestEndedTurnId: TURN,
1302
+ latestEndedAgeMs: now - FLUSH_TS,
1303
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
1304
+ // #4173 — the completion window is OPEN: the flush ended the turn
1305
+ // synthetically, and the session's REAL turn_end has not been observed
1306
+ // (the model is mid Stop-hook-nudge → /compact → recompose).
1307
+ latestEndedRealEndAgeMs: null,
1308
+ latestEndedCompletedGraceMs: SUPERSEDE_COMPLETED_GRACE_MS,
1309
+ })
1310
+
1311
+ it('the latest-ended owner tier still accepts the flushed turn 143 s after it ended', () => {
1312
+ const c = incidentCandidates(REPLY_TS)
1313
+ expect(resolveReplyOwnerTier(c)).toBe('latest-ended')
1314
+ expect(resolveReplyOwnerTurnId(c)).toBe(TURN)
1315
+ })
1316
+
1317
+ it('outcome: the reworded late reply supersedes the flushed message — exactly one bubble', () => {
1318
+ const reg = new FlushedTurnSupersedeRegistry()
1319
+ reg.record(CHAT, undefined, { turnId: TURN, messageIds: [25680], text: FLUSHED_TEXT }, FLUSH_TS)
1320
+
1321
+ // The gateway's own composition, step for step (outbound-send-path.ts):
1322
+ const c = incidentCandidates(REPLY_TS)
1323
+ const ownerId = resolveReplyOwnerTurnId(c)
1324
+ const bypass = decideContentGateBypass({
1325
+ tier: resolveReplyOwnerTier(c),
1326
+ resolvedTurnId: ownerId,
1327
+ candidates: c,
1328
+ handbackCouldOwnReply: false, // no subagent_handback enqueued in the window
1329
+ subagentCouldOwnReply: false, // no sub-agent live on the session (#4176)
1330
+ })
1331
+ expect(bypass).toBe(true)
1332
+ const d = reg.take(CHAT, undefined, {
1333
+ liveTurnId: ownerId,
1334
+ replyText: REWORDED_REPLY,
1335
+ positiveAttribution: bypass,
1336
+ now: REPLY_TS,
1337
+ })
1338
+ // The flushed message is corrected in place / replaced — the reply never
1339
+ // ships as a second bubble alongside msg 25680.
1340
+ expect(d).toMatchObject({ supersede: true, reason: 'supersede', deleteMessageIds: [25680] })
1341
+ })
1342
+
1343
+ it('a handback landing in the widened window still keeps the content gate (fresh send, no edit-over)', () => {
1344
+ // Safety half of the widening: MORE time in the window must never convert a
1345
+ // genuinely-new background handback into a silent edit-over of the flushed
1346
+ // answer. With a handback in flight the bypass stays off and different
1347
+ // content declines as 'new-content' (two visible messages — the #3429 trade).
1348
+ const reg = new FlushedTurnSupersedeRegistry()
1349
+ reg.record(CHAT, undefined, { turnId: TURN, messageIds: [25680], text: FLUSHED_TEXT }, FLUSH_TS)
1350
+ const c = incidentCandidates(REPLY_TS)
1351
+ const bypass = decideContentGateBypass({
1352
+ tier: resolveReplyOwnerTier(c),
1353
+ resolvedTurnId: resolveReplyOwnerTurnId(c),
1354
+ candidates: c,
1355
+ handbackCouldOwnReply: true,
1356
+ subagentCouldOwnReply: false,
1357
+ })
1358
+ expect(bypass).toBe(false)
1359
+ const d = reg.take(CHAT, undefined, {
1360
+ liveTurnId: resolveReplyOwnerTurnId(c),
1361
+ replyText: 'Worker finished: the deploy went out clean, all checks green.',
1362
+ positiveAttribution: bypass,
1363
+ now: REPLY_TS,
1364
+ })
1365
+ expect(d.supersede).toBe(false)
1366
+ expect(d.reason).toBe('new-content')
1367
+ // The record survives for the turn's OWN late replay.
1368
+ expect(reg.peek(CHAT, undefined, { liveTurnId: TURN, now: REPLY_TS }).supersede).toBe(true)
1369
+ })
1370
+ })
1371
+
1372
+
1373
+ describe('#4173 — the latest-ended tier follows the turn-completion window', () => {
1374
+ const ENDED = 'turn-ENDED'
1375
+ const c = (over: Partial<ReplyOwnerCandidates>): ReplyOwnerCandidates => ({
1376
+ liveTurnId: null,
1377
+ originTurnId: null,
1378
+ quotedTurnId: null,
1379
+ latestEndedTurnId: ENDED,
1380
+ latestEndedTtlMs: SUPERSEDE_OPEN_WINDOW_CAP_MS,
1381
+ latestEndedCompletedGraceMs: SUPERSEDE_COMPLETED_GRACE_MS,
1382
+ ...over,
1383
+ })
1384
+
1385
+ it('OPEN window (real turn_end not observed): accepted at 143 s AND at 20 min — ' +
1386
+ 'a compaction longer than any tuned TTL still resolves the owner (#4166)', () => {
1387
+ expect(resolveReplyOwnerTier(c({ latestEndedAgeMs: 143_000, latestEndedRealEndAgeMs: null }))).toBe('latest-ended')
1388
+ expect(resolveReplyOwnerTier(c({ latestEndedAgeMs: 20 * 60_000, latestEndedRealEndAgeMs: null }))).toBe('latest-ended')
1389
+ })
1390
+
1391
+ it('OPEN window past the crash-backstop cap: rejected (signal loss cannot grant forever)', () => {
1392
+ expect(
1393
+ resolveReplyOwnerTier(c({
1394
+ latestEndedAgeMs: SUPERSEDE_OPEN_WINDOW_CAP_MS + 1,
1395
+ latestEndedRealEndAgeMs: null,
1396
+ })),
1397
+ ).toBe('none')
1398
+ })
1399
+
1400
+ it('COMPLETED window: accepted inside the replay grace, rejected past it — the ' +
1401
+ 'Task-sub-agent-after-parent-turn-end reply resolves NO owner and gets no ' +
1402
+ 'destructive authority', () => {
1403
+ // Normal turn: realEnd == end, 30 s ago → the original tight bound holds.
1404
+ expect(
1405
+ resolveReplyOwnerTier(c({ latestEndedAgeMs: 30_000, latestEndedRealEndAgeMs: 30_000 })),
1406
+ ).toBe('latest-ended')
1407
+ // 90 s after the session really stopped: a same-bridge decoupled reply (a
1408
+ // background Task sub-agent replying after the parent turn ended) resolves
1409
+ // null — it can neither supersede nor bypass the content gate; it sends
1410
+ // fresh. Widening the completed bound past the grace turns this red.
1411
+ expect(
1412
+ resolveReplyOwnerTier(c({
1413
+ latestEndedAgeMs: 90_000,
1414
+ latestEndedRealEndAgeMs: 90_000,
1415
+ })),
1416
+ ).toBe('none')
1417
+ })
1418
+ })
1419
+
1420
+ /**
1421
+ * #4176 — the sub-agent liveness input to the bypass, at the decision level.
1422
+ * The behavioural proof lives in `send-reply-golden.test.ts`; these pin the
1423
+ * pure rule, including the fail-CLOSED handling of an omitted flag (the
1424
+ * telegram-plugin tree is NOT covered by `tsc --noEmit`, so the type alone
1425
+ * cannot stop a caller dropping the wiring).
1426
+ */
1427
+ describe('#4176 — a live sub-agent denies the content-gate bypass', () => {
1428
+ const CHAT = '1001'
1429
+ const TURN = `${CHAT}:_#flushed-4176`
1430
+ const base = {
1431
+ liveTurnId: null,
1432
+ originTurnId: null,
1433
+ quotedTurnId: null,
1434
+ latestEndedTurnId: TURN,
1435
+ latestEndedAgeMs: 90_000,
1436
+ latestEndedTtlMs: 30 * 60_000,
1437
+ latestEndedRealEndAgeMs: null,
1438
+ latestEndedCompletedGraceMs: 60_000,
1439
+ } as ReplyOwnerCandidates
1440
+
1441
+ for (const tier of ['latest-ended', 'origin', 'quoted'] as ReplyOwnerTier[]) {
1442
+ it(`${tier}: bypass granted with no sub-agent live, DENIED while one is live`, () => {
1443
+ const call = (subagentCouldOwnReply: boolean) =>
1444
+ decideContentGateBypass({
1445
+ tier,
1446
+ resolvedTurnId: TURN,
1447
+ candidates: { ...base, originTurnId: TURN, quotedTurnId: TURN },
1448
+ handbackCouldOwnReply: false,
1449
+ subagentCouldOwnReply,
1450
+ })
1451
+ expect(call(false)).toBe(true)
1452
+ expect(call(true)).toBe(false)
1453
+ })
1454
+ }
1455
+
1456
+ it('fails CLOSED when the flag is omitted entirely (dropped wiring ⇒ no bypass)', () => {
1457
+ const input = {
1458
+ tier: 'latest-ended' as ReplyOwnerTier,
1459
+ resolvedTurnId: TURN,
1460
+ candidates: base,
1461
+ handbackCouldOwnReply: false,
1462
+ } as Parameters<typeof decideContentGateBypass>[0]
1463
+ expect(decideContentGateBypass(input)).toBe(false)
1464
+ })
1465
+
1466
+ it('the `none` tier stays denied and the `live` tier is untouched by the flag', () => {
1467
+ expect(
1468
+ decideContentGateBypass({
1469
+ tier: 'none',
1470
+ resolvedTurnId: null,
1471
+ candidates: base,
1472
+ handbackCouldOwnReply: false,
1473
+ subagentCouldOwnReply: false,
1474
+ }),
1475
+ ).toBe(false)
1476
+ // A LIVE turn owns the reply by framework identity, not by inference, so a
1477
+ // background sub-agent cannot be the author of record here.
1478
+ expect(
1479
+ decideContentGateBypass({
1480
+ tier: 'live',
1481
+ resolvedTurnId: TURN,
1482
+ candidates: { ...base, liveTurnId: TURN },
1483
+ handbackCouldOwnReply: false,
1484
+ subagentCouldOwnReply: true,
1485
+ }),
1486
+ ).toBe(true)
1487
+ })
1488
+ })
@@ -164,6 +164,8 @@ function makeHarness(opts: { errors?: ScriptedError[] } = {}) {
164
164
  resolveThreadId: () => undefined,
165
165
  getLatestInboundMessageId: () => null,
166
166
  getLastSubagentHandbackAt: () => null,
167
+ // #4176 — no sub-agent live on this session (see subagent-reply-authority.ts).
168
+ subagentReplyAuthority: { subagentCouldOwnReply: () => false },
167
169
  recordOutbound: noop,
168
170
  emissionAuthorityFor: () =>
169
171
  ({