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) }) } }