mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
## Stack Context This stack makes AI Gateway data and budgets the source of truth for AI spend controls. 1. Re-back the per-chat cost endpoint with AI Gateway data (#27328, merged). 2. Remove native chat usage limits (#27329, merged). 3. **This PR, now based on `main`:** remove native chat cost tracking and its dedicated admin UI. ## Summary Removes native per-message price calculation, model pricing fields, cost persistence, aggregate cost queries, and admin cost API types. It also deletes the Analytics and Spend pages plus their legacy redirects. The AI Gateway-backed per-chat cost row and compact budget indicators remain. The spend documentation is renamed to `spend-management.md` and updated for the remaining surfaces, group budget APIs, CSV export, upgrade handling for native pricing and cost history, and the absence of a deployment-wide spend dashboard. The per-chat cost API documents that data follows AI Gateway retention and reports zero after all matching requests are purged. No schema is dropped in this release. `chat_messages.total_cost_micros` remains nullable and unwritten so replicas from the previous release can continue inserting messages during rolling upgrades. #27600 tracks removal after the compatibility window. > Mux prepared this PR on Mike's behalf.
673 lines
21 KiB
Go
673 lines
21 KiB
Go
package codersdk_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
func TestChatModelProviderOptions_MarshalJSON_UsesPlainProviderPayload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sendReasoning := true
|
|
thinkingDisplay := "summarized"
|
|
|
|
raw, err := json.Marshal(codersdk.ChatModelProviderOptions{
|
|
Anthropic: &codersdk.ChatModelAnthropicProviderOptions{
|
|
SendReasoning: &sendReasoning,
|
|
ThinkingDisplay: &thinkingDisplay,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(raw), `"type":"anthropic.options"`)
|
|
require.NotContains(t, string(raw), `"data":`)
|
|
require.Contains(t, string(raw), `"send_reasoning":true`)
|
|
require.Contains(t, string(raw), `"thinking_display":"summarized"`)
|
|
}
|
|
|
|
func TestChatModelProviderOptions_UnmarshalJSON_ParsesPlainProviderPayloads(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := []byte(`{
|
|
"anthropic": {
|
|
"send_reasoning": true,
|
|
"thinking_display": "summarized"
|
|
}
|
|
}`)
|
|
|
|
var decoded codersdk.ChatModelProviderOptions
|
|
err := json.Unmarshal(raw, &decoded)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decoded.Anthropic)
|
|
require.NotNil(t, decoded.Anthropic.SendReasoning)
|
|
require.True(t, *decoded.Anthropic.SendReasoning)
|
|
require.NotNil(t, decoded.Anthropic.ThinkingDisplay)
|
|
require.Equal(t, "summarized", *decoded.Anthropic.ThinkingDisplay)
|
|
}
|
|
|
|
func TestChatErrorKind_JSONRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
terminal := codersdk.ChatError{
|
|
Message: "limit reached",
|
|
Kind: codersdk.ChatErrorKindUsageLimit,
|
|
}
|
|
data, err := json.Marshal(terminal)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), `"kind":"usage_limit"`)
|
|
|
|
var decodedTerminal codersdk.ChatError
|
|
require.NoError(t, json.Unmarshal(data, &decodedTerminal))
|
|
require.Equal(t, codersdk.ChatErrorKindUsageLimit, decodedTerminal.Kind)
|
|
|
|
retry := codersdk.ChatStreamRetry{
|
|
Attempt: 1,
|
|
Error: "retrying",
|
|
Kind: codersdk.ChatErrorKindUsageLimit,
|
|
}
|
|
data, err = json.Marshal(retry)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), `"kind":"usage_limit"`)
|
|
|
|
var decodedRetry codersdk.ChatStreamRetry
|
|
require.NoError(t, json.Unmarshal(data, &decodedRetry))
|
|
require.Equal(t, codersdk.ChatErrorKindUsageLimit, decodedRetry.Kind)
|
|
}
|
|
|
|
func TestChatStreamEvent_JSONRoundTripIncludesResetTypesAndPartMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chatID := uuid.New()
|
|
events := []codersdk.ChatStreamEvent{
|
|
{Type: codersdk.ChatStreamEventTypePreviewReset, ChatID: chatID},
|
|
{Type: codersdk.ChatStreamEventTypeHistoryReset, ChatID: chatID},
|
|
{
|
|
Type: codersdk.ChatStreamEventTypeMessagePart,
|
|
ChatID: chatID,
|
|
MessagePart: &codersdk.ChatStreamMessagePart{
|
|
Role: codersdk.ChatMessageRoleAssistant,
|
|
Part: codersdk.ChatMessageText("partial"),
|
|
HistoryVersion: 12,
|
|
GenerationAttempt: 3,
|
|
Seq: 4,
|
|
},
|
|
},
|
|
}
|
|
data, err := json.Marshal(events)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), `"type":"preview_reset"`)
|
|
require.Contains(t, string(data), `"type":"history_reset"`)
|
|
require.Contains(t, string(data), `"history_version":12`)
|
|
require.Contains(t, string(data), `"generation_attempt":3`)
|
|
require.Contains(t, string(data), `"seq":4`)
|
|
|
|
var decoded []codersdk.ChatStreamEvent
|
|
require.NoError(t, json.Unmarshal(data, &decoded))
|
|
require.Equal(t, codersdk.ChatStreamEventTypePreviewReset, decoded[0].Type)
|
|
require.Equal(t, codersdk.ChatStreamEventTypeHistoryReset, decoded[1].Type)
|
|
require.Equal(t, int64(12), decoded[2].MessagePart.HistoryVersion)
|
|
require.Equal(t, int64(3), decoded[2].MessagePart.GenerationAttempt)
|
|
require.Equal(t, int64(4), decoded[2].MessagePart.Seq)
|
|
}
|
|
|
|
func TestChatMessagePart_StripInternal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("StripsProviderMetadata", func(t *testing.T) {
|
|
t.Parallel()
|
|
part := codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: "call-1",
|
|
ToolName: "some_tool",
|
|
Args: json.RawMessage(`{"key":"value"}`),
|
|
ProviderMetadata: json.RawMessage(`{"type":"ephemeral"}`),
|
|
}
|
|
part.StripInternal()
|
|
assert.Nil(t, part.ProviderMetadata)
|
|
// Public fields preserved.
|
|
assert.Equal(t, codersdk.ChatMessagePartTypeToolCall, part.Type)
|
|
assert.Equal(t, "call-1", part.ToolCallID)
|
|
assert.Equal(t, "some_tool", part.ToolName)
|
|
assert.JSONEq(t, `{"key":"value"}`, string(part.Args))
|
|
})
|
|
|
|
t.Run("StripsFileDataWhenFileIDSet", func(t *testing.T) {
|
|
t.Parallel()
|
|
id := uuid.New()
|
|
part := codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeFile,
|
|
FileID: uuid.NullUUID{UUID: id, Valid: true},
|
|
MediaType: "image/png",
|
|
Data: []byte("binary-payload"),
|
|
}
|
|
part.StripInternal()
|
|
assert.Nil(t, part.Data)
|
|
assert.Equal(t, id, part.FileID.UUID)
|
|
assert.Equal(t, "image/png", part.MediaType)
|
|
})
|
|
|
|
t.Run("PreservesDataWhenNoFileID", func(t *testing.T) {
|
|
t.Parallel()
|
|
part := codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeFile,
|
|
MediaType: "image/png",
|
|
Data: []byte("inline-data"),
|
|
}
|
|
part.StripInternal()
|
|
assert.Equal(t, []byte("inline-data"), part.Data)
|
|
})
|
|
|
|
t.Run("StripsContextFileContent", func(t *testing.T) {
|
|
t.Parallel()
|
|
agentID := uuid.New()
|
|
part := codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeContextFile,
|
|
ContextFilePath: "/home/coder/AGENTS.md",
|
|
ContextFileContent: "large content",
|
|
ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
|
ContextFileOS: "linux",
|
|
ContextFileDirectory: "/home/coder/project",
|
|
ContextFileSkillMetaFile: "CUSTOM.md",
|
|
}
|
|
part.StripInternal()
|
|
// Internal fields stripped.
|
|
assert.Empty(t, part.ContextFileContent)
|
|
assert.Empty(t, part.ContextFileOS)
|
|
assert.Empty(t, part.ContextFileDirectory)
|
|
assert.Empty(t, part.ContextFileSkillMetaFile)
|
|
// Public fields preserved.
|
|
assert.Equal(t, "/home/coder/AGENTS.md", part.ContextFilePath)
|
|
assert.Equal(t, agentID, part.ContextFileAgentID.UUID)
|
|
assert.True(t, part.ContextFileAgentID.Valid)
|
|
})
|
|
|
|
t.Run("NoopOnCleanPart", func(t *testing.T) {
|
|
t.Parallel()
|
|
part := codersdk.ChatMessageText("hello")
|
|
part.StripInternal()
|
|
assert.Equal(t, "hello", part.Text)
|
|
assert.Equal(t, codersdk.ChatMessagePartTypeText, part.Type)
|
|
})
|
|
}
|
|
|
|
func TestChatModelReasoningEffortConfigEnumTags(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
want := strings.Join(codersdk.ChatModelReasoningEffortValues(), ",")
|
|
typ := reflect.TypeOf(codersdk.ChatModelReasoningEffortConfig{})
|
|
for _, fieldName := range []string{"Default", "Max"} {
|
|
field, ok := typ.FieldByName(fieldName)
|
|
require.True(t, ok)
|
|
require.Equal(t, want, field.Tag.Get("enum"))
|
|
}
|
|
}
|
|
|
|
// TestChatMessagePartVariantTags validates the `variants` struct tags
|
|
// on ChatMessagePart fields. Every field must either declare variant
|
|
// membership or be explicitly excluded, and every known part type
|
|
// must appear in at least one tag.
|
|
//
|
|
// If this test fails, edit the variants struct tags on ChatMessagePart
|
|
// in codersdk/chats.go.
|
|
func TestChatMessagePartVariantTags(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const editHint = "edit the variants struct tags on ChatMessagePart in codersdk/chats.go"
|
|
|
|
// Fields intentionally excluded from all generated variants.
|
|
// If you add a new field to ChatMessagePart, either add a
|
|
// variants tag or add it here with a comment explaining why.
|
|
excludedFields := map[string]string{
|
|
"type": "discriminant, added automatically by codegen",
|
|
"provider_metadata": "internal only, stripped by db2sdk before API responses",
|
|
"context_file_content": "internal only, stripped before API responses (typescript:\"-\")",
|
|
"context_file_os": "internal only, used during prompt expansion (typescript:\"-\")",
|
|
"context_file_directory": "internal only, used during prompt expansion (typescript:\"-\")",
|
|
"skill_dir": "internal only, used by read_skill tools (typescript:\"-\")",
|
|
"context_file_skill_meta_file": "internal only, restored on subsequent turns (typescript:\"-\")",
|
|
}
|
|
// Part types intentionally excluded from all generated variants.
|
|
// If you add a new part type, either reference it in a variants
|
|
// tag or add it here with a reason.
|
|
excludedTypes := map[codersdk.ChatMessagePartType]string{
|
|
codersdk.ChatMessagePartTypeHookContext: "internal only, stripped from client-facing conversions by db2sdk",
|
|
}
|
|
knownTypes := make(map[codersdk.ChatMessagePartType]bool)
|
|
for _, pt := range codersdk.AllChatMessagePartTypes() {
|
|
knownTypes[pt] = true
|
|
}
|
|
|
|
// Parse all variants tags from the struct and validate them.
|
|
typ := reflect.TypeOf(codersdk.ChatMessagePart{})
|
|
coveredTypes := make(map[codersdk.ChatMessagePartType]bool)
|
|
|
|
for i := range typ.NumField() {
|
|
f := typ.Field(i)
|
|
jsonTag := f.Tag.Get("json")
|
|
if jsonTag == "" || jsonTag == "-" {
|
|
continue
|
|
}
|
|
jsonName, _, _ := strings.Cut(jsonTag, ",")
|
|
|
|
varTag := f.Tag.Get("variants")
|
|
if varTag == "" {
|
|
assert.Contains(t, excludedFields, jsonName,
|
|
"field %s (json:%q) has no variants tag and is not in excludedFields; %s",
|
|
f.Name, jsonName, editHint)
|
|
continue
|
|
}
|
|
|
|
assert.NotEqual(t, "type", jsonName,
|
|
"the discriminant field must not have a variants tag; %s", editHint)
|
|
|
|
for _, entry := range strings.Split(varTag, ",") {
|
|
typeLit := codersdk.ChatMessagePartType(strings.TrimSuffix(entry, "?"))
|
|
|
|
assert.True(t, knownTypes[typeLit],
|
|
"field %s variants tag references unknown type %q; %s",
|
|
f.Name, typeLit, editHint)
|
|
|
|
coveredTypes[typeLit] = true
|
|
}
|
|
}
|
|
|
|
// Every known type must appear in at least one variants tag
|
|
// unless it is intentionally excluded from client codegen.
|
|
for pt := range knownTypes {
|
|
if _, excluded := excludedTypes[pt]; excluded {
|
|
assert.False(t, coveredTypes[pt],
|
|
"ChatMessagePartType %q is in excludedTypes but referenced by a variants tag; %s", pt, editHint)
|
|
continue
|
|
}
|
|
assert.True(t, coveredTypes[pt],
|
|
"ChatMessagePartType %q is not referenced by any variants tag; %s", pt, editHint)
|
|
}
|
|
|
|
// Enforce the omitempty <-> variants invariant:
|
|
// required in any variant => must NOT have omitempty
|
|
// optional in all variants => MUST have omitempty
|
|
// See the struct comment on ChatMessagePart for rationale.
|
|
t.Run("omitempty must match variant optionality", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
typ := reflect.TypeOf(codersdk.ChatMessagePart{})
|
|
for i := range typ.NumField() {
|
|
f := typ.Field(i)
|
|
varTag := f.Tag.Get("variants")
|
|
if varTag == "" {
|
|
continue
|
|
}
|
|
|
|
allOptional := true
|
|
for _, entry := range strings.Split(varTag, ",") {
|
|
if !strings.HasSuffix(entry, "?") {
|
|
allOptional = false
|
|
break
|
|
}
|
|
}
|
|
|
|
jsonTag := f.Tag.Get("json")
|
|
hasOmitEmpty := strings.Contains(jsonTag, "omitempty")
|
|
|
|
if !allOptional {
|
|
assert.False(t, hasOmitEmpty,
|
|
"field %s is required in at least one variant but has omitempty in its json tag; "+
|
|
"remove omitempty so Go does not silently drop the zero value that TypeScript expects to always be present",
|
|
f.Name)
|
|
} else {
|
|
assert.True(t, hasOmitEmpty,
|
|
"field %s is optional in all variants but is missing omitempty in its json tag; "+
|
|
"add omitempty to avoid sending zero values for fields the frontend does not expect",
|
|
f.Name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestChatMessagePart_CreatedAt_JSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("RoundTrips", func(t *testing.T) {
|
|
t.Parallel()
|
|
ts := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC)
|
|
part := codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: "tc-1",
|
|
ToolName: "execute",
|
|
CreatedAt: &ts,
|
|
}
|
|
data, err := json.Marshal(part)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), `"created_at"`)
|
|
|
|
var decoded codersdk.ChatMessagePart
|
|
err = json.Unmarshal(data, &decoded)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decoded.CreatedAt)
|
|
require.True(t, ts.Equal(*decoded.CreatedAt))
|
|
})
|
|
|
|
t.Run("OmittedWhenNil", func(t *testing.T) {
|
|
t.Parallel()
|
|
part := codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: "tc-1",
|
|
ToolName: "execute",
|
|
}
|
|
data, err := json.Marshal(part)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(data), `"created_at"`)
|
|
})
|
|
}
|
|
|
|
func TestChatMessagePart_ReasoningTimestamps_JSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("RoundTrips", func(t *testing.T) {
|
|
t.Parallel()
|
|
startedAt := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC)
|
|
completedAt := startedAt.Add(2 * time.Second)
|
|
part := codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeReasoning,
|
|
Text: "thinking out loud",
|
|
CreatedAt: &startedAt,
|
|
CompletedAt: &completedAt,
|
|
}
|
|
data, err := json.Marshal(part)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), `"created_at"`)
|
|
require.Contains(t, string(data), `"completed_at"`)
|
|
|
|
var decoded codersdk.ChatMessagePart
|
|
err = json.Unmarshal(data, &decoded)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decoded.CreatedAt)
|
|
require.NotNil(t, decoded.CompletedAt)
|
|
require.True(t, startedAt.Equal(*decoded.CreatedAt))
|
|
require.True(t, completedAt.Equal(*decoded.CompletedAt))
|
|
})
|
|
|
|
t.Run("OmittedWhenNil", func(t *testing.T) {
|
|
t.Parallel()
|
|
part := codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeReasoning,
|
|
Text: "thinking out loud",
|
|
}
|
|
data, err := json.Marshal(part)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(data), `"created_at"`)
|
|
require.NotContains(t, string(data), `"completed_at"`)
|
|
})
|
|
|
|
t.Run("LegacyCreatedAtWithoutCompletedAt", func(t *testing.T) {
|
|
t.Parallel()
|
|
// CompletedAt is omitted on messages persisted before this
|
|
// feature shipped. Confirm round-trip leaves CompletedAt nil
|
|
// while preserving CreatedAt so legacy data does not break
|
|
// API consumers.
|
|
startedAt := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC)
|
|
part := codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeReasoning,
|
|
Text: "legacy reasoning",
|
|
CreatedAt: &startedAt,
|
|
}
|
|
data, err := json.Marshal(part)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(data), `"created_at"`)
|
|
require.NotContains(t, string(data), `"completed_at"`)
|
|
|
|
var decoded codersdk.ChatMessagePart
|
|
err = json.Unmarshal(data, &decoded)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decoded.CreatedAt)
|
|
require.Nil(t, decoded.CompletedAt)
|
|
})
|
|
}
|
|
|
|
func TestChatModelCallConfig_UnmarshalStoredCost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := []byte(`{
|
|
"temperature": 0.5,
|
|
"cost": {"input_price_per_million_tokens": "5"},
|
|
"provider_options": {"anthropic": {"thinking": {"budget_tokens": 1024}}}
|
|
}`)
|
|
|
|
var decoded codersdk.ChatModelCallConfig
|
|
require.NoError(t, json.Unmarshal(raw, &decoded))
|
|
require.NotNil(t, decoded.Temperature)
|
|
|
|
require.NoError(t, decoded.UnmarshalStrict(raw))
|
|
require.NotNil(t, decoded.Temperature)
|
|
|
|
// Configs predating the nested cost object stored the pricing keys at
|
|
// the top level (see migration 000435).
|
|
legacyTopLevel := []byte(`{
|
|
"temperature": 0.5,
|
|
"input_price_per_million_tokens": "5",
|
|
"output_price_per_million_tokens": "10",
|
|
"cache_read_price_per_million_tokens": "1",
|
|
"cache_write_price_per_million_tokens": "2"
|
|
}`)
|
|
require.NoError(t, decoded.UnmarshalStrict(legacyTopLevel))
|
|
require.NotNil(t, decoded.Temperature)
|
|
|
|
err := decoded.UnmarshalStrict([]byte(`{"provider_options": {"anthropic": {"bogus_setting": true}}}`))
|
|
require.ErrorContains(t, err, `unknown field "bogus_setting"`)
|
|
|
|
err = decoded.UnmarshalStrict([]byte(`{"temperature": 0.5} {"bogus_setting": true}`))
|
|
require.ErrorContains(t, err, "trailing data")
|
|
|
|
require.NoError(t, json.Unmarshal([]byte(`{"bogus_setting": true}`), &decoded))
|
|
}
|
|
|
|
func TestChatModelCallConfig_UseResponsesAPIRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var decoded codersdk.ChatModelCallConfig
|
|
err := decoded.UnmarshalStrict([]byte(`{"openai_config": {"use_responses_api": true}}`))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decoded.OpenAIConfig)
|
|
require.NotNil(t, decoded.OpenAIConfig.UseResponsesAPI)
|
|
require.True(t, *decoded.OpenAIConfig.UseResponsesAPI)
|
|
|
|
raw, err := json.Marshal(decoded)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(raw), `"use_responses_api":true`)
|
|
|
|
var unset codersdk.ChatModelCallConfig
|
|
require.NoError(t, unset.UnmarshalStrict([]byte(`{"openai_config": {}}`)))
|
|
require.Nil(t, unset.OpenAIConfig.UseResponsesAPI)
|
|
raw, err = json.Marshal(unset)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, string(raw), "use_responses_api")
|
|
}
|
|
|
|
// TestChat_JSONRoundTrip verifies that every field of codersdk.Chat
|
|
// survives a JSON marshal/unmarshal cycle. This catches omitempty
|
|
// silently eating zero-ish values, struct tag typos, and similar
|
|
// serialization bugs in the pubsub path.
|
|
func TestChat_JSONRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
|
prState := "open"
|
|
prTitle := "test PR"
|
|
authorLogin := "testuser"
|
|
avatarURL := "https://example.com/avatar.png"
|
|
baseBranch := "main"
|
|
headBranch := "feature/test"
|
|
prNumber := int32(42)
|
|
commits := int32(3)
|
|
approved := true
|
|
reviewerCount := int32(2)
|
|
refreshedAt := now
|
|
staleAt := now.Add(time.Hour)
|
|
lastError := &codersdk.ChatError{
|
|
Message: "boom",
|
|
Detail: "provider detail",
|
|
Kind: codersdk.ChatErrorKindGeneric,
|
|
Provider: "openai",
|
|
Retryable: true,
|
|
StatusCode: 503,
|
|
}
|
|
prURL := "https://github.com/coder/coder/pull/42"
|
|
workspaceID := uuid.New()
|
|
buildID := uuid.New()
|
|
agentID := uuid.New()
|
|
parentChatID := uuid.New()
|
|
rootChatID := uuid.New()
|
|
|
|
original := codersdk.Chat{
|
|
ID: uuid.New(),
|
|
OwnerID: uuid.New(),
|
|
WorkspaceID: &workspaceID,
|
|
BuildID: &buildID,
|
|
AgentID: &agentID,
|
|
ParentChatID: &parentChatID,
|
|
RootChatID: &rootChatID,
|
|
LastModelConfigID: uuid.New(),
|
|
Title: "round-trip-test",
|
|
Status: codersdk.ChatStatusRunning,
|
|
LastError: lastError,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Archived: true,
|
|
MCPServerIDs: []uuid.UUID{uuid.New()},
|
|
Labels: map[string]string{"env": "prod"},
|
|
DiffStatus: &codersdk.ChatDiffStatus{
|
|
ChatID: uuid.New(),
|
|
URL: &prURL,
|
|
PullRequestState: &prState,
|
|
PullRequestTitle: prTitle,
|
|
PullRequestDraft: true,
|
|
ChangesRequested: true,
|
|
Additions: 10,
|
|
Deletions: 5,
|
|
ChangedFiles: 3,
|
|
AuthorLogin: &authorLogin,
|
|
AuthorAvatarURL: &avatarURL,
|
|
BaseBranch: &baseBranch,
|
|
HeadBranch: &headBranch,
|
|
PRNumber: &prNumber,
|
|
Commits: &commits,
|
|
Approved: &approved,
|
|
ReviewerCount: &reviewerCount,
|
|
RefreshedAt: &refreshedAt,
|
|
StaleAt: &staleAt,
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(original)
|
|
require.NoError(t, err)
|
|
|
|
var decoded codersdk.Chat
|
|
err = json.Unmarshal(data, &decoded)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, original, decoded)
|
|
}
|
|
|
|
func TestNewDynamicTool(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type testArgs struct {
|
|
Query string `json:"query"`
|
|
}
|
|
|
|
t.Run("CorrectSchema", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tool := codersdk.NewDynamicTool(
|
|
"search", "search things",
|
|
func(_ context.Context, args testArgs, _ codersdk.DynamicToolCall) (codersdk.DynamicToolResponse, error) {
|
|
return codersdk.DynamicToolResponse{Content: args.Query}, nil
|
|
},
|
|
)
|
|
|
|
require.Equal(t, "search", tool.Name)
|
|
require.Equal(t, "search things", tool.Description)
|
|
require.Contains(t, string(tool.InputSchema), `"query"`)
|
|
require.Contains(t, string(tool.InputSchema), `"string"`)
|
|
})
|
|
|
|
t.Run("HandlerReceivesArgs", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var received testArgs
|
|
tool := codersdk.NewDynamicTool(
|
|
"search", "search things",
|
|
func(_ context.Context, args testArgs, _ codersdk.DynamicToolCall) (codersdk.DynamicToolResponse, error) {
|
|
received = args
|
|
return codersdk.DynamicToolResponse{Content: "ok"}, nil
|
|
},
|
|
)
|
|
|
|
resp, err := tool.Handler(context.Background(), codersdk.DynamicToolCall{
|
|
Args: `{"query":"hello"}`,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "ok", resp.Content)
|
|
require.Equal(t, "hello", received.Query)
|
|
})
|
|
|
|
t.Run("InvalidJSONArgs", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tool := codersdk.NewDynamicTool(
|
|
"search", "search things",
|
|
func(_ context.Context, args testArgs, _ codersdk.DynamicToolCall) (codersdk.DynamicToolResponse, error) {
|
|
return codersdk.DynamicToolResponse{Content: "should not reach"}, nil
|
|
},
|
|
)
|
|
|
|
resp, err := tool.Handler(context.Background(), codersdk.DynamicToolCall{
|
|
Args: "not-json",
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, resp.IsError)
|
|
require.Contains(t, resp.Content, "invalid parameters")
|
|
})
|
|
}
|
|
|
|
//nolint:tparallel,paralleltest
|
|
func TestParseChatWorkspaceTTL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want time.Duration
|
|
wantErr bool
|
|
}{
|
|
{"Empty_ReturnsDefault", "", 0, false},
|
|
{"ValidDuration_Hours", "2h", 2 * time.Hour, false},
|
|
{"ValidDuration_HoursAndMinutes", "2h30m", 2*time.Hour + 30*time.Minute, false},
|
|
{"ValidDuration_Minutes", "90m", 90 * time.Minute, false},
|
|
{"Zero", "0s", 0, false},
|
|
{"Negative", "-1h", 0, true},
|
|
{"Invalid", "not-a-duration", 0, true},
|
|
{"LargeDuration", "720h", 720 * time.Hour, false},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := codersdk.ParseChatWorkspaceTTL(tc.input)
|
|
if tc.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|