switchroom 0.19.44 → 0.19.46
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 +848 -826
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +48 -4
- package/telegram-plugin/dist/gateway/gateway.js +499 -322
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/auth-command.ts +7 -4
- package/telegram-plugin/gateway/auth-loopback-relay.ts +39 -20
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +67 -66
- 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/auth-loopback-relay.test.ts +58 -0
- 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
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
type ReplyOwnerTier,
|
|
40
40
|
type AnswerDeliveredLatch,
|
|
41
41
|
} from '../reply-owner-resolve.js'
|
|
42
|
-
import { FlushedTurnSupersedeRegistry,
|
|
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:
|
|
240
|
-
latestEndedTtlMs:
|
|
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:
|
|
252
|
-
latestEndedTtlMs:
|
|
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:
|
|
271
|
-
latestEndedTtlMs:
|
|
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('
|
|
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({
|
|
283
|
-
|
|
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:
|
|
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:
|
|
661
|
-
latestEndedTtlMs:
|
|
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:
|
|
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:
|
|
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:
|
|
875
|
-
{ label: 'stale', ageMs:
|
|
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:
|
|
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,
|
|
1176
|
-
'
|
|
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:
|
|
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 ⇒
|
|
1196
|
-
|
|
1197
|
-
expect(
|
|
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
|
({
|