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:
Cian Johnston
2026-05-01 10:57:52 +01:00
committed by GitHub
co-authored by Max schwenk Claude Opus 4.7
parent 90bee3aaef
commit 04cc983833
10 changed files with 1290 additions and 48 deletions
+5
View File
@@ -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,
+53
View File
@@ -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()
+9
View File
@@ -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
+17 -1
View File
@@ -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)
}
+66 -5
View File
@@ -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 {