Files
coder/aibridge/internal/integrationtest/setupbridge.go
T
Paweł Banaszewski c0b4180206 fix: fix race in setupInjectedToolTest (#25455)
Fixes race condition in `setupInjectedToolTest`.

To reproduce add short sleep to AsyncRecorder.RecordToolUsage method
(inside newly spawned go-routine):
https://github.com/coder/coder/blob/46821525f7d2f7466735463898fb6e054c169f85/aibridge/recorder/recorder.go#L254-L258

Upstream request counter can reach 2 while no recording has been done
since asyncRecorder does it asynchronously:
https://github.com/coder/coder/blob/46821525f7d2f7466735463898fb6e054c169f85/aibridge/internal/integrationtest/setupbridge.go#L242-L244

Added consuming request to `setupInjectedToolTest` so
`newInterceptionProcessor` handler finishes before returning from
`setupInjectedToolTest` which guarantees that all recordings are done:
https://github.com/coder/coder/blob/46821525f7d2f7466735463898fb6e054c169f85/aibridge/bridge.go#L282

Fixes: https://github.com/coder/internal/issues/1526
2026-05-19 09:36:38 +02:00

265 lines
7.9 KiB
Go

package integrationtest
import (
"bytes"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/sjson"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge"
"github.com/coder/coder/v2/aibridge/config"
aibcontext "github.com/coder/coder/v2/aibridge/context"
"github.com/coder/coder/v2/aibridge/fixtures"
"github.com/coder/coder/v2/aibridge/internal/testutil"
"github.com/coder/coder/v2/aibridge/mcp"
"github.com/coder/coder/v2/aibridge/metrics"
"github.com/coder/coder/v2/aibridge/provider"
"github.com/coder/coder/v2/aibridge/recorder"
)
const (
pathAnthropicMessages = "/anthropic/v1/messages"
pathOpenAIChatCompletions = "/openai/v1/chat/completions"
pathOpenAIResponses = "/openai/v1/responses"
pathCopilotChatCompletions = "/copilot/chat/completions"
pathCopilotResponses = "/copilot/responses"
// providerBedrock identifies a Bedrock provider in [withProvider].
// other providers use config.Provider* constants.
providerBedrock = "bedrock"
// defaults
apiKey = "api-key"
defaultActorID = "ae235cc1-9f8f-417d-a636-a7b170bac62e"
)
var defaultTracer = otel.Tracer("integrationtest")
type bridgeConfig struct {
providerBuilders []func(upstreamURL string) aibridge.Provider
metrics *metrics.Metrics
tracer trace.Tracer
mcpProxy mcp.ServerProxier
userID string
metadata recorder.Metadata
logger slog.Logger
}
// bridgeTestServer wraps an httptest.Server running a RequestBridge.
type bridgeTestServer struct {
*httptest.Server
Recorder *testutil.MockRecorder
Bridge *aibridge.RequestBridge
}
// makeRequest builds and executes an HTTP request against this server.
// Optional headers are applied after the default Content-Type.
func (s *bridgeTestServer) makeRequest(t *testing.T, method string, path string, body []byte, header ...http.Header) (*http.Response, error) {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), method, s.URL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
for _, h := range header {
for k, vals := range h {
for _, v := range vals {
req.Header.Add(k, v)
}
}
}
return http.DefaultClient.Do(req)
}
type bridgeOption func(*bridgeConfig)
// withProvider adds a default-configured provider of the given type.
// When any provider option is used, the default "all providers" set is not created.
func withProvider(providerType string) bridgeOption {
return func(c *bridgeConfig) {
c.providerBuilders = append(c.providerBuilders, func(addr string) aibridge.Provider {
return newDefaultProvider(providerType, addr)
})
}
}
// withCustomProvider adds a pre-built provider. The upstream URL passed to
// [newBridgeTestServer] is ignored for this provider.
// When any provider option is used, the default "all providers" set is not created.
func withCustomProvider(p aibridge.Provider) bridgeOption {
return func(c *bridgeConfig) {
c.providerBuilders = append(c.providerBuilders, func(string) aibridge.Provider {
return p
})
}
}
// withMetrics sets the Prometheus metrics for the bridge.
func withMetrics(m *metrics.Metrics) bridgeOption {
return func(c *bridgeConfig) { c.metrics = m }
}
// withTracer overrides the default tracer.
func withTracer(t trace.Tracer) bridgeOption {
return func(c *bridgeConfig) { c.tracer = t }
}
// withMCP sets the MCP server proxier (default: NoopMCPManager).
func withMCP(p mcp.ServerProxier) bridgeOption {
return func(c *bridgeConfig) { c.mcpProxy = p }
}
// withActor sets the actor ID and metadata for the BaseContext.
func withActor(id string, md recorder.Metadata) bridgeOption {
return func(c *bridgeConfig) { c.userID = id; c.metadata = md }
}
// newBridgeTestServer creates a fully configured test server running
// a RequestBridge with sensible defaults:
// - All standard providers (unless withProvider / withCustomProvider)
// - NoopMCPManager (unless withMCP)
// - slogtest debug logger
// - defaultTracer (unless withTracer)
// - defaultActorID (unless withActor)
func newBridgeTestServer(
ctx context.Context,
t *testing.T,
upstreamURL string,
opts ...bridgeOption,
) *bridgeTestServer {
t.Helper()
cfg := &bridgeConfig{
userID: defaultActorID,
}
for _, o := range opts {
o(cfg)
}
if cfg.tracer == nil {
cfg.tracer = defaultTracer
}
cfg.logger = newLogger(t)
if cfg.mcpProxy == nil {
cfg.mcpProxy = newNoopMCPManager()
}
// Resolve providers: use explicit builders when provided, otherwise
// create default providers for every supported type.
var providers []aibridge.Provider
if len(cfg.providerBuilders) > 0 {
for _, b := range cfg.providerBuilders {
providers = append(providers, b(upstreamURL))
}
} else {
providers = []aibridge.Provider{
newDefaultProvider(config.ProviderAnthropic, upstreamURL),
newDefaultProvider(config.ProviderOpenAI, upstreamURL),
}
}
mockRec := &testutil.MockRecorder{}
rec := aibridge.NewRecorder(cfg.logger, cfg.tracer, func() (aibridge.Recorder, error) {
return mockRec, nil
})
bridge, err := aibridge.NewRequestBridge(
ctx, providers, rec, cfg.mcpProxy,
cfg.logger, cfg.metrics, cfg.tracer,
)
require.NoError(t, err)
actorID, md := cfg.userID, cfg.metadata
srv := httptest.NewUnstartedServer(bridge)
srv.Config.BaseContext = func(_ net.Listener) context.Context {
return aibcontext.AsActor(ctx, actorID, md)
}
srv.Start()
t.Cleanup(srv.Close)
return &bridgeTestServer{
Server: srv,
Recorder: mockRec,
Bridge: bridge,
}
}
// setupInjectedToolTest abstracts common setup required for injected-tool integration tests.
// Extra bridge options (e.g. [withProvider]) are appended after the built-in
// MCP / tracer / actor options. When no provider option is given the default
// provider set (all providers) is used.
func setupInjectedToolTest(
t *testing.T,
fixture []byte,
streaming bool,
tracer trace.Tracer,
path string,
toolRequestValidatorFn func(*http.Request, []byte),
opts ...bridgeOption,
) (*bridgeTestServer, *mockMCP, *http.Response) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
t.Cleanup(cancel)
fix := fixtures.Parse(t, fixture)
// Setup mock server for multi-turn interaction.
// First request → tool call response
// Second request → final response.
firstResp := newFixtureResponse(fix)
toolResp := newFixtureToolResponse(fix)
toolResp.OnRequest = toolRequestValidatorFn
upstream := newMockUpstream(ctx, t, firstResp, toolResp)
mockMCP := setupMCPForTest(t, tracer)
allOpts := []bridgeOption{
withMCP(mockMCP),
withTracer(tracer),
withActor(defaultActorID, nil),
}
allOpts = append(allOpts, opts...)
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, allOpts...)
// Add the stream param to the request.
reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming)
require.NoError(t, err)
resp, err := bridgeServer.makeRequest(t, http.MethodPost, path, reqBody)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Drain the body so the bridge handler returns and asyncRecorder.Wait()
// flushes pending recordings (see aibridge/bridge.go:newInterceptionProcessor).
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
_ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(body))
return bridgeServer, mockMCP, resp
}
// newDefaultProvider creates a Provider with default test configuration.
func newDefaultProvider(providerType string, addr string) aibridge.Provider {
switch providerType {
case config.ProviderAnthropic:
return provider.NewAnthropic(anthropicCfg(addr, apiKey), nil)
case config.ProviderOpenAI:
return provider.NewOpenAI(openAICfg(addr, apiKey))
case providerBedrock:
return provider.NewAnthropic(anthropicCfg(addr, apiKey), bedrockCfg(addr))
default:
panic("unknown provider type: " + providerType)
}
}