codersdk/chats_test.go
Rama main · réplica de lectura del repositorio original.
package codersdk_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestUploadChatWorkspaceFileContentType(t *testing.T) {
t.Parallel()
chatID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
tests := []struct {
name string
contentType string
wantHeader string
}{
{name: "provided", contentType: "text/csv", wantHeader: "text/csv"},
{name: "empty", contentType: "", wantHeader: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v2/chats/"+chatID.String()+"/workspace-files", r.URL.Path)
require.Equal(t, tt.wantHeader, r.Header.Get("Content-Type"))
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusCreated)
require.NoError(t, json.NewEncoder(rw).Encode(codersdk.UploadChatWorkspaceFileResponse{
Path: "/home/coder/.coder/chats/test/files/data.csv",
Name: "data.csv",
Size: 4,
MediaType: tt.contentType,
}))
}))
defer srv.Close()
serverURL, err := url.Parse(srv.URL)
require.NoError(t, err)
client := codersdk.New(serverURL)
_, err = client.UploadChatWorkspaceFile(context.Background(), chatID, tt.contentType, "data.csv", strings.NewReader("data"))
require.NoError(t, err)
})
}
}
type closeCountingBody struct {
io.Reader
closes *atomic.Int32
}
func (b closeCountingBody) Close() error {
b.closes.Add(1)
return nil
}
func TestChatClientRequests(t *testing.T) {
t.Parallel()
newClient := func(t *testing.T, transport testutil.RoundTripperFunc) *codersdk.Client {
t.Helper()
serverURL, err := url.Parse("https://coder.example.com")
require.NoError(t, err)
return codersdk.New(serverURL, codersdk.WithHTTPClient(&http.Client{Transport: transport}))
}
t.Run("TransportError", func(t *testing.T) {
t.Parallel()
errTransport := xerrors.New("transport failed")
client := newClient(t, func(*http.Request) (*http.Response, error) {
return nil, errTransport
})
_, err := client.ChatModel(t.Context(), uuid.New(), uuid.New())
require.ErrorIs(t, err, errTransport)
err = client.DeleteChatModel(t.Context(), uuid.New(), uuid.New())
require.ErrorIs(t, err, errTransport)
})
t.Run("InvalidJSON", func(t *testing.T) {
t.Parallel()
client := newClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":`)),
Request: req,
}, nil
})
_, err := client.ChatModel(t.Context(), uuid.New(), uuid.New())
require.Error(t, err)
})
// ReadBodyAsError closes the body itself, and io.Closer leaves a
// second Close undefined.
t.Run("ClosesResponseBodyOnce", func(t *testing.T) {
t.Parallel()
getModel := func(ctx context.Context, client *codersdk.Client) error {
_, err := client.ChatModel(ctx, uuid.New(), uuid.New())
return err
}
deleteModel := func(ctx context.Context, client *codersdk.Client) error {
return client.DeleteChatModel(ctx, uuid.New(), uuid.New())
}
for _, tc := range []struct {
name string
call func(context.Context, *codersdk.Client) error
status int
body string
wantErr bool
}{
{name: "JSON", call: getModel, status: http.StatusOK, body: `{}`},
{name: "JSONUnexpectedStatus", call: getModel, status: http.StatusInternalServerError, body: `{"message":"failed"}`, wantErr: true},
{name: "JSONInvalidBody", call: getModel, status: http.StatusOK, body: `{"id":`, wantErr: true},
{name: "NoContent", call: deleteModel, status: http.StatusNoContent},
{name: "NoContentUnexpectedStatus", call: deleteModel, status: http.StatusInternalServerError, body: `{"message":"failed"}`, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var closes atomic.Int32
client := newClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: tc.status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: closeCountingBody{Reader: strings.NewReader(tc.body), closes: &closes},
Request: req,
}, nil
})
err := tc.call(t.Context(), client)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.EqualValues(t, 1, closes.Load())
})
}
})
}
func TestChatMessageWorkspaceFileReference_JSONRoundTrip(t *testing.T) {
t.Parallel()
workspaceID := uuid.MustParse("d8317985-0000-0000-0000-000000000002")
part := codersdk.ChatMessageWorkspaceFileReference(
workspaceID,
"/home/coder/.coder/chats/00000000-0000-0000-0000-000000000001/files/notes.txt",
"notes.txt",
42,
"text/plain",
)
data, err := json.Marshal(part)
require.NoError(t, err)
require.Contains(t, string(data), `"type":"workspace-file-reference"`)
require.Contains(t, string(data), `"workspace_file_path"`)
require.Contains(t, string(data), `"workspace_file_name"`)
require.Contains(t, string(data), `"workspace_file_size":42`)
require.Contains(t, string(data), `"workspace_file_media_type":"text/plain"`)
require.Contains(t, string(data), `"workspace_file_workspace_id":"`+workspaceID.String()+`"`)
var raw map[string]json.RawMessage
require.NoError(t, json.Unmarshal(data, &raw))
require.NotContains(t, raw, "size")
var decoded codersdk.ChatMessagePart
require.NoError(t, json.Unmarshal(data, &decoded))
require.Equal(t, part, decoded)
}
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"}`),
ProviderIdentity: "provider-1",
ProviderModel: "model-1",
ProviderCredential: "user",
ReasoningEpoch: 2,
}
part.StripInternal()
assert.Nil(t, part.ProviderMetadata)
assert.Empty(t, part.ProviderIdentity)
assert.Empty(t, part.ProviderModel)
assert.Empty(t, part.ProviderCredential)
assert.Zero(t, part.ReasoningEpoch)
// 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("StripsInvokedSkillFields", func(t *testing.T) {
t.Parallel()
part := codersdk.ChatMessageSkillInvocation(codersdk.ChatSkillSourceWorkspace, "deploy")
part.SkillBody = "Run scripts/deploy.sh."
part.SkillDir = "/home/coder/.agents/skills/deploy"
part.SkillWorkspaceID = uuid.New()
part.StripInternal()
assert.Empty(t, part.SkillBody)
assert.Empty(t, part.SkillDir)
assert.Equal(t, uuid.Nil, part.SkillWorkspaceID)
assert.Equal(t, codersdk.ChatSkillSourceWorkspace, part.SkillSource)
assert.Equal(t, "deploy", part.SkillName)
})
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",
"provider_identity": "internal only, stripped by db2sdk before API responses",
"provider_model": "internal only, stripped by db2sdk before API responses",
"provider_credential": "internal only, stripped by db2sdk before API responses",
"reasoning_epoch": "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:\"-\")",
"skill_workspace_id": "internal only, gates skill_dir in the prompt (typescript:\"-\")",
"context_file_skill_meta_file": "internal only, restored on subsequent turns (typescript:\"-\")",
"skill_body": "internal only, stripped before API responses (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")
}
func TestChatModelCallConfig_ReasoningModelRoundTrip(t *testing.T) {
t.Parallel()
for _, value := range []string{"true", "false", "null"} {
t.Run(value, func(t *testing.T) {
t.Parallel()
input := `{"openai_config":{"reasoning_model":` + value + `}}`
var decoded codersdk.ChatModelCallConfig
require.NoError(t, decoded.UnmarshalStrict([]byte(input)))
require.NotNil(t, decoded.OpenAIConfig)
raw, err := json.Marshal(decoded)
require.NoError(t, err)
if value == "null" {
require.Nil(t, decoded.OpenAIConfig.ReasoningModel)
require.JSONEq(t, `{"openai_config":{}}`, string(raw))
} else {
require.NotNil(t, decoded.OpenAIConfig.ReasoningModel)
require.Equal(t, value == "true", *decoded.OpenAIConfig.ReasoningModel)
require.JSONEq(t, input, string(raw))
}
})
}
var unset codersdk.ChatModelCallConfig
require.NoError(t, unset.UnmarshalStrict([]byte(`{"openai_config":{}}`)))
require.Nil(t, unset.OpenAIConfig.ReasoningModel)
raw, err := json.Marshal(unset)
require.NoError(t, err)
require.JSONEq(t, `{"openai_config":{}}`, string(raw))
}
// 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)
})
}
}