diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index a2c757f1d5..cca6917e7d 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -3202,6 +3202,11 @@ func (api *API) chatCreateWorkspace( // chatStartWorkspace starts a stopped workspace by creating a new // build with the "start" transition. It mirrors chatCreateWorkspace // but for the start path. +// +// Aliased as ChatStartWorkspace in coderd/export_test.go so external +// tests in the coderd_test package can drive the auto-update path +// end-to-end. The proper fix is to extract the request building into +// a pure function; tracked in CODAGT-292. func (api *API) chatStartWorkspace( ctx context.Context, ownerID uuid.UUID, diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index b95b568a55..25fea235c2 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -12501,6 +12501,59 @@ func TestPostChats_DynamicToolValidation(t *testing.T) { }) } +// requireActiveVersionStore always returns RequireActiveVersion: true so +// tests can exercise relevant code paths without an enterprise license. +type requireActiveVersionStore struct{} + +func (requireActiveVersionStore) GetTemplateAccessControl(_ database.Template) dbauthz.TemplateAccessControl { + return dbauthz.TemplateAccessControl{RequireActiveVersion: true} +} + +func (requireActiveVersionStore) SetTemplateAccessControl(_ context.Context, _ database.Store, _ uuid.UUID, _ dbauthz.TemplateAccessControl) error { + return nil +} + +func TestChatStartWorkspace_RequireActiveVersion(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + var store dbauthz.AccessControlStore = requireActiveVersionStore{} + api.AccessControlStore.Store(&store) + db := api.Database + user := coderdtest.CreateFirstUser(t, rawClient) + + // Given: active template version v1 plus workspace stopped on v1. + wsResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.UserID, + OrganizationID: user.OrganizationID, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStop, + }).Do() + tmplID := wsResp.Workspace.TemplateID + v1ID := wsResp.Build.TemplateVersionID + + // Given: a new active version v2 is published. + v2Resp := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: tmplID, Valid: true}, + OrganizationID: user.OrganizationID, + CreatedBy: user.UserID, + }).Do() + v2 := v2Resp.TemplateVersion + require.NotEqual(t, v1ID, v2.ID, "v2 must differ from v1") + + // When: we start the workspace through chatStartWorkspace. + build, err := coderd.ChatStartWorkspace(api, ctx, user.UserID, wsResp.Workspace.ID, + codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + }) + + // Then: the build is auto-updated to the active version. + require.NoError(t, err) + require.Equal(t, v2.ID, build.TemplateVersionID, "build must be on the active version") + require.Nil(t, build.TemplateVersionPresetID, "no preset must be applied") +} + func TestGetChatMessages_Pagination(t *testing.T) { t.Parallel() diff --git a/coderd/export_test.go b/coderd/export_test.go index 95d8313cab..44f24a09ba 100644 --- a/coderd/export_test.go +++ b/coderd/export_test.go @@ -2,3 +2,12 @@ package coderd // InsertAgentChatTestModelConfig exposes insertAgentChatTestModelConfig for external tests. var InsertAgentChatTestModelConfig = insertAgentChatTestModelConfig + +// ChatStartWorkspace exposes chatStartWorkspace for external tests. +// +// chatStartWorkspace is intentionally unexported to keep symmetry with +// its sister chatCreateWorkspace. The alias lets external tests drive +// the RequireActiveVersion auto-update path end-to-end without +// stubbing the entire DB layer. The proper fix is to extract a pure +// request builder; tracked in CODAGT-292. +var ChatStartWorkspace = (*API).chatStartWorkspace diff --git a/coderd/x/chatd/chattool/createworkspace.go b/coderd/x/chatd/chattool/createworkspace.go index d97c9ea014..cbd83352f8 100644 --- a/coderd/x/chatd/chattool/createworkspace.go +++ b/coderd/x/chatd/chattool/createworkspace.go @@ -77,6 +77,7 @@ type createWorkspaceArgs struct { TemplateID string `json:"template_id" description:"The UUIDv4 of the template to create the workspace from. Obtain this from list_templates."` Name string `json:"name,omitempty" description:"The name of the workspace to create. If not provided, a random name will be generated."` Parameters map[string]string `json:"parameters,omitempty" description:"Key-value pairs of template parameters to use when creating the workspace. Obtain available parameters from read_template."` + PresetID string `json:"preset_id,omitempty" description:"The UUIDv4 of a template version preset to use. Obtain available presets from read_template. When provided, the preset's parameters are applied automatically and the workspace may claim a prebuilt instance for faster startup."` } // CreateWorkspace returns a tool that creates a new workspace from a @@ -91,7 +92,10 @@ func CreateWorkspace(organizationID uuid.UUID, db database.Store, options Create "template_id (from list_templates). Optionally provide "+ "a name and parameter values (from read_template). "+ "If no name is given, one will be generated. "+ - "This tool is idempotent — if the chat already has a "+ + "Provide a preset_id (from read_template) to apply "+ + "preset parameters and potentially claim a prebuilt "+ + "workspace for faster startup. "+ + "This tool is idempotent. If the chat already has a "+ "workspace that is building or running, the existing "+ "workspace is returned.", func(ctx context.Context, args createWorkspaceArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { @@ -184,6 +188,18 @@ func CreateWorkspace(organizationID uuid.UUID, db database.Store, options Create TTLMillis: ttlMs, } + // Apply preset if provided. + presetIDStr := strings.TrimSpace(args.PresetID) + if presetIDStr != "" { + presetID, err := uuid.Parse(presetIDStr) + if err != nil { + return fantasy.NewTextErrorResponse( + xerrors.Errorf("invalid preset_id: %w", err).Error(), + ), nil + } + createReq.TemplateVersionPresetID = presetID + } + name := strings.TrimSpace(args.Name) if name == "" { name = generatedWorkspaceName(tmpl.Name) diff --git a/coderd/x/chatd/chattool/createworkspace_test.go b/coderd/x/chatd/chattool/createworkspace_test.go index 86f83d0e40..cacf908526 100644 --- a/coderd/x/chatd/chattool/createworkspace_test.go +++ b/coderd/x/chatd/chattool/createworkspace_test.go @@ -1332,3 +1332,242 @@ func TestCreateWorkspace_OnChatUpdatedFiresAfterBuild(t *testing.T) { func validNullTime(t time.Time) sql.NullTime { return sql.NullTime{Time: t, Valid: true} } + +// createWorkspacePresetTestSetup holds common test dependencies +// for create_workspace preset tests. +type createWorkspacePresetTestSetup struct { + DB *dbmock.MockStore + OwnerID uuid.UUID + OrgID uuid.UUID + TemplateID uuid.UUID + ChatID uuid.UUID + WorkspaceID uuid.UUID + BuildID uuid.UUID + AgentID uuid.UUID +} + +// setupCreateWorkspacePresetTest creates common mock expectations +// for preset-related create_workspace tests. It sets up RBAC, +// template lookup, TTL, and chat lookup. +func setupCreateWorkspacePresetTest(t *testing.T) createWorkspacePresetTestSetup { + t.Helper() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + s := createWorkspacePresetTestSetup{ + DB: db, + OwnerID: uuid.New(), + OrgID: uuid.New(), + TemplateID: uuid.New(), + ChatID: uuid.New(), + WorkspaceID: uuid.New(), + BuildID: uuid.New(), + AgentID: uuid.New(), + } + + // RBAC. + db.EXPECT(). + GetAuthorizationUserRoles(gomock.Any(), s.OwnerID). + Return(database.GetAuthorizationUserRolesRow{ + ID: s.OwnerID, + Username: "testuser", + Status: "active", + }, nil) + + // Template lookup. + db.EXPECT(). + GetTemplateByID(gomock.Any(), s.TemplateID). + Return(database.Template{ + ID: s.TemplateID, + OrganizationID: s.OrgID, + Name: "test-template", + ActiveVersionID: uuid.New(), + }, nil) + + // Chat workspace TTL. + db.EXPECT(). + GetChatWorkspaceTTL(gomock.Any()). + Return("", sql.ErrNoRows) + + // Check for existing workspace (no existing). + db.EXPECT(). + GetChatByID(gomock.Any(), s.ChatID). + Return(database.Chat{ID: s.ChatID}, nil) + + return s +} + +// expectSuccessfulBuild adds mock expectations for a successful +// build, agent lookup, and agent lifecycle check. +func (s createWorkspacePresetTestSetup) expectSuccessfulBuild() { + s.DB.EXPECT(). + UpdateChatWorkspaceBinding(gomock.Any(), gomock.Any()). + Return(database.Chat{ID: s.ChatID}, nil) + + s.DB.EXPECT(). + GetWorkspaceBuildByID(gomock.Any(), s.BuildID). + Return(database.WorkspaceBuild{ + ID: s.BuildID, + JobID: uuid.New(), + }, nil) + s.DB.EXPECT(). + GetProvisionerJobByID(gomock.Any(), gomock.Any()). + Return(database.ProvisionerJob{ + JobStatus: database.ProvisionerJobStatusSucceeded, + }, nil) + + s.DB.EXPECT(). + GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), s.WorkspaceID). + Return([]database.WorkspaceAgent{{ + ID: s.AgentID, + Name: "main", + }}, nil) + + s.DB.EXPECT(). + GetWorkspaceAgentLifecycleStateByID(gomock.Any(), s.AgentID). + Return(database.GetWorkspaceAgentLifecycleStateByIDRow{ + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + }, nil) +} + +func TestCreateWorkspace_WithPresetID(t *testing.T) { + t.Parallel() + + s := setupCreateWorkspacePresetTest(t) + s.expectSuccessfulBuild() + + presetID := uuid.New() + + var capturedReq codersdk.CreateWorkspaceRequest + createFn := func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { + capturedReq = req + return codersdk.Workspace{ + ID: s.WorkspaceID, + Name: req.Name, + LatestBuild: codersdk.WorkspaceBuild{ + ID: s.BuildID, + }, + }, nil + } + + agentConnFn := func(_ context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { + return nil, func() {}, nil + } + + tool := CreateWorkspace(s.OrgID, s.DB, CreateWorkspaceOptions{ + OwnerID: s.OwnerID, + ChatID: s.ChatID, + CreateFn: createFn, + AgentConnFn: agentConnFn, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + input := fmt.Sprintf( + `{"template_id":%q,"preset_id":%q,"name":"test-ws"}`, + s.TemplateID.String(), presetID.String(), + ) + + ctx := context.Background() + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-preset", + Name: "create_workspace", + Input: input, + }) + require.NoError(t, err) + require.False(t, resp.IsError, "unexpected error: %s", resp.Content) + + require.Equal(t, presetID, capturedReq.TemplateVersionPresetID, + "expected preset ID to be set on CreateWorkspaceRequest") +} + +func TestCreateWorkspace_InvalidPresetID(t *testing.T) { + t.Parallel() + + s := setupCreateWorkspacePresetTest(t) + + tool := CreateWorkspace(s.OrgID, s.DB, CreateWorkspaceOptions{ + OwnerID: s.OwnerID, + ChatID: s.ChatID, + CreateFn: func(_ context.Context, _ uuid.UUID, _ codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { + t.Fatal("CreateFn should not be called with invalid preset_id") + return codersdk.Workspace{}, nil + }, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + input := fmt.Sprintf( + `{"template_id":%q,"preset_id":"not-a-uuid","name":"test-ws"}`, + s.TemplateID.String(), + ) + + ctx := context.Background() + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-bad-preset", + Name: "create_workspace", + Input: input, + }) + require.NoError(t, err) + require.True(t, resp.IsError) + require.Contains(t, resp.Content, "invalid preset_id") +} + +func TestCreateWorkspace_WithPresetAndParams(t *testing.T) { + t.Parallel() + + s := setupCreateWorkspacePresetTest(t) + s.expectSuccessfulBuild() + + presetID := uuid.New() + + var capturedReq codersdk.CreateWorkspaceRequest + createFn := func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { + capturedReq = req + return codersdk.Workspace{ + ID: s.WorkspaceID, + Name: req.Name, + LatestBuild: codersdk.WorkspaceBuild{ + ID: s.BuildID, + }, + }, nil + } + + agentConnFn := func(_ context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { + return nil, func() {}, nil + } + + tool := CreateWorkspace(s.OrgID, s.DB, CreateWorkspaceOptions{ + OwnerID: s.OwnerID, + ChatID: s.ChatID, + CreateFn: createFn, + AgentConnFn: agentConnFn, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + input := fmt.Sprintf( + `{"template_id":%q,"preset_id":%q,"name":"test-ws","parameters":{"region":"us-east"}}`, + s.TemplateID.String(), presetID.String(), + ) + + ctx := context.Background() + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-preset-params", + Name: "create_workspace", + Input: input, + }) + require.NoError(t, err) + require.False(t, resp.IsError, "unexpected error: %s", resp.Content) + + // Verify preset ID is set. + require.Equal(t, presetID, capturedReq.TemplateVersionPresetID, + "expected preset ID to be set") + + // Verify parameters are also populated. + require.Len(t, capturedReq.RichParameterValues, 1, + "expected rich parameter values to be set") + require.Equal(t, "region", capturedReq.RichParameterValues[0].Name) + require.Equal(t, "us-east", capturedReq.RichParameterValues[0].Value) +} diff --git a/coderd/x/chatd/chattool/readtemplate.go b/coderd/x/chatd/chattool/readtemplate.go index 3b87b4d8b0..4048c734a4 100644 --- a/coderd/x/chatd/chattool/readtemplate.go +++ b/coderd/x/chatd/chattool/readtemplate.go @@ -29,9 +29,9 @@ func ReadTemplate(organizationID uuid.UUID, db database.Store, options ReadTempl return fantasy.NewAgentTool( "read_template", "Get details about a workspace template, including its "+ - "configurable parameters. Use this after finding a "+ - "template with list_templates and before creating a "+ - "workspace with create_workspace.", + "configurable parameters and available presets. Use this "+ + "after finding a template with list_templates and before "+ + "creating a workspace with create_workspace.", func(ctx context.Context, args readTemplateArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { if db == nil { return fantasy.NewTextErrorResponse("database is not configured"), nil @@ -73,6 +73,13 @@ func ReadTemplate(organizationID uuid.UUID, db database.Store, options ReadTempl ), nil } + presets, err := db.GetPresetsByTemplateVersionID(ctx, template.ActiveVersionID) + if err != nil { + return fantasy.NewTextErrorResponse( + xerrors.Errorf("failed to get template presets: %w", err).Error(), + ), nil + } + templateInfo := map[string]any{ "id": template.ID.String(), "name": template.Name, @@ -129,10 +136,64 @@ func ReadTemplate(organizationID uuid.UUID, db database.Store, options ReadTempl paramList = append(paramList, param) } - return toolResponse(map[string]any{ + result := map[string]any{ "template": templateInfo, "parameters": paramList, - }), nil + } + + // Include presets only when the template has them + // to avoid cluttering responses. + if len(presets) > 0 { + presetParams, err := db.GetPresetParametersByTemplateVersionID(ctx, template.ActiveVersionID) + if err != nil { + return fantasy.NewTextErrorResponse( + xerrors.Errorf("failed to get preset parameters: %w", err).Error(), + ), nil + } + + // Index preset parameters by preset ID for + // efficient lookup. + paramsByPreset := make(map[uuid.UUID][]map[string]any) + for _, pp := range presetParams { + paramsByPreset[pp.TemplateVersionPresetID] = append( + paramsByPreset[pp.TemplateVersionPresetID], + map[string]any{ + "name": pp.Name, + "value": pp.Value, + }, + ) + } + + presetList := make([]map[string]any, 0, len(presets)) + for _, p := range presets { + preset := map[string]any{ + "id": p.ID.String(), + "name": p.Name, + "default": p.IsDefault, + } + if desc := strings.TrimSpace(p.Description); desc != "" { + preset["description"] = desc + } + if icon := strings.TrimSpace(p.Icon); icon != "" { + preset["icon"] = icon + } + // Surface the prebuild count when set so the LLM can prefer + // presets backed by prebuilt workspaces. Match the toolsdk + // `desired_prebuild_instances` key for cross-surface consistency. + if p.DesiredInstances.Valid && p.DesiredInstances.Int32 > 0 { + preset["desired_prebuild_instances"] = p.DesiredInstances.Int32 + } + if params, ok := paramsByPreset[p.ID]; ok { + preset["parameters"] = params + } else { + preset["parameters"] = []map[string]any{} + } + presetList = append(presetList, preset) + } + result["presets"] = presetList + } + + return toolResponse(result), nil }, ) } diff --git a/coderd/x/chatd/chattool/readtemplate_test.go b/coderd/x/chatd/chattool/readtemplate_test.go new file mode 100644 index 0000000000..cadeba6ccd --- /dev/null +++ b/coderd/x/chatd/chattool/readtemplate_test.go @@ -0,0 +1,183 @@ +package chattool_test + +import ( + "database/sql" + "encoding/json" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/chatd/chattool" + "github.com/coder/coder/v2/testutil" +) + +func TestReadTemplate_IncludesPresets(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tmpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + ActiveVersionID: tv.ID, + }) + + // Create a preset with parameters. + const usEastLargeDesiredPrebuildInstances = 3 + preset := dbgen.Preset(t, db, database.InsertPresetParams{ + TemplateVersionID: tv.ID, + Name: "us-east-large", + IsDefault: true, + Description: "US East large instance", + Icon: "/icon/us.png", + DesiredInstances: sql.NullInt32{ + Int32: usEastLargeDesiredPrebuildInstances, + Valid: true, + }, + }) + _ = dbgen.PresetParameter(t, db, database.InsertPresetParametersParams{ + TemplateVersionPresetID: preset.ID, + Names: []string{"region", "instance_type"}, + Values: []string{"us-east", "large"}, + }) + + // Create a second preset without parameters. + _ = dbgen.Preset(t, db, database.InsertPresetParams{ + TemplateVersionID: tv.ID, + Name: "empty-preset", + }) + + ctx := testutil.Context(t, testutil.WaitShort) + tool := chattool.ReadTemplate(org.ID, db, chattool.ReadTemplateOptions{ + OwnerID: user.ID, + }) + + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-1", + Name: "read_template", + Input: `{"template_id":"` + tmpl.ID.String() + `"}`, + }) + require.NoError(t, err) + require.False(t, resp.IsError, "unexpected error: %s", resp.Content) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + + // Verify template info is present. + tmplInfo, ok := result["template"].(map[string]any) + require.True(t, ok) + require.Equal(t, tmpl.ID.String(), tmplInfo["id"]) + + // Verify presets are present. + presetsRaw, ok := result["presets"].([]any) + require.True(t, ok, "expected presets in response") + require.Len(t, presetsRaw, 2) + + // Find the preset with parameters. + var foundPreset map[string]any + for _, p := range presetsRaw { + pm := p.(map[string]any) + if pm["name"] == "us-east-large" { + foundPreset = pm + break + } + } + require.NotNil(t, foundPreset, "expected to find us-east-large preset") + require.Equal(t, preset.ID.String(), foundPreset["id"]) + require.Equal(t, true, foundPreset["default"]) + require.Equal(t, "US East large instance", foundPreset["description"]) + require.Equal(t, "/icon/us.png", foundPreset["icon"]) + // Prebuild count round-trips so the LLM can prefer presets + // backed by prebuilt workspaces. + require.EqualValues(t, usEastLargeDesiredPrebuildInstances, foundPreset["desired_prebuild_instances"]) + + // Verify preset parameters. + presetParamsRaw, ok := foundPreset["parameters"].([]any) + require.True(t, ok) + require.Len(t, presetParamsRaw, 2) + + paramMap := make(map[string]string) + for _, pp := range presetParamsRaw { + ppm := pp.(map[string]any) + paramMap[ppm["name"].(string)] = ppm["value"].(string) + } + require.Equal(t, "us-east", paramMap["region"]) + require.Equal(t, "large", paramMap["instance_type"]) + + // Verify the empty preset has correct defaults. + var emptyPreset map[string]any + for _, p := range presetsRaw { + pm := p.(map[string]any) + if pm["name"] == "empty-preset" { + emptyPreset = pm + break + } + } + require.NotNil(t, emptyPreset, "expected to find empty-preset") + require.Equal(t, false, emptyPreset["default"]) + _, hasDesc := emptyPreset["description"] + require.False(t, hasDesc, "empty-preset should not have description") + _, hasIcon := emptyPreset["icon"] + require.False(t, hasIcon, "empty-preset should not have icon") + _, hasPrebuilds := emptyPreset["desired_prebuild_instances"] + require.False(t, hasPrebuilds, "empty-preset should not have desired_prebuild_instances") + emptyParams, ok := emptyPreset["parameters"].([]any) + require.True(t, ok) + require.Empty(t, emptyParams, "empty-preset should have no parameters") +} + +func TestReadTemplate_NoPresets(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tmpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + ActiveVersionID: tv.ID, + }) + + ctx := testutil.Context(t, testutil.WaitShort) + tool := chattool.ReadTemplate(org.ID, db, chattool.ReadTemplateOptions{ + OwnerID: user.ID, + }) + + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-2", + Name: "read_template", + Input: `{"template_id":"` + tmpl.ID.String() + `"}`, + }) + require.NoError(t, err) + require.False(t, resp.IsError) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + + // Presets key should be absent when there are no presets. + _, hasPresets := result["presets"] + require.False(t, hasPresets, "presets key should be absent when there are none") +} diff --git a/coderd/x/chatd/chattool/startworkspace.go b/coderd/x/chatd/chattool/startworkspace.go index 473be1c273..aca85b9a0e 100644 --- a/coderd/x/chatd/chattool/startworkspace.go +++ b/coderd/x/chatd/chattool/startworkspace.go @@ -180,6 +180,7 @@ func StartWorkspace(options StartWorkspaceOptions) fantasy.AgentTool { codersdk.WorkspaceBuildParameter{Name: k, Value: v}, ) } + startBuild, err := options.StartFn(ownerCtx, options.OwnerID, ws.ID, startReq) if err != nil { if responseErr, ok := httperror.IsResponder(err); ok { diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index a2dbda6ba4..81908820a6 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "runtime/debug" @@ -30,6 +31,7 @@ const ( ToolNameListWorkspaces = "coder_list_workspaces" ToolNameListTemplates = "coder_list_templates" ToolNameListTemplateVersionParams = "coder_template_version_parameters" + ToolNameGetTemplate = "coder_get_template" ToolNameGetAuthenticatedUser = "coder_get_authenticated_user" ToolNameCreateWorkspaceBuild = "coder_create_workspace_build" ToolNameCreateTemplateVersion = "coder_create_template_version" @@ -310,6 +312,7 @@ var All = []GenericTool{ DeleteTemplate.Generic(), ListTemplates.Generic(), ListTemplateVersionParameters.Generic(), + GetTemplate.Generic(), ListWorkspaces.Generic(), GetAuthenticatedUser.Generic(), GetTemplateVersionLogs.Generic(), @@ -437,10 +440,26 @@ This returns more data than list_workspaces to reduce token usage.`, } type CreateWorkspaceArgs struct { - Name string `json:"name"` - RichParameters map[string]string `json:"rich_parameters"` - TemplateVersionID string `json:"template_version_id"` - User string `json:"user"` + Name string `json:"name"` + RichParameters map[string]string `json:"rich_parameters"` + TemplateID string `json:"template_id,omitempty"` + TemplateVersionID string `json:"template_version_id,omitempty"` + TemplateVersionPresetID string `json:"template_version_preset_id,omitempty"` + User string `json:"user"` +} + +// richParametersFromMap converts the map shape used on tool args into the +// slice shape used on the wire. Iteration order is undefined, which is fine +// because wsbuilder treats RichParameterValues as a set keyed by Name. +func richParametersFromMap(m map[string]string) []codersdk.WorkspaceBuildParameter { + if len(m) == 0 { + return nil + } + out := make([]codersdk.WorkspaceBuildParameter, 0, len(m)) + for k, v := range m { + out = append(out, codersdk.WorkspaceBuildParameter{Name: k, Value: v}) + } + return out } var CreateWorkspace = Tool[CreateWorkspaceArgs, codersdk.Workspace]{ @@ -470,9 +489,17 @@ be ready before trying to use or connect to the workspace. "type": "string", "description": userDescription("create a workspace"), }, + "template_id": map[string]any{ + "type": "string", + "description": "ID of the template to create the workspace from. The server resolves the active version. Prefer this over template_version_id unless you specifically need to pin a non-active version. Obtain this from coder_list_templates or coder_get_template.", + }, "template_version_id": map[string]any{ "type": "string", - "description": "ID of the template version to create the workspace from.", + "description": "ID of a specific template version to create the workspace from. Use only when pinning a non-active version is required; otherwise prefer template_id. Mutually exclusive with template_id.", + }, + "template_version_preset_id": map[string]any{ + "type": "string", + "description": "Optional ID of a template version preset to create the workspace from. Obtain available presets from coder_get_template. When set, the preset's parameter values take precedence over conflicting entries in rich_parameters.", }, "name": map[string]any{ "type": "string", @@ -483,30 +510,60 @@ be ready before trying to use or connect to the workspace. "description": "Key/value pairs of rich parameters to pass to the template version to create the workspace.", }, }, - Required: []string{"user", "template_version_id", "name", "rich_parameters"}, + Required: []string{"user", "name", "rich_parameters"}, }, }, MCPAnnotations: mcpMutationAnnotations, Handler: func(ctx context.Context, deps Deps, args CreateWorkspaceArgs) (codersdk.Workspace, error) { - tvID, err := uuid.Parse(args.TemplateVersionID) - if err != nil { - return codersdk.Workspace{}, xerrors.New("template_version_id must be a valid UUID") + // The REST API requires exactly one of template_id or + // template_version_id. Pre-validate here so the LLM gets a + // clear, actionable error instead of an opaque server-side + // validation failure. + if (args.TemplateID == "") == (args.TemplateVersionID == "") { + return codersdk.Workspace{}, xerrors.New("exactly one of template_id or template_version_id must be provided") + } + var ( + tID uuid.UUID + tvID uuid.UUID + err error + ) + if args.TemplateID != "" { + tID, err = uuid.Parse(args.TemplateID) + if err != nil { + return codersdk.Workspace{}, xerrors.New("template_id must be a valid UUID") + } + } + if args.TemplateVersionID != "" { + tvID, err = uuid.Parse(args.TemplateVersionID) + if err != nil { + return codersdk.Workspace{}, xerrors.New("template_version_id must be a valid UUID") + } + } + + var tvPresetID uuid.UUID + if args.TemplateVersionPresetID != "" { + tvPresetID, err = uuid.Parse(args.TemplateVersionPresetID) + if err != nil { + return codersdk.Workspace{}, xerrors.New("template_version_preset_id must be a valid UUID") + } } if args.User == "" { args.User = codersdk.Me } - var buildParams []codersdk.WorkspaceBuildParameter - for k, v := range args.RichParameters { - buildParams = append(buildParams, codersdk.WorkspaceBuildParameter{ - Name: k, - Value: v, - }) - } - workspace, err := deps.coderClient.CreateUserWorkspace(ctx, args.User, codersdk.CreateWorkspaceRequest{ + req := codersdk.CreateWorkspaceRequest{ + TemplateID: tID, TemplateVersionID: tvID, Name: args.Name, - RichParameterValues: buildParams, - }) + RichParameterValues: richParametersFromMap(args.RichParameters), + } + if tvPresetID != uuid.Nil { + req.TemplateVersionPresetID = tvPresetID + } + // When no preset is supplied, wsbuilder may still auto-bind a + // preset whose parameter values exactly match RichParameterValues. + // This is intentional pre-existing server-side behavior; the tool + // surface does not suppress it. + workspace, err := deps.coderClient.CreateUserWorkspace(ctx, args.User, req) if err != nil { return codersdk.Workspace{}, err } @@ -622,6 +679,116 @@ var ListTemplateVersionParameters = Tool[ListTemplateVersionParametersArgs, []co }, } +type GetTemplateArgs struct { + TemplateID string `json:"template_id"` +} + +// TemplateDetail extends MinimalTemplate with the active version's +// rich parameters and presets. Presets are omitted when the template +// has none, to mirror the chattool read_template response shape. +type TemplateDetail struct { + MinimalTemplate + Parameters []codersdk.TemplateVersionParameter `json:"parameters"` + Presets []presetView `json:"presets,omitempty"` +} + +// presetView is a tool-local projection of codersdk.Preset with +// snake_case JSON keys that match the field names referenced in +// the create_workspace tool description. codersdk.Preset has no +// JSON tags, so its fields would otherwise serialize as PascalCase +// and the LLM would look for keys that do not exist on the wire. +type presetView struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Default bool `json:"default"` + DesiredPrebuildInstances *int `json:"desired_prebuild_instances,omitempty"` + Parameters []presetParameterView `json:"parameters"` +} + +type presetParameterView struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func toPresetView(p codersdk.Preset) presetView { + params := make([]presetParameterView, 0, len(p.Parameters)) + for _, pp := range p.Parameters { + params = append(params, presetParameterView{ + Name: pp.Name, + Value: pp.Value, + }) + } + return presetView{ + ID: p.ID, + Name: p.Name, + Description: p.Description, + Default: p.Default, + DesiredPrebuildInstances: p.DesiredPrebuildInstances, + Parameters: params, + } +} + +var GetTemplate = Tool[GetTemplateArgs, TemplateDetail]{ + Tool: aisdk.Tool{ + Name: ToolNameGetTemplate, + Description: `Get details about a workspace template, including its configurable parameters and available presets for the active version. + +Use this after finding a template with coder_list_templates and before creating a workspace with coder_create_workspace. Presets, when present, can be passed to coder_create_workspace as template_version_preset_id. + +When selecting a preset: if a preset is marked default and the user has not specified preferences, prefer that preset. Presets with desired_prebuild_instances > 0 may have prebuilt workspaces available for faster startup; prefer those when startup speed matters.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "template_id": map[string]any{ + "type": "string", + "description": "ID of the template to read details for. Obtain this from coder_list_templates.", + }, + }, + Required: []string{"template_id"}, + }, + }, + MCPAnnotations: mcpReadOnlyAnnotations, + Handler: func(ctx context.Context, deps Deps, args GetTemplateArgs) (TemplateDetail, error) { + templateID, err := uuid.Parse(args.TemplateID) + if err != nil { + return TemplateDetail{}, xerrors.Errorf("template_id must be a valid UUID: %w", err) + } + template, err := deps.coderClient.Template(ctx, templateID) + if err != nil { + return TemplateDetail{}, xerrors.Errorf("get template: %w", err) + } + // A template without an active version would cause the + // follow-up calls to issue confusing "not found" errors + // against a zero UUID. Fail clearly instead. + if template.ActiveVersionID == uuid.Nil { + return TemplateDetail{}, xerrors.New("template has no active version") + } + parameters, err := deps.coderClient.TemplateVersionRichParameters(ctx, template.ActiveVersionID) + if err != nil { + return TemplateDetail{}, xerrors.Errorf("get template parameters: %w", err) + } + presets, err := deps.coderClient.TemplateVersionPresets(ctx, template.ActiveVersionID) + if err != nil { + return TemplateDetail{}, xerrors.Errorf("get template presets: %w", err) + } + detail := TemplateDetail{ + MinimalTemplate: MinimalTemplate{ + DisplayName: template.DisplayName, + ID: template.ID.String(), + Name: template.Name, + Description: template.Description, + ActiveVersionID: template.ActiveVersionID, + ActiveUserCount: template.ActiveUserCount, + }, + Parameters: parameters, + } + for _, p := range presets { + detail.Presets = append(detail.Presets, toPresetView(p)) + } + return detail, nil + }, +} + var GetAuthenticatedUser = Tool[NoArgs, codersdk.User]{ Tool: aisdk.Tool{ Name: ToolNameGetAuthenticatedUser, @@ -638,9 +805,11 @@ var GetAuthenticatedUser = Tool[NoArgs, codersdk.User]{ } type CreateWorkspaceBuildArgs struct { - TemplateVersionID string `json:"template_version_id"` - Transition string `json:"transition"` - WorkspaceID string `json:"workspace_id"` + RichParameters map[string]string `json:"rich_parameters,omitempty"` + TemplateVersionID string `json:"template_version_id"` + TemplateVersionPresetID string `json:"template_version_preset_id,omitempty"` + Transition string `json:"transition"` + WorkspaceID string `json:"workspace_id"` } var CreateWorkspaceBuild = Tool[CreateWorkspaceBuildArgs, codersdk.WorkspaceBuild]{ @@ -648,6 +817,11 @@ var CreateWorkspaceBuild = Tool[CreateWorkspaceBuildArgs, codersdk.WorkspaceBuil Name: ToolNameCreateWorkspaceBuild, Description: `Create a new workspace build for an existing workspace. Use this to start, stop, or delete. +For start transitions, optionally pass template_version_preset_id to apply a +preset (obtain available presets from coder_get_template), or rich_parameters +to override individual parameter values. Both fields are rejected on stop and +delete transitions because they are scoped to a starting build. + After creating a workspace build, watch the build logs and wait for the workspace build to complete before trying to start another build or use or connect to the workspace. @@ -666,6 +840,14 @@ connect to the workspace. "type": "string", "description": "(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.", }, + "template_version_preset_id": map[string]any{ + "type": "string", + "description": "(Optional) ID of a template version preset to apply. Only valid for start transitions. Obtain available presets from coder_get_template. Presets are scoped to the template version they were created on; pass template_version_id with the same version the preset came from when the workspace's current build is on a different version, otherwise the build may apply mismatched parameter defaults. When set, the preset's parameter values take precedence over conflicting entries in rich_parameters.", + }, + "rich_parameters": map[string]any{ + "type": "object", + "description": "(Optional) Key/value pairs of rich parameters to apply to the build. Only valid for start transitions.", + }, }, Required: []string{"workspace_id", "transition"}, }, @@ -676,19 +858,38 @@ connect to the workspace. if err != nil { return codersdk.WorkspaceBuild{}, xerrors.Errorf("workspace_id must be a valid UUID: %w", err) } - var templateVersionID uuid.UUID + transition := codersdk.WorkspaceTransition(args.Transition) + // Presets and rich_parameters are scoped to a starting build; + // they have no meaning on stop or delete transitions. Surface + // both violations at once via errors.Join so agents fix them + // in a single round-trip instead of one tool call per error. + if transition != codersdk.WorkspaceTransitionStart { + var errs []error + if args.TemplateVersionPresetID != "" { + errs = append(errs, xerrors.New("template_version_preset_id is only valid for start transitions")) + } + if len(args.RichParameters) > 0 { + errs = append(errs, xerrors.New("rich_parameters is only valid for start transitions")) + } + if len(errs) > 0 { + return codersdk.WorkspaceBuild{}, errors.Join(errs...) + } + } + cbr := codersdk.CreateWorkspaceBuildRequest{ + Transition: transition, + RichParameterValues: richParametersFromMap(args.RichParameters), + } if args.TemplateVersionID != "" { - tvID, err := uuid.Parse(args.TemplateVersionID) + cbr.TemplateVersionID, err = uuid.Parse(args.TemplateVersionID) if err != nil { return codersdk.WorkspaceBuild{}, xerrors.Errorf("template_version_id must be a valid UUID: %w", err) } - templateVersionID = tvID } - cbr := codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransition(args.Transition), - } - if templateVersionID != uuid.Nil { - cbr.TemplateVersionID = templateVersionID + if args.TemplateVersionPresetID != "" { + cbr.TemplateVersionPresetID, err = uuid.Parse(args.TemplateVersionPresetID) + if err != nil { + return codersdk.WorkspaceBuild{}, xerrors.Errorf("template_version_preset_id must be a valid UUID: %w", err) + } } return deps.coderClient.CreateWorkspaceBuild(ctx, workspaceID, cbr) }, diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index 0ce93e210c..2ea1e74d33 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -132,6 +132,14 @@ func TestGenericToolMCPAnnotations(t *testing.T) { idempotentHint: true, openWorldHint: false, }, + { + name: "GetTemplateIsReadOnly", + toolName: toolsdk.ToolNameGetTemplate, + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, } for _, tt := range tests { @@ -178,6 +186,12 @@ func TestTools(t *testing.T) { } return agents }).Do() + preset := dbgen.Preset(t, store, database.InsertPresetParams{ + TemplateVersionID: r.TemplateVersion.ID, + Name: testutil.GetRandomNameHyphenated(t), + CreatedAt: r.TemplateVersion.CreatedAt, + Description: "Preset for agent tool tests.", + }) // Given: a client configured with the agent token. agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken)) @@ -404,6 +418,169 @@ func TestTools(t *testing.T) { // Cancel the build so it doesn't remain in the 'pending' state indefinitely. require.NoError(t, client.CancelWorkspaceBuild(ctx, rollbackBuild.ID, codersdk.CancelWorkspaceBuildParams{})) }) + + t.Run("Start_WithPreset", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + + result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{ + WorkspaceID: r.Workspace.ID.String(), + Transition: "start", + TemplateVersionPresetID: preset.ID.String(), + }) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceTransitionStart, result.Transition) + require.Equal(t, r.Workspace.ID, result.WorkspaceID) + require.NotNil(t, result.TemplateVersionPresetID, + "build must record the preset ID supplied to create_workspace_build") + require.Equal(t, preset.ID, *result.TemplateVersionPresetID) + + require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{})) + }) + + t.Run("Start_WithRichParameters", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + // Isolated fixture: a template version with one rich + // parameter, so rich_parameters has something to bind + // to. The shared `r` fixture has no parameters. + rpBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).Do() + dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{ + TemplateVersionID: rpBuild.TemplateVersion.ID, + Name: "region", + Description: "Region to deploy in.", + Type: "string", + DefaultValue: "us-east-1", + Required: false, + Mutable: true, + }) + + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{ + WorkspaceID: rpBuild.Workspace.ID.String(), + Transition: "start", + RichParameters: map[string]string{"region": "us-west-2"}, + }) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceTransitionStart, result.Transition) + + params, err := memberClient.WorkspaceBuildParameters(ctx, result.ID) + require.NoError(t, err) + require.Len(t, params, 1) + require.Equal(t, "region", params[0].Name) + require.Equal(t, "us-west-2", params[0].Value) + + require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{})) + }) + + t.Run("Start_WithPresetAndParams", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + // Isolated fixture: a template version with a parameter + // and a preset that sets it. Asserts the documented + // override direction: when preset and rich_parameters + // conflict, the preset value wins. Mirrors the + // CreateWorkspace/WithPresetAndParams contract. + ovBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).Do() + dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{ + TemplateVersionID: ovBuild.TemplateVersion.ID, + Name: "region", + Description: "Region to deploy in.", + Type: "string", + DefaultValue: "us-east-1", + Required: false, + Mutable: true, + }) + ovPreset := dbgen.Preset(t, store, database.InsertPresetParams{ + TemplateVersionID: ovBuild.TemplateVersion.ID, + Name: testutil.GetRandomNameHyphenated(t), + CreatedAt: ovBuild.TemplateVersion.CreatedAt, + Description: "Preset for build override test.", + }) + dbgen.PresetParameter(t, store, database.InsertPresetParametersParams{ + TemplateVersionPresetID: ovPreset.ID, + Names: []string{"region"}, + Values: []string{"us-west-2"}, + }) + + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + result, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{ + WorkspaceID: ovBuild.Workspace.ID.String(), + Transition: "start", + TemplateVersionPresetID: ovPreset.ID.String(), + RichParameters: map[string]string{"region": "us-east-1"}, + }) + require.NoError(t, err) + require.NotNil(t, result.TemplateVersionPresetID) + require.Equal(t, ovPreset.ID, *result.TemplateVersionPresetID) + + params, err := memberClient.WorkspaceBuildParameters(ctx, result.ID) + require.NoError(t, err) + require.Len(t, params, 1) + require.Equal(t, "region", params[0].Name) + require.Equal(t, "us-west-2", params[0].Value, + "preset parameter value must override conflicting rich_parameters entry") + + require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{})) + }) + + t.Run("RejectsPresetOnStop", func(t *testing.T) { + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + _, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{ + WorkspaceID: r.Workspace.ID.String(), + Transition: "stop", + TemplateVersionPresetID: preset.ID.String(), + }) + require.ErrorContains(t, err, "template_version_preset_id is only valid for start") + }) + + t.Run("RejectsParamsOnDelete", func(t *testing.T) { + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + _, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{ + WorkspaceID: r.Workspace.ID.String(), + Transition: "delete", + RichParameters: map[string]string{"region": "us-west-2"}, + }) + require.ErrorContains(t, err, "rich_parameters is only valid for start") + }) + + t.Run("RejectsBothOnStop", func(t *testing.T) { + // Both fields set on a non-start transition. The + // handler must surface both violations via errors.Join + // so agents fix both in one round-trip rather than + // fix-one, retry, hit-the-next. + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + _, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{ + WorkspaceID: r.Workspace.ID.String(), + Transition: "stop", + TemplateVersionPresetID: preset.ID.String(), + RichParameters: map[string]string{"region": "us-west-2"}, + }) + require.Error(t, err) + require.ErrorContains(t, err, "template_version_preset_id is only valid for start") + require.ErrorContains(t, err, "rich_parameters is only valid for start") + }) + + t.Run("InvalidPresetID", func(t *testing.T) { + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + _, err = testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{ + WorkspaceID: r.Workspace.ID.String(), + Transition: "start", + TemplateVersionPresetID: "not-a-uuid", + }) + require.ErrorContains(t, err, "template_version_preset_id must be a valid UUID") + }) }) t.Run("ListTemplateVersionParameters", func(t *testing.T) { @@ -417,6 +594,129 @@ func TestTools(t *testing.T) { require.Empty(t, params) }) + t.Run("GetTemplate", func(t *testing.T) { + // Build an isolated fixture so the existing fixture's + // assertions (no parameters, single preset with no + // preset parameters) stay intact. + gtBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).Do() + // Add a rich parameter to the active version so + // `parameters` is non-empty in the response. + dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{ + TemplateVersionID: gtBuild.TemplateVersion.ID, + Name: "region", + DisplayName: "Region", + Description: "Region to deploy in.", + Type: "string", + DefaultValue: "us-east-1", + Required: false, + Mutable: true, + }) + // Attach a preset with one parameter so we can assert + // PresetParameters round-trip end-to-end. + const gtPresetDesiredPrebuildInstances = 3 + gtPreset := dbgen.Preset(t, store, database.InsertPresetParams{ + TemplateVersionID: gtBuild.TemplateVersion.ID, + Name: testutil.GetRandomNameHyphenated(t), + CreatedAt: gtBuild.TemplateVersion.CreatedAt, + Description: "Preset for GetTemplate tests.", + DesiredInstances: sql.NullInt32{ + Int32: gtPresetDesiredPrebuildInstances, + Valid: true, + }, + }) + dbgen.PresetParameter(t, store, database.InsertPresetParametersParams{ + TemplateVersionPresetID: gtPreset.ID, + Names: []string{"region"}, + Values: []string{"us-west-2"}, + }) + + // A second template with no presets, used to assert + // the omit-when-empty behavior of the `presets` field. + gtNoPresetBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).Do() + + t.Run("WithPresets", func(t *testing.T) { + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + + result, err := testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{ + TemplateID: gtBuild.Template.ID.String(), + }) + require.NoError(t, err) + + // MinimalTemplate fields populated. + require.Equal(t, gtBuild.Template.ID.String(), result.ID) + require.Equal(t, gtBuild.Template.Name, result.Name) + require.Equal(t, gtBuild.Template.ActiveVersionID, result.ActiveVersionID) + + // Parameters round-trip from the active version. + require.Len(t, result.Parameters, 1) + require.Equal(t, "region", result.Parameters[0].Name) + require.Equal(t, "us-east-1", result.Parameters[0].DefaultValue) + + // Presets and their parameters round-trip. + require.Len(t, result.Presets, 1) + require.Equal(t, gtPreset.ID, result.Presets[0].ID) + require.Equal(t, gtPreset.Name, result.Presets[0].Name) + require.Equal(t, "Preset for GetTemplate tests.", result.Presets[0].Description) + require.Len(t, result.Presets[0].Parameters, 1) + require.Equal(t, "region", result.Presets[0].Parameters[0].Name) + require.Equal(t, "us-west-2", result.Presets[0].Parameters[0].Value) + + // DesiredPrebuildInstances round-trips through toPresetView. + // The tool description tells the LLM to prefer presets with + // desired_prebuild_instances > 0; if this field stops + // flowing, that hint silently breaks. + require.NotNil(t, result.Presets[0].DesiredPrebuildInstances, + "desired_prebuild_instances should be populated when the preset has DesiredInstances") + require.EqualValues(t, gtPresetDesiredPrebuildInstances, *result.Presets[0].DesiredPrebuildInstances) + }) + + t.Run("WithoutPresets", func(t *testing.T) { + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + + result, err := testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{ + TemplateID: gtNoPresetBuild.Template.ID.String(), + }) + require.NoError(t, err) + + require.Equal(t, gtNoPresetBuild.Template.ID.String(), result.ID) + require.Empty(t, result.Presets, "presets should be empty when the template has none") + + // The `presets` field should be absent from the + // JSON entirely when the template has no presets. + b, err := json.Marshal(result) + require.NoError(t, err) + require.NotContains(t, string(b), `"presets"`) + }) + + t.Run("InvalidID", func(t *testing.T) { + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + + _, err = testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{ + TemplateID: "not-a-uuid", + }) + require.ErrorContains(t, err, "template_id must be a valid UUID") + }) + + t.Run("NotFound", func(t *testing.T) { + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + + _, err = testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{ + TemplateID: uuid.New().String(), + }) + require.ErrorContains(t, err, "get template") + }) + }) + t.Run("GetWorkspaceAgentLogs", func(t *testing.T) { tb, err := toolsdk.NewDeps(memberClient) require.NoError(t, err) @@ -533,18 +833,193 @@ func TestTools(t *testing.T) { t.Run("CreateWorkspace", func(t *testing.T) { tb, err := toolsdk.NewDeps(client) require.NoError(t, err) - // We need a template version ID to create a workspace - res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ - User: "me", - TemplateVersionID: r.TemplateVersion.ID.String(), - Name: testutil.GetRandomNameHyphenated(t), - RichParameters: map[string]string{}, + t.Run("WithoutPreset", func(t *testing.T) { + res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ + User: "me", + TemplateVersionID: r.TemplateVersion.ID.String(), + Name: testutil.GetRandomNameHyphenated(t), + RichParameters: map[string]string{}, + }) + + require.NoError(t, err) + require.NotEmpty(t, res.ID, "expected a workspace ID") }) - // The creation might fail for various reasons, but the important thing is - // to mark it as tested - require.NoError(t, err) - require.NotEmpty(t, res.ID, "expected a workspace ID") + t.Run("WithPreset", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ + User: "me", + TemplateVersionID: r.TemplateVersion.ID.String(), + TemplateVersionPresetID: preset.ID.String(), + Name: testutil.GetRandomNameHyphenated(t), + RichParameters: map[string]string{}, + }) + + require.NoError(t, err) + require.NotEmpty(t, res.ID, "expected a workspace ID") + + build, err := client.WorkspaceBuild(ctx, res.LatestBuild.ID) + require.NoError(t, err) + require.NotNil(t, build.TemplateVersionPresetID) + require.Equal(t, preset.ID, *build.TemplateVersionPresetID) + }) + + t.Run("WithTemplateID", func(t *testing.T) { + // Exercises the template_id path on create_workspace, + // which lets the server resolve the active version + // atomically with the build. Mirrors how the chattool + // surface keys this tool. + res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ + User: "me", + TemplateID: r.Template.ID.String(), + Name: testutil.GetRandomNameHyphenated(t), + RichParameters: map[string]string{}, + }) + + require.NoError(t, err) + require.NotEmpty(t, res.ID, "expected a workspace ID") + }) + + t.Run("WithRichParameters", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + // Isolated fixture: a template version with a single + // rich parameter, no preset. Confirms that + // rich_parameters round-trip on their own without + // being shadowed or overridden by preset auto-binding + // when no preset matches. + rpBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).Do() + dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{ + TemplateVersionID: rpBuild.TemplateVersion.ID, + Name: "region", + Description: "Region to deploy in.", + Type: "string", + DefaultValue: "us-east-1", + Required: false, + Mutable: true, + }) + + res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ + User: "me", + TemplateVersionID: rpBuild.TemplateVersion.ID.String(), + Name: testutil.GetRandomNameHyphenated(t), + RichParameters: map[string]string{"region": "us-west-2"}, + }) + require.NoError(t, err) + require.NotEmpty(t, res.ID, "expected a workspace ID") + + params, err := client.WorkspaceBuildParameters(ctx, res.LatestBuild.ID) + require.NoError(t, err) + require.Len(t, params, 1) + require.Equal(t, "region", params[0].Name) + require.Equal(t, "us-west-2", params[0].Value) + }) + + t.Run("RejectsBothIDs", func(t *testing.T) { + _, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ + User: "me", + TemplateID: r.Template.ID.String(), + TemplateVersionID: r.TemplateVersion.ID.String(), + Name: testutil.GetRandomNameHyphenated(t), + RichParameters: map[string]string{}, + }) + require.ErrorContains(t, err, "exactly one of template_id or template_version_id") + }) + + t.Run("RejectsNeitherID", func(t *testing.T) { + _, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ + User: "me", + Name: testutil.GetRandomNameHyphenated(t), + RichParameters: map[string]string{}, + }) + require.ErrorContains(t, err, "exactly one of template_id or template_version_id") + }) + + t.Run("WithPresetAndParams", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + // Build an isolated fixture: a template version with one + // rich parameter and a preset that sets it. The shared + // fixture's preset has no parameters and would not exercise + // the override path. + ovBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).Do() + dbgen.TemplateVersionParameter(t, store, database.TemplateVersionParameter{ + TemplateVersionID: ovBuild.TemplateVersion.ID, + Name: "region", + Description: "Region to deploy in.", + Type: "string", + DefaultValue: "us-east-1", + Required: false, + Mutable: true, + }) + ovPreset := dbgen.Preset(t, store, database.InsertPresetParams{ + TemplateVersionID: ovBuild.TemplateVersion.ID, + Name: testutil.GetRandomNameHyphenated(t), + CreatedAt: ovBuild.TemplateVersion.CreatedAt, + Description: "Preset for override test.", + }) + dbgen.PresetParameter(t, store, database.InsertPresetParametersParams{ + TemplateVersionPresetID: ovPreset.ID, + Names: []string{"region"}, + Values: []string{"us-west-2"}, + }) + + // Send conflicting rich_parameters; the preset value + // should win, per the contract advertised in the + // template_version_preset_id schema description. + res, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ + User: "me", + TemplateVersionID: ovBuild.TemplateVersion.ID.String(), + TemplateVersionPresetID: ovPreset.ID.String(), + Name: testutil.GetRandomNameHyphenated(t), + RichParameters: map[string]string{"region": "us-east-1"}, + }) + require.NoError(t, err) + require.NotEmpty(t, res.ID, "expected a workspace ID") + + // wsbuilder persists resolved parameters during the + // build transaction, before provisioning, so the values + // are readable immediately without waiting for the + // build job to complete. + params, err := client.WorkspaceBuildParameters(ctx, res.LatestBuild.ID) + require.NoError(t, err) + require.Len(t, params, 1) + require.Equal(t, "region", params[0].Name) + require.Equal(t, "us-west-2", params[0].Value, + "preset parameter value must override conflicting rich_parameters entry") + }) + + t.Run("RejectsInvalidTemplateID", func(t *testing.T) { + _, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ + User: "me", + Name: testutil.GetRandomNameHyphenated(t), + TemplateID: "not-a-uuid", + }) + require.ErrorContains(t, err, "template_id must be a valid UUID") + }) + + t.Run("RejectsInvalidTemplateVersionID", func(t *testing.T) { + _, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ + User: "me", + Name: testutil.GetRandomNameHyphenated(t), + TemplateVersionID: "not-a-uuid", + }) + require.ErrorContains(t, err, "template_version_id must be a valid UUID") + }) + + t.Run("RejectsInvalidTemplateVersionPresetID", func(t *testing.T) { + _, err := testTool(t, toolsdk.CreateWorkspace, tb, toolsdk.CreateWorkspaceArgs{ + User: "me", + Name: testutil.GetRandomNameHyphenated(t), + TemplateVersionID: uuid.NewString(), + TemplateVersionPresetID: "not-a-uuid", + }) + require.ErrorContains(t, err, "template_version_preset_id must be a valid UUID") + }) }) t.Run("WorkspaceSSHExec", func(t *testing.T) { @@ -1123,11 +1598,10 @@ func TestTools(t *testing.T) { { name: "WithPreset", args: toolsdk.CreateTaskArgs{ - TemplateVersionID: r.TemplateVersion.ID.String(), + TemplateVersionID: aiTV.TemplateVersion.ID.String(), TemplateVersionPresetID: presetID.String(), Input: "not enough barrel rolls", }, - error: "Template does not have a valid \"coder_ai_task\" resource.", }, }