mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix: add preset support to MCP tools (#24694)
The chat tools (`read_template`, `create_workspace`) did not surface or respect template version presets. Presets were invisible to the LLM and preset parameter defaults were never applied at workspace creation. The `toolsdk` MCP surface had the same gap (ref #24695, now subsumed here). ## What this changes - **`read_template`** returns presets with `id`, `name`, `default`, `description`, `icon`, `parameters`, and `desired_prebuild_instances` (when set), so the LLM can pick the right preset and prefer prebuilt-backed ones. - **`create_workspace`** accepts a `preset_id`. The wsbuilder applies preset parameter defaults and may claim a prebuilt workspace. - **`start_workspace`** does *not* accept a preset. Presets are a creation-time choice; subsequent starts use the workspace's existing version and parameters. Users who need a specific preset or version on an existing chat can create the workspace out-of-band (CLI / UI / API) with the desired configuration and attach the chat to it. - **`toolsdk`** gains `GetTemplate` (with presets including `desired_prebuild_instances`), preset support on `CreateWorkspace`, and preset + `rich_parameters` support on `CreateWorkspaceBuild`. The `template_version_preset_id` description warns about preset/version affinity. > 🤖 Generated with [Coder Agents](https://coder.com/agents) and reviewed by a human. Co-authored-by: Max schwenk <maschwenk@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Max schwenk
Claude Opus 4.7
parent
90bee3aaef
commit
04cc983833
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user