diff --git a/go.mod b/go.mod index 91b0245dbe3b96100c174d2df98198e0ce32ec18..df2b1ff87579a558a1020e450ff7f9c178e172c4 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251106195925-579e174cd7fa charm.land/fantasy v0.2.1 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 + charm.land/x/vcr v0.1.0 github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.10.3 @@ -52,10 +53,8 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/sjson v1.2.5 github.com/zeebo/xxh3 v1.0.2 - go.yaml.in/yaml/v4 v4.0.0-rc.3 golang.org/x/sync v0.18.0 golang.org/x/text v0.30.0 - gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117 gopkg.in/natefinch/lumberjack.v2 v2.2.1 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 @@ -152,6 +151,7 @@ require ( go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/crypto v0.43.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/image v0.27.0 // indirect @@ -165,6 +165,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e6bc15c164085614381662c3ab291200ec269062..a36479112012c303b11b254028936ec7dda767a7 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ charm.land/fantasy v0.2.1 h1:hi+azqT05hEdt0nLljYO4vyIASTAtmhcfhUdM3yFclI= charm.land/fantasy v0.2.1/go.mod h1:QyB3iv9GHs/6O90Z6ZjSTdM1XS3N/70Ujke+w3w0jLo= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= +charm.land/x/vcr v0.1.0 h1:XhCUVij6Ss6+xJuAb2n4mNRGSS/SrnNoUmEwJziy+Dg= +charm.land/x/vcr v0.1.0/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM= cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= @@ -450,8 +452,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117 h1:fbE/sTnBb9UNfE8cJsOzrYYPqVWVHb7jWH4SI1W//cM= -gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117/go.mod h1:YuVT9NPq7t3oT2WpUemB0DbNL7djIjgajZycxoDLnqs= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 h1:g3ah7zaWmw41EtOgBNXpx8zk4HYuH3OMwB+qh1Dt834= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 13b8ab20aeb981583455ae6c0f913b8265f9f3e8..0a513b914310941537c64d5e3e72eee814597528 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -8,11 +8,11 @@ import ( "testing" "charm.land/fantasy" + "charm.land/x/vcr" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" _ "github.com/joho/godotenv/autoload" ) @@ -24,7 +24,7 @@ var modelPairs = []modelPair{ {"zai-glm4.6", zAIBuilder("glm-4.6"), zAIBuilder("glm-4.5-air")}, } -func getModels(t *testing.T, r *recorder.Recorder, pair modelPair) (fantasy.LanguageModel, fantasy.LanguageModel) { +func getModels(t *testing.T, r *vcr.Recorder, pair modelPair) (fantasy.LanguageModel, fantasy.LanguageModel) { large, err := pair.largeModel(t, r) require.NoError(t, err) small, err := pair.smallModel(t, r) @@ -33,7 +33,7 @@ func getModels(t *testing.T, r *recorder.Recorder, pair modelPair) (fantasy.Lang } func setupAgent(t *testing.T, pair modelPair) (SessionAgent, fakeEnv) { - r := newRecorder(t) + r := vcr.NewRecorder(t) large, small := getModels(t, r, pair) env := testEnv(t) diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index c7762e21bff36fabf5bf1557e5ee648f8e1357be..88f72a991fe62653e7d94a5ac38eac548ef1d7d2 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -13,6 +13,7 @@ import ( "charm.land/fantasy/providers/openai" "charm.land/fantasy/providers/openaicompat" "charm.land/fantasy/providers/openrouter" + "charm.land/x/vcr" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" @@ -25,7 +26,6 @@ import ( "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" "github.com/stretchr/testify/require" - "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" _ "github.com/joho/godotenv/autoload" ) @@ -40,7 +40,7 @@ type fakeEnv struct { lspClients *csync.Map[string, *lsp.Client] } -type builderFunc func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) +type builderFunc func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) type modelPair struct { name string @@ -49,7 +49,7 @@ type modelPair struct { } func anthropicBuilder(model string) builderFunc { - return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) { + return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) { provider, err := anthropic.New( anthropic.WithAPIKey(os.Getenv("CRUSH_ANTHROPIC_API_KEY")), anthropic.WithHTTPClient(&http.Client{Transport: r}), @@ -62,7 +62,7 @@ func anthropicBuilder(model string) builderFunc { } func openaiBuilder(model string) builderFunc { - return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) { + return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) { provider, err := openai.New( openai.WithAPIKey(os.Getenv("CRUSH_OPENAI_API_KEY")), openai.WithHTTPClient(&http.Client{Transport: r}), @@ -75,7 +75,7 @@ func openaiBuilder(model string) builderFunc { } func openRouterBuilder(model string) builderFunc { - return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) { + return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) { provider, err := openrouter.New( openrouter.WithAPIKey(os.Getenv("CRUSH_OPENROUTER_API_KEY")), openrouter.WithHTTPClient(&http.Client{Transport: r}), @@ -88,7 +88,7 @@ func openRouterBuilder(model string) builderFunc { } func zAIBuilder(model string) builderFunc { - return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) { + return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) { provider, err := openaicompat.New( openaicompat.WithBaseURL("https://api.z.ai/api/coding/paas/v4"), openaicompat.WithAPIKey(os.Getenv("CRUSH_ZAI_API_KEY")), @@ -153,7 +153,7 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro return agent } -func coderAgent(r *recorder.Recorder, env fakeEnv, large, small fantasy.LanguageModel) (SessionAgent, error) { +func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel) (SessionAgent, error) { fixedTime := func() time.Time { t, _ := time.Parse("1/2/2006", "1/1/2025") return t diff --git a/internal/agent/recorder_test.go b/internal/agent/recorder_test.go deleted file mode 100644 index 34ac8f5a2924f91bde53ff4e270dc175e535ef1b..0000000000000000000000000000000000000000 --- a/internal/agent/recorder_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package agent - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "path/filepath" - "reflect" - "strings" - "testing" - - "go.yaml.in/yaml/v4" - "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" - "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" -) - -func newRecorder(t *testing.T) *recorder.Recorder { - cassetteName := filepath.Join("testdata", t.Name()) - - r, err := recorder.New( - cassetteName, - recorder.WithMode(recorder.ModeRecordOnce), - recorder.WithMatcher(customMatcher(t)), - recorder.WithMarshalFunc(marshalFunc), - recorder.WithSkipRequestLatency(true), // disable sleep to simulate response time, makes tests faster - recorder.WithHook(hookRemoveHeaders, recorder.AfterCaptureHook), - ) - if err != nil { - t.Fatalf("recorder: failed to create recorder: %v", err) - } - - t.Cleanup(func() { - if err := r.Stop(); err != nil { - t.Errorf("recorder: failed to stop recorder: %v", err) - } - }) - - return r -} - -func customMatcher(t *testing.T) recorder.MatcherFunc { - return func(r *http.Request, i cassette.Request) bool { - if r.Body == nil || r.Body == http.NoBody { - return cassette.DefaultMatcher(r, i) - } - if r.Method != i.Method || r.URL.String() != i.URL { - return false - } - - reqBody, err := io.ReadAll(r.Body) - if err != nil { - t.Fatalf("recorder: failed to read request body") - } - r.Body.Close() - r.Body = io.NopCloser(bytes.NewBuffer(reqBody)) - - // Some providers can sometimes generate JSON requests with keys in - // a different order, which means a direct string comparison will fail. - // Falling back to deserializing the content if we don't have a match. - requestContent := normalizeLineEndings(reqBody) - cassetteContent := normalizeLineEndings(i.Body) - if requestContent == cassetteContent { - return true - } - var content1, content2 any - if err := json.Unmarshal([]byte(requestContent), &content1); err != nil { - return false - } - if err := json.Unmarshal([]byte(cassetteContent), &content2); err != nil { - return false - } - return reflect.DeepEqual(content1, content2) - } -} - -func marshalFunc(in any) ([]byte, error) { - var buff bytes.Buffer - enc := yaml.NewEncoder(&buff) - enc.SetIndent(2) - enc.CompactSeqIndent() - if err := enc.Encode(in); err != nil { - return nil, err - } - return buff.Bytes(), nil -} - -var headersToKeep = map[string]struct{}{ - "accept": {}, - "content-type": {}, - "user-agent": {}, -} - -func hookRemoveHeaders(i *cassette.Interaction) error { - for k := range i.Request.Headers { - if _, ok := headersToKeep[strings.ToLower(k)]; !ok { - delete(i.Request.Headers, k) - } - } - for k := range i.Response.Headers { - if _, ok := headersToKeep[strings.ToLower(k)]; !ok { - delete(i.Response.Headers, k) - } - } - return nil -} - -// normalizeLineEndings does not only replace `\r\n` into `\n`, -// but also replaces `\\r\\n` into `\\n`. That's because we want the content -// inside JSON string to be replaces as well. -func normalizeLineEndings[T string | []byte](s T) string { - str := string(s) - str = strings.ReplaceAll(str, "\r\n", "\n") - str = strings.ReplaceAll(str, `\r\n`, `\n`) - return str -}