mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
This commit is contained in:
@@ -9,9 +9,22 @@ import (
|
||||
gomarkdown "github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
|
||||
xhtml "golang.org/x/net/html"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// innerTextMarkdown converts Markdown to HTML for InnerTextFromMarkdown. Table
|
||||
// renders cells as text (not pipe-delimited lines); WithUnsafe lets embedded raw
|
||||
// HTML through so its inner text survives. Safe to share: goldmark inits the
|
||||
// parser once via sync.Once, then only reads it.
|
||||
var innerTextMarkdown = goldmark.New(
|
||||
goldmark.WithExtensions(extension.Table),
|
||||
goldmark.WithRendererOptions(goldmarkhtml.WithUnsafe()),
|
||||
)
|
||||
|
||||
var plaintextStyle = ansi.StyleConfig{
|
||||
Document: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{},
|
||||
@@ -108,3 +121,56 @@ func HTMLFromMarkdown(markdown string) string {
|
||||
})
|
||||
return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer)))
|
||||
}
|
||||
|
||||
// InnerTextFromMarkdown renders Markdown (including embedded raw HTML) to HTML
|
||||
// and returns its visible text ("innerText"). Block, code-line, and table-cell
|
||||
// boundaries become newlines and intra-line whitespace is collapsed; link text
|
||||
// is kept but URLs, images, and badges are dropped.
|
||||
//
|
||||
// Input is untrusted: a parser panic is recovered and returned as an error.
|
||||
func InnerTextFromMarkdown(markdown string) (out string, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
out, err = "", xerrors.Errorf("render markdown to innertext: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
var rendered bytes.Buffer
|
||||
if convErr := innerTextMarkdown.Convert([]byte(markdown), &rendered); convErr != nil {
|
||||
return "", xerrors.Errorf("convert markdown to html: %w", convErr)
|
||||
}
|
||||
|
||||
z := xhtml.NewTokenizer(&rendered)
|
||||
var b strings.Builder
|
||||
// script and style are raw-text elements: their body is the single text token
|
||||
// after the start tag. Skip just that token (not a running depth) so a stray
|
||||
// </script> or unterminated tag can't swallow the rest of the document.
|
||||
skipNextText := false
|
||||
for {
|
||||
if z.Next() == xhtml.ErrorToken {
|
||||
break // includes io.EOF
|
||||
}
|
||||
switch tok := z.Token(); tok.Type {
|
||||
case xhtml.StartTagToken:
|
||||
skipNextText = tok.Data == "script" || tok.Data == "style"
|
||||
case xhtml.TextToken:
|
||||
if skipNextText {
|
||||
skipNextText = false
|
||||
continue
|
||||
}
|
||||
_, _ = b.WriteString(tok.Data)
|
||||
default:
|
||||
skipNextText = false
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse intra-line whitespace but keep newlines so code lines, table
|
||||
// cells, and block boundaries stay on separate lines; drop blank lines.
|
||||
var lines []string
|
||||
for _, line := range strings.Split(b.String(), "\n") {
|
||||
if f := strings.Join(strings.Fields(line), " "); f != "" {
|
||||
lines = append(lines, f)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
@@ -87,3 +87,59 @@ func TestHTML(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerTextFromMarkdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"LinkTextKeptUrlDropped", "Use [Coder](https://coder.com/docs) now.", "Use Coder now."},
|
||||
{"ImageDropped", "# T\n\n\n\nBody.", "T\nBody."},
|
||||
{"BadgeDropped", "[](https://discord.gg/x)\n\nReal.", "Real."},
|
||||
{"CodeBlockLinesKept", "Intro.\n\n```sh\nnpm install\nnpm run dev\n```\n\nOutro.", "Intro.\nnpm install\nnpm run dev\nOutro."},
|
||||
{"TableCellsKept", "Before.\n\n| env | required |\n|---|---|\n| FOO | yes |\n\nAfter.", "Before.\nenv\nrequired\nFOO\nyes\nAfter."},
|
||||
{"HtmlInnerTextKept", "<p>Important: needs GPU.</p>", "Important: needs GPU."},
|
||||
{
|
||||
// Markdown nested inside a block-level HTML wrapper must still be
|
||||
// parsed (CommonMark terminates the HTML block at the blank line):
|
||||
// nav links collapse to text, badges drop. Regresses the gomarkdown
|
||||
// behavior that leaked raw badge markdown with URLs.
|
||||
"MarkdownInsideHtmlBlock",
|
||||
"<div align=\"center\">\n <img src=\"logo.png\" alt=\"Logo\">\n</div>\n\n" +
|
||||
"[Docs](https://x.com/docs) | [Why](https://x.com/why)\n\n" +
|
||||
"[](https://x.com)\n\nReal prose.",
|
||||
"Docs | Why\nReal prose.",
|
||||
},
|
||||
{"ScriptDropped", "Before.\n\n<script>alert('x')</script>\n\nAfter.", "Before.\nAfter."},
|
||||
// An empty-body <script src=...> must not leave the skip armed and eat the
|
||||
// next text run (guards the skipNextText reset on non-text tokens).
|
||||
{"ScriptSrcEmptyBody", "Before.\n\n<script src=\"x.js\"></script>\n\nAfter.", "Before.\nAfter."},
|
||||
{"StyleDropped", "Before.\n\n<style>.x{color:red}</style>\n\nAfter.", "Before.\nAfter."},
|
||||
// A bare </script> in prose must not underflow the skip and swallow what
|
||||
// follows.
|
||||
{"BareScriptCloseNoUnderflow", "Before.\n\n</script>\n\nAfter.", "Before.\nAfter."},
|
||||
// An unterminated raw-text element is, per the HTML spec, a single run to
|
||||
// EOF, so the remainder is unavoidably consumed; it must not error.
|
||||
{"UnterminatedScriptEatsRest", "Intro.\n\n<script>\nvar x = 1;\n\nMore prose.", "Intro."},
|
||||
{"EmphasisAndCodeSpanFlattened", "Run `make` for **speed**.", "Run make for speed."},
|
||||
{"HeadingParagraphOrder", "# Title\n\nLead.\n\n## Prereq\n\nDetail.", "Title\nLead.\nPrereq\nDetail."},
|
||||
// Straight ASCII punctuation must stay ASCII (goldmark applies no
|
||||
// Typographer), and existing smart punctuation passes through unchanged.
|
||||
// The smart characters are \u-escaped so the docs linter does not rewrite
|
||||
// them back to ASCII in source.
|
||||
{"PunctuationNotRewritten", "Range 10\u201420, \"q\", ... and smart \u201cq\u201d \u2014 \u2026", "Range 10\u201420, \"q\", ... and smart \u201cq\u201d \u2014 \u2026"},
|
||||
{"EmptyReturnsEmpty", "", ""},
|
||||
{"WhitespaceReturnsEmpty", " \n\t\n", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := render.InnerTextFromMarkdown(tt.input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ import (
|
||||
|
||||
const (
|
||||
listTemplatesPageSize = 10
|
||||
// ListTemplatesReadmeExcerptMaxRunes bounds the README excerpt surfaced
|
||||
// per template by list_templates.
|
||||
ListTemplatesReadmeExcerptMaxRunes = 1000
|
||||
|
||||
// Minimum active developers before organization popularity alone is a
|
||||
// confident recommendation.
|
||||
@@ -120,6 +123,8 @@ func ListTemplates(db database.Store, organizationID uuid.UUID, options ListTemp
|
||||
"list_templates",
|
||||
"List workspace templates as a ranked shortlist, optionally filtered "+
|
||||
"by a query matching template name, display name, or description. "+
|
||||
"Each result includes a short README excerpt for routing context; "+
|
||||
"call read_template for the full README and parameters. "+
|
||||
"Follow the "+NextStepField+" field in the result. Returns 10 per "+
|
||||
"page; fetch next_page only when no listed template fits the request.",
|
||||
func(ctx context.Context, args listTemplatesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
@@ -192,7 +197,16 @@ func ListTemplates(db database.Store, organizationID uuid.UUID, options ListTemp
|
||||
|
||||
items := make([]map[string]any, 0, end-start)
|
||||
for _, t := range ranked[start:end] {
|
||||
items = append(items, templateItem(t))
|
||||
item := templateItem(t)
|
||||
// Per-template README fetch: the batched query needs ResourceSystem
|
||||
// and would drop excerpts for non-owners, so accept an N+1 bounded
|
||||
// by the page size.
|
||||
if version, vErr := db.GetTemplateVersionByID(ctx, t.Template.ActiveVersionID); vErr == nil {
|
||||
if excerpt := readmeText(version.Readme, ListTemplatesReadmeExcerptMaxRunes); excerpt != "" {
|
||||
item["readme_excerpt"] = excerpt
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
|
||||
@@ -4,16 +4,22 @@ 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"
|
||||
@@ -893,6 +899,119 @@ func TestTemplateAllowlistEnforcement(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
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(),
|
||||
})
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/render"
|
||||
coderstrings "github.com/coder/coder/v2/coderd/util/strings"
|
||||
)
|
||||
|
||||
// readmeInputMaxBytes caps how many README bytes are parsed so a giant README
|
||||
// can't OOM coderd. It sits well above the output rune cap, so it never trims a
|
||||
// real excerpt.
|
||||
const readmeInputMaxBytes = 64 * 1024
|
||||
|
||||
// readmeText returns the README as bounded, frontmatter-stripped plain text
|
||||
// truncated to maxRunes, or "" when the README is blank or conversion fails.
|
||||
func readmeText(readme string, maxRunes int) string {
|
||||
// Cap the parse input first (see readmeInputMaxBytes); goldmark and the
|
||||
// tokenizer tolerate a mid-line or mid-rune cut.
|
||||
bounded := readme[:min(len(readme), readmeInputMaxBytes)]
|
||||
text, err := render.InnerTextFromMarkdown(stripReadmeFrontmatter(bounded))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return coderstrings.Truncate(text, maxRunes, coderstrings.TruncateWithEllipsis)
|
||||
}
|
||||
|
||||
// stripReadmeFrontmatter strips a leading frontmatter block from the README if it
|
||||
// exists. An unterminated frontmatter block is treated as a regular body section.
|
||||
// UTF-8 BOMs are stripped if present, and CRLF is normalized to LF. Leading
|
||||
// whitespace is also stripped.
|
||||
func stripReadmeFrontmatter(readme string) string {
|
||||
trimmed := strings.TrimLeftFunc(readme, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || r == '\ufeff'
|
||||
})
|
||||
|
||||
var out strings.Builder
|
||||
scn := bufio.NewScanner(strings.NewReader(trimmed))
|
||||
scn.Buffer(nil, readmeInputMaxBytes+1) // headroom
|
||||
var lineNumber int
|
||||
var fences int
|
||||
for scn.Scan() {
|
||||
line := scn.Text()
|
||||
lineNumber++
|
||||
|
||||
// Only handle fences if we haven't already found an
|
||||
// opening and closing fence.
|
||||
if fences < 2 {
|
||||
isFence := strings.TrimRight(line, " \t\r") == "---"
|
||||
if isFence {
|
||||
fences++
|
||||
continue
|
||||
} else if lineNumber == 1 {
|
||||
// No leading fence -> no frontmatter. Return the entire document.
|
||||
return trimmed
|
||||
}
|
||||
// We are still in the frontmatter block. Skip writing this line.
|
||||
continue
|
||||
}
|
||||
_, _ = out.WriteString(line)
|
||||
_, _ = out.WriteString("\n")
|
||||
}
|
||||
|
||||
// Can err if input is too big. Shouldn't happen normally.
|
||||
if scn.Err() != nil {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// Scanner did not scan any lines. No frontmatter to strip.
|
||||
if lineNumber == 0 {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// If we are still fenced but we reached the end of the document
|
||||
// we have an unterminated fence.
|
||||
if fences == 1 {
|
||||
return trimmed
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReadmeText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const maxRunes = 1000
|
||||
|
||||
t.Run("StripsFrontmatterThenRenders", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := readmeText("---\nkey: val\n---\n\n# Title\n\nBody prose.\n", maxRunes)
|
||||
require.Equal(t, "Title\nBody prose.", got)
|
||||
})
|
||||
|
||||
t.Run("BlankReturnsEmpty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, "", readmeText(" \n\t\n", maxRunes))
|
||||
require.Equal(t, "", readmeText("---\nkey: val\n---\n", maxRunes)) // frontmatter only
|
||||
})
|
||||
|
||||
t.Run("OverCapAddsEllipsis", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := readmeText(strings.Repeat("a", maxRunes+50), maxRunes)
|
||||
gotRunes := []rune(got)
|
||||
require.Len(t, gotRunes, maxRunes)
|
||||
require.Equal(t, '…', gotRunes[len(gotRunes)-1])
|
||||
})
|
||||
|
||||
t.Run("CapsParseInput", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A rune budget far larger than the input makes the byte cap the only
|
||||
// thing bounding the output, so this fails if readmeText stops capping the
|
||||
// parse input.
|
||||
huge := strings.Repeat("word ", 40_000) // ~200KiB of prose
|
||||
got := readmeText(huge, 10_000_000)
|
||||
require.NotEmpty(t, got)
|
||||
require.LessOrEqual(t, len(got), readmeInputMaxBytes)
|
||||
})
|
||||
|
||||
t.Run("RawTextElementSpanningCutRendersEmpty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A <style> that opens before the cap and closes after it is left
|
||||
// unterminated by truncation, so its raw-text run swallows the document and
|
||||
// the excerpt is empty. The same content under the cap renders fine, which
|
||||
// isolates the mid-cut as the cause.
|
||||
css := strings.Repeat(" .x { color: red; }\n", readmeInputMaxBytes/10) // > cap
|
||||
require.Equal(t, "", readmeText("<style>\n"+css+"</style>\n\nReal prose.\n", maxRunes))
|
||||
require.Equal(t, "Real prose.", readmeText("<style>\n.x{}\n</style>\n\nReal prose.\n", maxRunes))
|
||||
})
|
||||
}
|
||||
|
||||
func TestStripReadmeFrontmatter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "LeadingFenceStripped",
|
||||
input: "---\ndisplay_name: Foo\ntags: [a, b]\n---\n# Title\n\nBody.\n",
|
||||
want: "# Title\n\nBody.\n",
|
||||
},
|
||||
{
|
||||
name: "NoFence",
|
||||
input: "\n# Title\n\nNo frontmatter here.\n",
|
||||
want: "# Title\n\nNo frontmatter here.\n",
|
||||
},
|
||||
{
|
||||
name: "FrontmatterOnlyLeavesBlank",
|
||||
input: "---\ndisplay_name: Foo\n---\n",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "LeadingBlankLinesBeforeFence",
|
||||
input: "\n\n---\nkey: val\n---\nBody.\n",
|
||||
want: "Body.\n",
|
||||
},
|
||||
{
|
||||
name: "CRLFFence",
|
||||
input: "---\r\nkey: val\r\n---\r\n# Title\r\n",
|
||||
want: "# Title\n",
|
||||
},
|
||||
{
|
||||
name: "BOMPrefix",
|
||||
input: "\ufeff---\nkey: val\n---\nBody.\n",
|
||||
want: "Body.\n",
|
||||
},
|
||||
{
|
||||
name: "FenceWithTrailingWhitespace",
|
||||
input: "--- \nkey: val\n---\t\nBody.\n",
|
||||
want: "Body.\n",
|
||||
},
|
||||
{
|
||||
name: "UnterminatedFenceUnchanged",
|
||||
input: "---\nkey: val\nno closing fence\n",
|
||||
want: "---\nkey: val\nno closing fence\n",
|
||||
},
|
||||
{
|
||||
name: "BOMWithoutFenceStripsBOM",
|
||||
input: "\ufeff# Title\n\nNo frontmatter here.\n",
|
||||
want: "# Title\n\nNo frontmatter here.\n",
|
||||
},
|
||||
{
|
||||
name: "BOMWithUnterminatedFenceStripsBOM",
|
||||
input: "\ufeff---\nkey: val\nno closing fence\n",
|
||||
want: "---\nkey: val\nno closing fence\n",
|
||||
},
|
||||
{
|
||||
name: "HorizontalRuleNotFence",
|
||||
input: "# Title\n\n---\n\nMore.\n",
|
||||
want: "# Title\n\n---\n\nMore.\n",
|
||||
},
|
||||
{
|
||||
// Only the leading block is frontmatter. A "---" thematic break in
|
||||
// the body must survive verbatim, not re-open a fence.
|
||||
name: "FrontmatterThenThematicBreakInBody",
|
||||
input: "---\ntitle: Foo\n---\n# Heading\n\n---\n\nSecond section.\n",
|
||||
want: "# Heading\n\n---\n\nSecond section.\n",
|
||||
},
|
||||
{
|
||||
// Multiple body breaks: every "---" after the closing fence is body
|
||||
// content. An even count must not silently eat the sections between.
|
||||
name: "FrontmatterThenMultipleThematicBreaks",
|
||||
input: "---\ntitle: Foo\n---\nIntro\n\n---\n\nMid\n\n---\n\nEnd\n",
|
||||
want: "Intro\n\n---\n\nMid\n\n---\n\nEnd\n",
|
||||
},
|
||||
{
|
||||
// An indented "---" is not a YAML document separator (must be at
|
||||
// column 0), so it stays inside the frontmatter block.
|
||||
name: "IndentedFenceInFrontmatterNotClosing",
|
||||
input: "---\nkey: val\n ---\nstill: fm\n---\nBody.\n",
|
||||
want: "Body.\n",
|
||||
},
|
||||
{
|
||||
// Final body line without a trailing newline is still emitted and
|
||||
// newline-terminated.
|
||||
name: "BodyWithoutTrailingNewline",
|
||||
input: "---\nk: v\n---\nBody no newline",
|
||||
want: "Body no newline\n",
|
||||
},
|
||||
{
|
||||
name: "WhitespaceOnlyReturnsEmpty",
|
||||
input: " \n\t\n",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "EmptyReturnsEmpty",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
// Leading indentation marks a Markdown code block. It must survive
|
||||
// verbatim; per-line trimming would turn the block into a paragraph.
|
||||
name: "IndentedCodeBlockPreserved",
|
||||
input: "---\ntitle: Foo\n---\n# Heading\n\n indented := code\n more := code\n",
|
||||
want: "# Heading\n\n indented := code\n more := code\n",
|
||||
},
|
||||
{
|
||||
// Tabs and spaces inside a fenced code block are content, not
|
||||
// stray whitespace, and must not be stripped.
|
||||
name: "FencedCodeBlockPreservesIndentation",
|
||||
input: "---\ntitle: Foo\n---\n```go\nfunc main() {\n\tprintln(\"hi\")\n}\n```\n",
|
||||
want: "```go\nfunc main() {\n\tprintln(\"hi\")\n}\n```\n",
|
||||
},
|
||||
{
|
||||
// A "---" inside a body code block is literal content, not a fence,
|
||||
// once the leading frontmatter block has closed.
|
||||
name: "ThematicBreakInsideBodyCodeBlock",
|
||||
input: "---\ntitle: Foo\n---\n```\n---\n```\nAfter.\n",
|
||||
want: "```\n---\n```\nAfter.\n",
|
||||
},
|
||||
{
|
||||
// Table pipes and the dashed alignment row pass through unchanged;
|
||||
// the alignment row is not a frontmatter fence.
|
||||
name: "TablePreserved",
|
||||
input: "---\ntitle: Foo\n---\n| Col A | Col B |\n| ----- | ----- |\n| 1 | 2 |\n",
|
||||
want: "| Col A | Col B |\n| ----- | ----- |\n| 1 | 2 |\n",
|
||||
},
|
||||
{
|
||||
name: "Oversized",
|
||||
input: strings.Repeat("x", readmeInputMaxBytes+2),
|
||||
want: strings.Repeat("x", readmeInputMaxBytes+2),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := stripReadmeFrontmatter(tc.input)
|
||||
if diff := cmp.Diff(tc.want, got); diff != "" {
|
||||
t.Errorf("unexpected diff: %s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
// ReadTemplateReadmeMaxRunes bounds the full README returned by read_template
|
||||
// so one large README cannot dominate a single tool response.
|
||||
const ReadTemplateReadmeMaxRunes = 8000
|
||||
|
||||
// ReadTemplateOptions configures the read_template tool.
|
||||
type ReadTemplateOptions struct {
|
||||
OwnerID uuid.UUID
|
||||
@@ -31,9 +35,9 @@ func ReadTemplate(db database.Store, organizationID uuid.UUID, options ReadTempl
|
||||
return fantasy.NewAgentTool(
|
||||
"read_template",
|
||||
"Get details about a workspace template, including its "+
|
||||
"configurable parameters and available presets. Use this "+
|
||||
"after list_templates when you need required parameter "+
|
||||
"details or preset IDs before create_workspace.",
|
||||
"configurable parameters, available presets, and the active "+
|
||||
"version README. Use this after list_templates when you need "+
|
||||
"parameter details, preset IDs, or the README before create_workspace.",
|
||||
func(ctx context.Context, args readTemplateArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
templateIDStr := strings.TrimSpace(args.TemplateID)
|
||||
if templateIDStr == "" {
|
||||
@@ -89,6 +93,13 @@ func ReadTemplate(db database.Store, organizationID uuid.UUID, options ReadTempl
|
||||
if desc := strings.TrimSpace(template.Description); desc != "" {
|
||||
templateInfo["description"] = desc
|
||||
}
|
||||
// Best-effort: a missing or unreadable version must not fail
|
||||
// read_template.
|
||||
if version, err := db.GetTemplateVersionByID(ctx, template.ActiveVersionID); err == nil {
|
||||
if r := readmeText(version.Readme, ReadTemplateReadmeMaxRunes); r != "" {
|
||||
templateInfo["readme"] = r
|
||||
}
|
||||
}
|
||||
|
||||
paramList := make([]map[string]any, 0, len(params))
|
||||
for _, p := range params {
|
||||
|
||||
@@ -3,9 +3,11 @@ 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"
|
||||
@@ -181,3 +183,106 @@ func TestReadTemplate_NoPresets(t *testing.T) {
|
||||
_, 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\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")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user