Files
coder/coderd/x/chatd/chattool/readtemplate_test.go
T
Cian Johnston 2d28c1b396 feat: surface template README to agent template tools (#26334)
Fixes CODAGT-447.

Alternative implementation of https://github.com/coder/coder/pull/26212
and https://github.com/coder/coder/pull/25978

- Adds up to the first 1000 characters of `README.md` (with leading
frontmatter stripped) to `chattool.list_templates` output
- Adds up to 800 characters of `README.md` to `chattool.read_template`.

**Note:** skipping `toolsdk` versions to keep scope small.

> 🤖 Generated by Coder Agents
2026-06-24 12:32:46 +01:00

289 lines
9.2 KiB
Go

package chattool_test
import (
"database/sql"
"encoding/json"
"strings"
"testing"
"charm.land/fantasy"
"github.com/google/uuid"
"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(db, org.ID, 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(db, org.ID, 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")
}
func TestReadTemplate_Readme(t *testing.T) {
t.Parallel()
// Seed the database, user, and organization once and reuse them across
// subtests; each subtest only adds its own template (and version).
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,
})
readTemplateInfo := func(t *testing.T, activeVersionID uuid.UUID) map[string]any {
t.Helper()
tmpl := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
ActiveVersionID: activeVersionID,
})
ctx := testutil.Context(t, testutil.WaitShort)
tool := chattool.ReadTemplate(db, org.ID, 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))
tmplInfo, ok := result["template"].(map[string]any)
require.True(t, ok)
return tmplInfo
}
readTemplateInfoForReadme := func(t *testing.T, readme string) map[string]any {
t.Helper()
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
Readme: readme,
})
return readTemplateInfo(t, tv.ID)
}
t.Run("Surfaced", func(t *testing.T) {
t.Parallel()
readme := "---\ndescription: Go template.\n---\n# Title\n\nUse Docker.\n"
tmplInfo := readTemplateInfoForReadme(t, readme)
require.Equal(t, "Title\nUse Docker.", tmplInfo["readme"])
})
t.Run("EmptyOmitsField", func(t *testing.T) {
t.Parallel()
tmplInfo := readTemplateInfoForReadme(t, " \n\t\n")
_, ok := tmplInfo["readme"]
require.False(t, ok, "readme should be omitted when blank")
})
t.Run("NotTruncatedUnderCap", func(t *testing.T) {
t.Parallel()
readme := "# Title\n\n" + strings.Repeat("x", 3000)
tmplInfo := readTemplateInfoForReadme(t, readme)
require.Equal(t, "Title\n"+strings.Repeat("x", 3000), tmplInfo["readme"])
})
// Images are dropped but code blocks are preserved as text (detail view).
t.Run("DropsImagesKeepsCode", func(t *testing.T) {
t.Parallel()
readme := "# Setup\n\n![diagram](./a.svg)\n\nRun the installer.\n\n```sh\nmake build\n```\n\nDone.\n"
tmplInfo := readTemplateInfoForReadme(t, readme)
require.Equal(t, "Setup\nRun the installer.\nmake build\nDone.", tmplInfo["readme"])
})
// READMEs larger than the cap are truncated with a trailing ellipsis so a
// single large document cannot dominate the response.
t.Run("TruncatedOverCap", func(t *testing.T) {
t.Parallel()
readme := strings.Repeat("x", 9000)
tmplInfo := readTemplateInfoForReadme(t, readme)
got, ok := tmplInfo["readme"].(string)
require.True(t, ok)
gotRunes := []rune(got)
require.Len(t, gotRunes, chattool.ReadTemplateReadmeMaxRunes)
require.Equal(t, '…', gotRunes[len(gotRunes)-1])
})
// A template whose active version row is missing must not fail
// read_template; the version fetch is best-effort and readme is simply
// omitted.
t.Run("MissingVersionOmitsField", func(t *testing.T) {
t.Parallel()
tmplInfo := readTemplateInfo(t, uuid.New())
_, ok := tmplInfo["readme"]
require.False(t, ok, "readme should be omitted when the version is missing")
})
}