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
+231 -30
View File
@@ -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)
},
+486 -12
View File
@@ -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.",
},
}