Ctrl K
ComunidadGuías…

codersdk/chats_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
		})
	}
}