@@ -372,3 +372,129 @@ func TestBlockRemovedEventWithBytesHashes(t *testing.T) {
372372 assert .Equal (t , "removed-model" , removed .ModelName )
373373 assert .Equal (t , "removed-pod" , removed .PodName )
374374}
375+
376+ // --- Issue #2285: decoder must read lora_id/medium/lora_name/group_idx ---
377+
378+ // rawBlockStoredBatch marshals a single BlockStored event using the exact
379+ // positional layout vLLM emits (msgspec array_like, tag first), so these tests
380+ // assert against the real wire contract rather than Go-encoder symmetry.
381+ //
382+ // vLLM BlockStored positions (vllm/distributed/kv_events.py):
383+ //
384+ // [0]tag [1]block_hashes [2]parent_block_hash [3]token_ids [4]block_size
385+ // [5]lora_id [6]medium [7]lora_name [8]extra_keys [9]group_idx ...
386+ func rawBlockStoredBatch (t * testing.T , eventFields []interface {}) []byte {
387+ t .Helper ()
388+ event := append ([]interface {}{string (EventTypeBlockStored )}, eventFields ... )
389+ batch := []interface {}{
390+ float64 (1_700_000_000 ), // batch timestamp (seconds)
391+ []interface {}{event },
392+ }
393+ data , err := msgpack .Marshal (batch )
394+ require .NoError (t , err )
395+ return data
396+ }
397+
398+ // blockStoredCore returns the four always-present fields after the tag:
399+ // block_hashes, parent_block_hash, token_ids, block_size.
400+ func blockStoredCore () []interface {} {
401+ return []interface {}{
402+ []interface {}{int64 (101 ), int64 (102 )}, // block_hashes
403+ nil , // parent_block_hash
404+ []interface {}{int64 (1 ), int64 (2 ), int64 (3 ), int64 (4 )}, // token_ids
405+ int64 (4 ), // block_size
406+ }
407+ }
408+
409+ func decodeSingleBlockStored (t * testing.T , data []byte ) * BlockStoredEvent {
410+ t .Helper ()
411+ batch , err := DecodeEventBatch (data , "m" , "p" )
412+ require .NoError (t , err )
413+ require .Len (t , batch .Events , 1 )
414+ ev , ok := batch .Events [0 ].(* BlockStoredEvent )
415+ require .True (t , ok , "expected *BlockStoredEvent" )
416+ return ev
417+ }
418+
419+ func TestBlockStored_DecodesAllOptionalFields (t * testing.T ) {
420+ fields := append (blockStoredCore (),
421+ int64 (7 ), // lora_id
422+ "GPU" , // medium
423+ "adapter-a" , // lora_name
424+ nil , // extra_keys
425+ int64 (3 ), // group_idx
426+ )
427+ ev := decodeSingleBlockStored (t , rawBlockStoredBatch (t , fields ))
428+
429+ require .NotNil (t , ev .LoraID )
430+ assert .Equal (t , int64 (7 ), * ev .LoraID )
431+ require .NotNil (t , ev .Medium )
432+ assert .Equal (t , "GPU" , * ev .Medium )
433+ require .NotNil (t , ev .LoraName )
434+ assert .Equal (t , "adapter-a" , * ev .LoraName )
435+ require .NotNil (t , ev .GroupIdx )
436+ assert .Equal (t , int64 (3 ), * ev .GroupIdx )
437+ }
438+
439+ // group_idx sits at position 9, after extra_keys at 8. Verify it is read by
440+ // position even when extra_keys carries a non-nil payload.
441+ func TestBlockStored_GroupIdxReadPastExtraKeys (t * testing.T ) {
442+ fields := append (blockStoredCore (),
443+ nil , // lora_id
444+ "cpu" , // medium
445+ nil , // lora_name
446+ []interface {}{[]interface {}{"k" }}, // extra_keys (non-nil)
447+ int64 (5 ), // group_idx
448+ )
449+ ev := decodeSingleBlockStored (t , rawBlockStoredBatch (t , fields ))
450+
451+ require .NotNil (t , ev .Medium )
452+ assert .Equal (t , "cpu" , * ev .Medium )
453+ require .NotNil (t , ev .GroupIdx )
454+ assert .Equal (t , int64 (5 ), * ev .GroupIdx )
455+ }
456+
457+ // None values (nil medium / lora_name) must decode to nil pointers, not "".
458+ func TestBlockStored_NilOptionalValues (t * testing.T ) {
459+ fields := append (blockStoredCore (),
460+ nil , // lora_id
461+ nil , // medium
462+ nil , // lora_name
463+ nil , // extra_keys
464+ nil , // group_idx
465+ )
466+ ev := decodeSingleBlockStored (t , rawBlockStoredBatch (t , fields ))
467+
468+ assert .Nil (t , ev .LoraID )
469+ assert .Nil (t , ev .Medium )
470+ assert .Nil (t , ev .LoraName )
471+ assert .Nil (t , ev .GroupIdx )
472+ }
473+
474+ // Older vLLM builds send only the first five positions. Decoding must still
475+ // succeed and leave the new fields nil (no panic, no error).
476+ func TestBlockStored_BackwardCompatShortArray (t * testing.T ) {
477+ ev := decodeSingleBlockStored (t , rawBlockStoredBatch (t , blockStoredCore ()))
478+
479+ assert .Equal (t , []int64 {101 , 102 }, ev .BlockHashes )
480+ assert .Nil (t , ev .LoraID )
481+ assert .Nil (t , ev .Medium )
482+ assert .Nil (t , ev .LoraName )
483+ assert .Nil (t , ev .GroupIdx )
484+ }
485+
486+ // Intermediate length: through lora_name (position 7), no extra_keys/group_idx.
487+ func TestBlockStored_PartialOptionalFields (t * testing.T ) {
488+ fields := append (blockStoredCore (),
489+ int64 (2 ), // lora_id
490+ "GPU" , // medium
491+ "adapter-b" , // lora_name
492+ )
493+ ev := decodeSingleBlockStored (t , rawBlockStoredBatch (t , fields ))
494+
495+ require .NotNil (t , ev .LoraID )
496+ assert .Equal (t , int64 (2 ), * ev .LoraID )
497+ require .NotNil (t , ev .LoraName )
498+ assert .Equal (t , "adapter-b" , * ev .LoraName )
499+ assert .Nil (t , ev .GroupIdx )
500+ }
0 commit comments