mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +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
+231
-30
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user