Files
coder/coderd/x/chatd/chattool/listtemplates_test.go
T
Ethan 0ac23e3ee1 feat: add per-template Coder Agents access control (#27285)
Relates to CODAGT-713

Depends on #27284

This makes the per-template `agents_allowed` field authoritative in the API and chatd. It adds optional create and metadata update fields with the intended default and omission semantics, supports `agents-allowed:` template search, includes the value in telemetry, and makes `list_templates`, `read_template`, and `create_workspace` read the template row directly. Existing-workspace retries remain idempotent, and blocked same-organisation templates return an actionable message.

The experimental `/template-allowlist` routes remain temporarily because the shipped AI Settings page still calls them, but they no longer control chatd enforcement. #27514 moves that page to per-template metadata, #27515 removes the legacy storage, routes, SDK types, and utility, #27517 adds the CLI flags, and #27518 updates the platform controls documentation for the per-template model, directly addressing CRF-5 and CRF-6. The stack is intended to merge as a unit.
2026-08-06 14:14:37 +10:00

1109 lines
38 KiB
Go

package chattool_test
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"charm.land/fantasy"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestListTemplates_OrganizationFilter(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})
orgA := dbgen.Organization(t, db, database.Organization{})
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: orgA.ID,
})
orgB := dbgen.Organization(t, db, database.Organization{})
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: orgB.ID,
})
tAlpha := dbgen.Template(t, db, database.Template{
OrganizationID: orgA.ID,
CreatedBy: user.ID,
Name: "alpha",
AgentsAllowed: true,
})
tBeta := dbgen.Template(t, db, database.Template{
OrganizationID: orgB.ID,
CreatedBy: user.ID,
Name: "beta",
AgentsAllowed: true,
})
t.Run("ScopedToOrgA", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
tool := chattool.ListTemplates(db, orgA.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
})
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "org-a", Name: "list_templates", Input: "{}"})
require.NoError(t, err)
require.False(t, resp.IsError)
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
templates := result["templates"].([]any)
require.Len(t, templates, 1)
m := templates[0].(map[string]any)
require.Equal(t, tAlpha.ID.String(), m["id"].(string))
})
t.Run("NilOrgReturnsBoth", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
tool := chattool.ListTemplates(db, uuid.Nil, chattool.ListTemplatesOptions{
OwnerID: user.ID,
// Pass uuid.Nil to skip org filtering.
})
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "nil-org", Name: "list_templates", Input: "{}"})
require.NoError(t, err)
require.False(t, resp.IsError)
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
templates := result["templates"].([]any)
require.Len(t, templates, 2)
require.Equal(t, chattool.NextStepAskUser, result["next_step"])
_, ok := result["recommended_template_id"]
require.False(t, ok)
})
t.Run("ReadTemplate_CrossOrgRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
// Tool scoped to orgA, but requesting a template in orgB.
tool := chattool.ReadTemplate(db, orgA.ID, chattool.ReadTemplateOptions{
OwnerID: user.ID,
})
input := `{"template_id":"` + tBeta.ID.String() + `"}`
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "cross-org", Name: "read_template", Input: input})
require.NoError(t, err)
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "not found")
})
t.Run("ReadTemplate_SameOrgAllowed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
// Tool scoped to orgA, requesting a template in orgA.
tool := chattool.ReadTemplate(db, orgA.ID, chattool.ReadTemplateOptions{
OwnerID: user.ID,
})
input := `{"template_id":"` + tAlpha.ID.String() + `"}`
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "same-org", Name: "read_template", Input: input})
require.NoError(t, err)
require.False(t, resp.IsError)
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
tmplInfo := result["template"].(map[string]any)
require.Equal(t, tAlpha.ID.String(), tmplInfo["id"].(string))
})
}
func TestListTemplates_QueryMatchesDisplayNameAndDescription(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
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,
})
displayTemplate := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "tpl-42",
DisplayName: "Data Science Lab",
AgentsAllowed: true,
})
descriptionTemplate := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "node-general",
Description: "A JavaScript and TypeScript workspace.",
AgentsAllowed: true,
})
_ = dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "unrelated",
Description: "A plain Linux workspace.",
AgentsAllowed: true,
})
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
})
result := runListTemplates(ctx, t, tool, `{"query":"Data Science"}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 1)
require.Equal(t, displayTemplate.ID.String(), templates[0]["id"])
require.Equal(t, chattool.NextStepUseRecommended, result["next_step"])
require.Equal(t, displayTemplate.ID.String(), result["recommended_template_id"])
result = runListTemplates(ctx, t, tool, `{"query":"TypeScript"}`)
templates = listTemplateItems(t, result)
require.Len(t, templates, 1)
require.Equal(t, descriptionTemplate.ID.String(), templates[0]["id"])
require.Equal(t, chattool.NextStepUseRecommended, result["next_step"])
require.Equal(t, descriptionTemplate.ID.String(), result["recommended_template_id"])
result = runListTemplates(ctx, t, tool, `{"query":"-"}`)
templates = listTemplateItems(t, result)
require.Empty(t, templates)
require.Equal(t, chattool.NextStepNoMatches, result["next_step"])
_, ok := result["recommended_template_id"]
require.False(t, ok)
result = runListTemplates(ctx, t, tool, `{"query":"does-not-exist"}`)
templates = listTemplateItems(t, result)
require.Empty(t, templates)
require.Equal(t, chattool.NextStepNoMatches, result["next_step"])
_, ok = result["recommended_template_id"]
require.False(t, ok)
}
func TestListTemplates_QueryScoreTiers(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
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,
})
exact := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "python",
AgentsAllowed: true,
})
prefix := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "python-alpha",
AgentsAllowed: true,
})
contains := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "go-python",
AgentsAllowed: true,
})
description := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "generic-dev",
Description: "Python-capable general environment.",
AgentsAllowed: true,
})
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
})
result := runListTemplates(ctx, t, tool, `{"query":"python"}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 4)
require.Equal(t, exact.ID.String(), templates[0]["id"])
require.Equal(t, prefix.ID.String(), templates[1]["id"])
require.Equal(t, contains.ID.String(), templates[2]["id"])
require.Equal(t, description.ID.String(), templates[3]["id"])
hyphenated := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "python-gpu",
AgentsAllowed: true,
})
result = runListTemplates(ctx, t, tool, `{"query":"python gpu"}`)
templates = listTemplateItems(t, result)
require.Len(t, templates, 1)
require.Equal(t, hyphenated.ID.String(), templates[0]["id"])
descriptionHyphenated := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "ml-tools",
Description: "Includes machine-learning libraries.",
AgentsAllowed: true,
})
result = runListTemplates(ctx, t, tool, `{"query":"machine learning"}`)
templates = listTemplateItems(t, result)
require.Len(t, templates, 1)
require.Equal(t, descriptionHyphenated.ID.String(), templates[0]["id"])
}
func TestListTemplates_RanksAllCandidatesBeforePagination(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
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,
})
var target database.Template
for i := range 11 {
tpl := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: fmt.Sprintf("template-%02d", i),
AgentsAllowed: true,
})
if i == 10 {
target = tpl
}
}
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: target.ID,
LastUsedAt: time.Now().Add(-time.Hour),
})
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
})
result := runListTemplates(ctx, t, tool, `{}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 10)
require.Equal(t, float64(1), result["page"])
require.Equal(t, float64(2), result["next_page"])
require.Equal(t, target.ID.String(), templates[0]["id"])
require.Equal(t, float64(1), templates[0]["your_workspace_count"])
require.NotEmpty(t, templates[0]["last_used_by_you"])
require.Equal(t, chattool.NextStepUseRecommended, result["next_step"])
require.Equal(t, target.ID.String(), result["recommended_template_id"])
result = runListTemplates(ctx, t, tool, `{"page":2}`)
templates = listTemplateItems(t, result)
require.Len(t, templates, 1)
require.Equal(t, float64(2), result["page"])
_, hasNextPage := result["next_page"]
require.False(t, hasNextPage)
}
func TestListTemplates_QueryRelevanceOutranksPersonalUsage(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
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,
})
target := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "python-gpu",
Description: "GPU workspace.",
AgentsAllowed: true,
})
used := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "generic-dev",
Description: "Python-capable general environment.",
AgentsAllowed: true,
})
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: used.ID,
LastUsedAt: time.Now().Add(-14 * 24 * time.Hour),
})
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
})
result := runListTemplates(ctx, t, tool, `{"query":"python"}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 2)
require.Equal(t, target.ID.String(), templates[0]["id"])
require.Equal(t, used.ID.String(), templates[1]["id"])
require.Equal(t, chattool.NextStepUseRecommended, result["next_step"])
require.Equal(t, target.ID.String(), result["recommended_template_id"])
}
func TestListTemplates_PersonalUsageBreaksEqualQueryScoreTie(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
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,
})
unused := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "python-alpha",
AgentsAllowed: true,
})
used := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "python-beta",
AgentsAllowed: true,
})
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: used.ID,
LastUsedAt: time.Now().Add(-time.Hour),
})
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
})
result := runListTemplates(ctx, t, tool, `{"query":"python"}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 2)
require.Equal(t, used.ID.String(), templates[0]["id"])
require.Equal(t, unused.ID.String(), templates[1]["id"])
require.Equal(t, chattool.NextStepUseRecommended, result["next_step"])
require.Equal(t, used.ID.String(), result["recommended_template_id"])
}
func TestListTemplates_OrgPopularityFallback(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
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,
})
popular := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "popular-template",
AgentsAllowed: true,
})
lessPopular := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "less-popular-template",
AgentsAllowed: true,
})
for range 2 {
otherUser := dbgen.User(t, db, database.User{})
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: otherUser.ID,
OrganizationID: org.ID,
TemplateID: popular.ID,
})
}
otherUser := dbgen.User(t, db, database.User{})
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: otherUser.ID,
OrganizationID: org.ID,
TemplateID: lessPopular.ID,
})
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
})
result := runListTemplates(ctx, t, tool, `{}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 2)
require.Equal(t, popular.ID.String(), templates[0]["id"])
require.Equal(t, float64(2), templates[0]["active_developers"])
require.Equal(t, chattool.NextStepUseRecommended, result["next_step"])
require.Equal(t, popular.ID.String(), result["recommended_template_id"])
}
func TestListTemplates_WeakOrgPopularityDoesNotRecommend(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
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,
})
usedByOne := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "used-by-one",
AgentsAllowed: true,
})
unused := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "unused",
AgentsAllowed: true,
})
otherUser := dbgen.User(t, db, database.User{})
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: otherUser.ID,
OrganizationID: org.ID,
TemplateID: usedByOne.ID,
})
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
})
result := runListTemplates(ctx, t, tool, `{}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 2)
require.Equal(t, usedByOne.ID.String(), templates[0]["id"])
require.Equal(t, unused.ID.String(), templates[1]["id"])
require.Equal(t, float64(1), templates[0]["active_developers"])
require.Equal(t, chattool.NextStepAskUser, result["next_step"])
_, ok := result["recommended_template_id"]
require.False(t, ok)
}
func TestListTemplates_StalePersonalUsageDoesNotRecommend(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
clock.Set(now).MustWait(ctx)
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,
})
oldUsage := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "old-usage",
AgentsAllowed: true,
})
unused := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "unused",
AgentsAllowed: true,
})
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: oldUsage.ID,
LastUsedAt: now.Add(-180 * 24 * time.Hour),
})
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
Clock: clock,
})
result := runListTemplates(ctx, t, tool, `{}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 2)
require.Equal(t, oldUsage.ID.String(), templates[0]["id"])
require.Equal(t, unused.ID.String(), templates[1]["id"])
// 180 days old is outside the 60-day lookback window.
_, hasCount := templates[0]["your_workspace_count"]
require.False(t, hasCount)
require.Equal(t, chattool.NextStepAskUser, result["next_step"])
_, ok := result["recommended_template_id"]
require.False(t, ok)
}
func TestListTemplates_StaleFrequentPersonalUsageDoesNotRecommend(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
clock.Set(now).MustWait(ctx)
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,
})
staleUsage := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "stale-usage",
AgentsAllowed: true,
})
unused := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "unused",
AgentsAllowed: true,
})
// Stale usage decays out of the personal signal despite its frequency.
for range 2 {
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: staleUsage.ID,
LastUsedAt: now.Add(-180 * 24 * time.Hour),
})
}
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
Clock: clock,
})
result := runListTemplates(ctx, t, tool, `{}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 2)
require.Equal(t, staleUsage.ID.String(), templates[0]["id"])
require.Equal(t, unused.ID.String(), templates[1]["id"])
require.Equal(t, chattool.NextStepAskUser, result["next_step"])
_, ok := result["recommended_template_id"]
require.False(t, ok)
_, hasCount := templates[0]["your_workspace_count"]
require.False(t, hasCount)
}
func TestListTemplates_RecentPersonalUsageRecommends(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
clock.Set(now).MustWait(ctx)
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,
})
recentUsage := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "recent-usage",
AgentsAllowed: true,
})
unused := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "unused",
AgentsAllowed: true,
})
// Recent in-window usage is a confident signal.
for range 2 {
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: recentUsage.ID,
LastUsedAt: now.Add(-2 * 24 * time.Hour),
})
}
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
Clock: clock,
})
result := runListTemplates(ctx, t, tool, `{}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 2)
require.Equal(t, recentUsage.ID.String(), templates[0]["id"])
require.Equal(t, unused.ID.String(), templates[1]["id"])
require.Equal(t, float64(2), templates[0]["your_workspace_count"])
require.Equal(t, chattool.NextStepUseRecommended, result["next_step"])
require.Equal(t, recentUsage.ID.String(), result["recommended_template_id"])
}
func TestListTemplates_DeletedRecentPersonalUsageShowsEvidence(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
clock.Set(now).MustWait(ctx)
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,
})
deletedUsage := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "deleted-usage",
AgentsAllowed: true,
})
unused := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "unused",
AgentsAllowed: true,
})
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: deletedUsage.ID,
LastUsedAt: now.Add(-2 * 24 * time.Hour),
Deleted: true,
})
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
Clock: clock,
})
result := runListTemplates(ctx, t, tool, `{}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 2)
require.Equal(t, deletedUsage.ID.String(), templates[0]["id"])
require.Equal(t, unused.ID.String(), templates[1]["id"])
require.NotEmpty(t, templates[0]["last_used_by_you"])
_, hasActiveCount := templates[0]["your_workspace_count"]
require.False(t, hasActiveCount)
// Recent deleted usage alone clears the confidence floor.
require.Equal(t, chattool.NextStepUseRecommended, result["next_step"])
require.Equal(t, deletedUsage.ID.String(), result["recommended_template_id"])
}
func TestListTemplates_AmbiguousTopMatches(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
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,
})
_ = dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "go-alpha",
AgentsAllowed: true,
})
_ = dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "go-beta",
AgentsAllowed: true,
})
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
})
result := runListTemplates(ctx, t, tool, `{"query":"go"}`)
templates := listTemplateItems(t, result)
require.Len(t, templates, 2)
require.Equal(t, chattool.NextStepAskUser, result["next_step"])
_, ok := result["recommended_template_id"]
require.False(t, ok)
}
//nolint:tparallel,paralleltest // Subtests share a single DB and run sequentially.
func TestTemplateAgentsAllowedEnforcement(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
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,
})
allowed := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "template-alpha",
AgentsAllowed: true,
})
blocked := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "template-beta",
AgentsAllowed: false,
})
t.Run("ListTemplates", func(t *testing.T) {
tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{
OwnerID: user.ID,
})
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c1", Name: "list_templates", Input: "{}"})
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
templates := result["templates"].([]any)
require.Len(t, templates, 1)
template := templates[0].(map[string]any)
require.Equal(t, allowed.ID.String(), template["id"])
require.NotEqual(t, blocked.ID.String(), template["id"])
})
t.Run("ReadTemplate", func(t *testing.T) {
t.Run("Allowed", func(t *testing.T) {
tool := chattool.ReadTemplate(db, org.ID, chattool.ReadTemplateOptions{
OwnerID: user.ID,
})
input := `{"template_id":"` + allowed.ID.String() + `"}`
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c2", Name: "read_template", Input: input})
require.NoError(t, err)
require.False(t, resp.IsError)
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
template := result["template"].(map[string]any)
require.Equal(t, allowed.ID.String(), template["id"])
})
t.Run("Blocked", func(t *testing.T) {
tool := chattool.ReadTemplate(db, org.ID, chattool.ReadTemplateOptions{
OwnerID: user.ID,
})
input := `{"template_id":"` + blocked.ID.String() + `"}`
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c3", Name: "read_template", Input: input})
require.NoError(t, err)
require.True(t, resp.IsError)
require.Equal(t, "template not available for chat workspaces; use list_templates to find allowed templates", resp.Content)
})
})
model := seedModelConfig(t, db)
t.Run("CreateWorkspace", func(t *testing.T) {
t.Run("Allowed", func(t *testing.T) {
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: model.ID,
Title: "allowed-create",
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeApi,
})
require.NoError(t, err)
createCalled := false
tool := chattool.CreateWorkspace(db, org.ID, chat.ID, chattool.CreateWorkspaceOptions{
OwnerID: user.ID,
CreateFn: func(_ context.Context, _ uuid.UUID, _ codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) {
createCalled = true
return codersdk.Workspace{}, nil
},
})
input := `{"template_id":"` + allowed.ID.String() + `"}`
_, err = tool.Run(ctx, fantasy.ToolCall{ID: "c4", Name: "create_workspace", Input: input})
require.NoError(t, err)
require.True(t, createCalled, "CreateFn should be called for an allowed template")
})
t.Run("Blocked", func(t *testing.T) {
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: model.ID,
Title: "blocked-create",
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeApi,
})
require.NoError(t, err)
var createCalled bool
tool := chattool.CreateWorkspace(db, org.ID, chat.ID, chattool.CreateWorkspaceOptions{
OwnerID: user.ID,
CreateFn: func(_ context.Context, _ uuid.UUID, _ codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) {
createCalled = true
t.Fatal("CreateFn should not be called for a blocked template")
return codersdk.Workspace{}, nil
},
})
input := `{"template_id":"` + blocked.ID.String() + `"}`
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c5", Name: "create_workspace", Input: input})
require.NoError(t, err)
require.True(t, resp.IsError)
require.Equal(t, "template not available for chat workspaces; use list_templates to find allowed templates", resp.Content)
require.False(t, createCalled, "CreateFn should not be called for a blocked template")
})
})
}
// TestListTemplates_ReadmeExcerpt runs list_templates through a dbauthz-wrapped
// store as an ordinary org member (not the site owner) and asserts which
// templates surface a readme_excerpt. Exercising it under real RBAC as a
// non-owner also guards against regressing to a system-scoped version query that
// only the owner role can run.
func TestListTemplates_ReadmeExcerpt(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,
})
ctx := testutil.Context(t, testutil.WaitShort)
// seed creates a template whose active version carries readme, linking the
// version back to the template so GetTemplateVersionByID authorizes via the
// parent template (the production path), not the broader org-level fallback
// used for unlinked versions.
seed := func(name, readme string) database.Template {
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
Readme: readme,
})
tmpl := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: name,
ActiveVersionID: tv.ID,
AgentsAllowed: true,
})
require.NoError(t, db.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{
ID: tv.ID,
TemplateID: uuid.NullUUID{UUID: tmpl.ID, Valid: true},
UpdatedAt: tv.UpdatedAt,
Name: tv.Name,
Message: tv.Message,
}))
return tmpl
}
longReadme := strings.TrimSpace(strings.Repeat("Go with Docker. ", 90))
tWith := seed("with-readme", longReadme+"\n")
// A README that opens with a frontmatter block: the excerpt must skip the
// metadata and surface the body prose instead.
tFrontmatter := seed("frontmatter-readme", "---\ndisplay_name: With Frontmatter\ntags: [a, b]\n---\nRouting prose for the agent.\n")
seed("empty-readme", " \n\t\n")
// A frontmatter-only README has no body, so the excerpt is omitted.
seed("frontmatter-only", "---\ndisplay_name: Only Frontmatter\n---\n")
_ = dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
Name: "missing-version",
ActiveVersionID: uuid.New(),
AgentsAllowed: true,
})
// Run through a dbauthz-wrapped store so the tool executes under real RBAC as
// the member, not with the raw store's implicit system access.
authzDB := dbauthz.New(
db,
rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()),
slogtest.Make(t, nil),
testAccessControlStorePointer(),
)
tool := chattool.ListTemplates(authzDB, org.ID, chattool.ListTemplatesOptions{OwnerID: user.ID})
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "list", Name: "list_templates", Input: "{}"})
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))
items := result["templates"].([]any)
byID := make(map[string]map[string]any, len(items))
gotHasExcerpt := make(map[string]bool, len(items))
for _, it := range items {
m := it.(map[string]any)
byID[m["id"].(string)] = m
_, ok := m["readme_excerpt"]
gotHasExcerpt[m["name"].(string)] = ok
}
// Assert which templates surface a readme_excerpt in a single structural
// diff: present for real prose (with or without frontmatter), omitted when
// the body is blank, frontmatter-only, or the active version is missing.
wantHasExcerpt := map[string]bool{
"with-readme": true,
"frontmatter-readme": true,
"empty-readme": false,
"frontmatter-only": false,
"missing-version": false,
}
if diff := cmp.Diff(wantHasExcerpt, gotHasExcerpt); diff != "" {
t.Fatalf("readme_excerpt presence mismatch (-want +got):\n%s", diff)
}
// The long README is truncated to the cap and ends with an ellipsis so the
// agent can tell a clipped excerpt from a complete one.
excerpt, ok := byID[tWith.ID.String()]["readme_excerpt"].(string)
require.True(t, ok)
excerptRunes := []rune(excerpt)
require.Len(t, excerptRunes, chattool.ListTemplatesReadmeExcerptMaxRunes)
require.Equal(t, '…', excerptRunes[len(excerptRunes)-1])
require.Equal(t, string([]rune(longReadme)[:chattool.ListTemplatesReadmeExcerptMaxRunes-1]), string(excerptRunes[:len(excerptRunes)-1]))
// Frontmatter is skipped so the body prose fills the excerpt.
require.Equal(t, "Routing prose for the agent.", byID[tFrontmatter.ID.String()]["readme_excerpt"],
"readme_excerpt should skip frontmatter and surface the body")
}
// TestGetTemplateRankingSignalsByOwnerID exercises the raw SQL signals query:
// the lookback window, the active/deleted split, and excluding the prebuilds
// system user from the organization developer count.
func TestGetTemplateRankingSignalsByOwnerID(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, _ := dbtestutil.NewDB(t)
now := time.Now()
lookbackCutoff := now.Add(-60 * 24 * time.Hour)
user := dbgen.User(t, db, database.User{})
otherUser := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
for _, u := range []uuid.UUID{user.ID, otherUser.ID} {
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: u, OrganizationID: org.ID})
}
used := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID, CreatedBy: user.ID, Name: "used",
AgentsAllowed: true,
})
unused := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused",
AgentsAllowed: true,
})
activeLastUsedAt := now.Add(-2 * 24 * time.Hour)
deletedLastUsedAt := now.Add(-3 * 24 * time.Hour)
// Active, in-window workspace for the requesting user.
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID, OrganizationID: org.ID, TemplateID: used.ID,
LastUsedAt: activeLastUsedAt,
})
// Recently-deleted, in-window workspace for the requesting user.
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID, OrganizationID: org.ID, TemplateID: used.ID,
LastUsedAt: deletedLastUsedAt, Deleted: true,
})
// Outside the lookback window: excluded from in-window counts, still an org dev.
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID, OrganizationID: org.ID, TemplateID: used.ID,
LastUsedAt: now.Add(-90 * 24 * time.Hour),
})
// Another developer's active workspace contributes to org popularity.
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: otherUser.ID, OrganizationID: org.ID, TemplateID: used.ID,
LastUsedAt: now.Add(-1 * 24 * time.Hour),
})
// The prebuilds system user must be excluded from the org developer count.
dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: database.PrebuildsSystemUserID, OrganizationID: org.ID, TemplateID: used.ID,
LastUsedAt: now.Add(-1 * 24 * time.Hour),
})
rows, err := db.GetTemplateRankingSignalsByOwnerID(ctx, database.GetTemplateRankingSignalsByOwnerIDParams{
TemplateIDs: []uuid.UUID{used.ID, unused.ID},
OwnerID: user.ID,
OrganizationID: org.ID,
PrebuildsUserID: database.PrebuildsSystemUserID,
LookbackCutoff: lookbackCutoff,
})
require.NoError(t, err)
byTemplate := make(map[uuid.UUID]database.GetTemplateRankingSignalsByOwnerIDRow, len(rows))
for _, row := range rows {
byTemplate[row.TemplateID] = row
}
// The unnest LEFT JOIN returns a row for every requested template.
require.Len(t, byTemplate, 2)
usedRow := byTemplate[used.ID]
require.Equal(t, int64(1), usedRow.ActiveCount, "only the in-window active workspace counts")
require.Equal(t, int64(1), usedRow.DeletedRecentCount, "the in-window deleted workspace counts")
require.Equal(t, int64(2), usedRow.OrgDevs, "user and otherUser count; prebuilds user is excluded")
require.True(t, usedRow.LastUsedAt.Valid)
require.WithinDuration(t, activeLastUsedAt, usedRow.LastUsedAt.Time, time.Microsecond)
unusedRow := byTemplate[unused.ID]
require.Equal(t, int64(0), unusedRow.ActiveCount)
require.Equal(t, int64(0), unusedRow.DeletedRecentCount)
require.Equal(t, int64(0), unusedRow.OrgDevs)
require.False(t, unusedRow.LastUsedAt.Valid)
}
func runListTemplates(
ctx context.Context,
t *testing.T,
tool fantasy.AgentTool,
input string,
) map[string]any {
t.Helper()
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: uuid.NewString(),
Name: "list_templates",
Input: input,
})
require.NoError(t, err)
require.False(t, resp.IsError)
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
return result
}
func listTemplateItems(t *testing.T, result map[string]any) []map[string]any {
t.Helper()
rawTemplates, ok := result["templates"].([]any)
require.True(t, ok)
templates := make([]map[string]any, 0, len(rawTemplates))
for _, raw := range rawTemplates {
template, ok := raw.(map[string]any)
require.True(t, ok)
templates = append(templates, template)
}
return templates
}