@@ -1570,3 +1570,114 @@ func TestExtProc_PopulatesSchemeFromPseudoHeader(t *testing.T) {
15701570 })
15711571 }
15721572}
1573+
1574+ // streamingRecorderPlugin is a Plugin + StreamingResponder used to
1575+ // verify the extproc listener dispatches the buffered response body
1576+ // through OnResponseFrame. The pipeline.RunResponse skip introduced
1577+ // for the proxy listeners would otherwise leave streaming-aware
1578+ // plugins (mcp/inference/a2a parsers) with no response-phase
1579+ // dispatch under envoy-sidecar — the regression this guards.
1580+ type streamingRecorderPlugin struct {
1581+ frames [][]byte
1582+ lasts []bool
1583+ onResponseCalls int
1584+ }
1585+
1586+ func (p * streamingRecorderPlugin ) Name () string { return "streaming-recorder" }
1587+ func (p * streamingRecorderPlugin ) Capabilities () pipeline.PluginCapabilities {
1588+ return pipeline.PluginCapabilities {ReadsBody : true }
1589+ }
1590+ func (p * streamingRecorderPlugin ) OnRequest (_ context.Context , _ * pipeline.Context ) pipeline.Action {
1591+ return pipeline.Action {Type : pipeline .Continue }
1592+ }
1593+ func (p * streamingRecorderPlugin ) OnResponse (_ context.Context , _ * pipeline.Context ) pipeline.Action {
1594+ p .onResponseCalls ++
1595+ return pipeline.Action {Type : pipeline .Continue }
1596+ }
1597+ func (p * streamingRecorderPlugin ) OnResponseFrame (_ context.Context , _ * pipeline.Context , frame []byte , last bool ) pipeline.Action {
1598+ cp := make ([]byte , len (frame ))
1599+ copy (cp , frame )
1600+ p .frames = append (p .frames , cp )
1601+ p .lasts = append (p .lasts , last )
1602+ return pipeline.Action {Type : pipeline .Continue }
1603+ }
1604+
1605+ // TestExtProc_BufferedJSONResponse_DispatchesToStreamingResponder
1606+ // asserts the framework's "pick one path" contract on extproc:
1607+ // pipeline.RunResponse skips StreamingResponder plugins, so the
1608+ // listener MUST deliver the buffered application/json body via
1609+ // RunResponseFrame(last=true) — otherwise mcp/inference/a2a parsers
1610+ // lose response observability under envoy-sidecar entirely.
1611+ func TestExtProc_BufferedJSONResponse_DispatchesToStreamingResponder (t * testing.T ) {
1612+ probe := & streamingRecorderPlugin {}
1613+ p , err := pipeline .New ([]pipeline.Plugin {probe })
1614+ if err != nil {
1615+ t .Fatal (err )
1616+ }
1617+ srv := & Server {InboundPipeline : pipeline .NewHolder (p ), OutboundPipeline : pipeline .NewHolder (p )}
1618+
1619+ pctx := & pipeline.Context {
1620+ Direction : pipeline .Inbound ,
1621+ ResponseHeaders : http.Header {
1622+ "Content-Type" : []string {"application/json" },
1623+ },
1624+ }
1625+ body := []byte (`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}` )
1626+ resp := srv .handleResponseBody (context .Background (), body , pctx , "inbound" )
1627+ if resp .GetImmediateResponse () != nil {
1628+ t .Fatalf ("unexpected immediate response: %+v" , resp .GetImmediateResponse ())
1629+ }
1630+
1631+ if len (probe .frames ) != 1 || ! probe .lasts [0 ] {
1632+ t .Fatalf ("frames=%d lasts=%v; want exactly one last=true frame" , len (probe .frames ), probe .lasts )
1633+ }
1634+ if string (probe .frames [0 ]) != string (body ) {
1635+ t .Errorf ("frame[0] = %q; want %q" , probe .frames [0 ], body )
1636+ }
1637+ // OnResponse must NOT fire for a StreamingResponder plugin —
1638+ // pipeline.RunResponse skips it on purpose so the body isn't
1639+ // delivered through both hooks.
1640+ if probe .onResponseCalls != 0 {
1641+ t .Errorf ("OnResponse called %d times; want 0" , probe .onResponseCalls )
1642+ }
1643+ }
1644+
1645+ // TestExtProc_BufferedSSEResponse_DispatchesPerEvent verifies that
1646+ // when Envoy buffers a text/event-stream body, the extproc listener
1647+ // re-parses with sseframe and dispatches one OnResponseFrame call
1648+ // per SSE event followed by a final last=true. Mirrors what the
1649+ // proxy listeners do over their wire-streaming dispatch path so
1650+ // streaming-aware plugins see the same shape regardless of mode.
1651+ func TestExtProc_BufferedSSEResponse_DispatchesPerEvent (t * testing.T ) {
1652+ probe := & streamingRecorderPlugin {}
1653+ p , err := pipeline .New ([]pipeline.Plugin {probe })
1654+ if err != nil {
1655+ t .Fatal (err )
1656+ }
1657+ srv := & Server {InboundPipeline : pipeline .NewHolder (p ), OutboundPipeline : pipeline .NewHolder (p )}
1658+
1659+ pctx := & pipeline.Context {
1660+ Direction : pipeline .Inbound ,
1661+ ResponseHeaders : http.Header {
1662+ "Content-Type" : []string {"text/event-stream" },
1663+ },
1664+ }
1665+ body := []byte ("data: {\" id\" :1}\n \n data: {\" id\" :2}\n \n data: {\" id\" :3}\n \n " )
1666+ resp := srv .handleResponseBody (context .Background (), body , pctx , "inbound" )
1667+ if resp .GetImmediateResponse () != nil {
1668+ t .Fatalf ("unexpected immediate response: %+v" , resp .GetImmediateResponse ())
1669+ }
1670+
1671+ // Three events as non-last + one final last=true call.
1672+ if len (probe .frames ) != 4 {
1673+ t .Fatalf ("got %d frames, want 4 (3 events + 1 last=true)" , len (probe .frames ))
1674+ }
1675+ for i := 0 ; i < 3 ; i ++ {
1676+ if probe .lasts [i ] {
1677+ t .Errorf ("frame[%d] last=true, want false" , i )
1678+ }
1679+ }
1680+ if ! probe .lasts [3 ] {
1681+ t .Errorf ("frame[3] last=false, want true (final)" )
1682+ }
1683+ }
0 commit comments