From 2d28c1b396f62623aecc90eeb1318d3146a08bd4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 24 Jun 2026 12:32:46 +0100 Subject: [PATCH] feat: surface template README to agent template tools (#26334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- coderd/render/markdown.go | 66 ++++++ coderd/render/markdown_test.go | 56 +++++ coderd/x/chatd/chattool/listtemplates.go | 16 +- coderd/x/chatd/chattool/listtemplates_test.go | 119 ++++++++++ coderd/x/chatd/chattool/readme.go | 82 +++++++ .../x/chatd/chattool/readme_internal_test.go | 204 ++++++++++++++++++ coderd/x/chatd/chattool/readtemplate.go | 17 +- coderd/x/chatd/chattool/readtemplate_test.go | 105 +++++++++ docs/ai-coder/agents/getting-started.md | 15 +- .../template-optimization.md | 38 ++++ go.mod | 2 +- 11 files changed, 712 insertions(+), 8 deletions(-) create mode 100644 coderd/x/chatd/chattool/readme.go create mode 100644 coderd/x/chatd/chattool/readme_internal_test.go diff --git a/coderd/render/markdown.go b/coderd/render/markdown.go index 75e6d8d1c1..ed0c16bc84 100644 --- a/coderd/render/markdown.go +++ b/coderd/render/markdown.go @@ -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 + // 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 +} diff --git a/coderd/render/markdown_test.go b/coderd/render/markdown_test.go index 4095cac3f0..7720280292 100644 --- a/coderd/render/markdown_test.go +++ b/coderd/render/markdown_test.go @@ -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![alt](a.svg)\n\nBody.", "T\nBody."}, + {"BadgeDropped", "[![discord](shield.png)](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", "

Important: needs GPU.

", "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", + "
\n \"Logo\"\n
\n\n" + + "[Docs](https://x.com/docs) | [Why](https://x.com/why)\n\n" + + "[![badge](https://img.shields.io/x.svg)](https://x.com)\n\nReal prose.", + "Docs | Why\nReal prose.", + }, + {"ScriptDropped", "Before.\n\n\n\nAfter.", "Before.\nAfter."}, + // An empty-body \n\nAfter.", "Before.\nAfter."}, + {"StyleDropped", "Before.\n\n\n\nAfter.", "Before.\nAfter."}, + // A bare in prose must not underflow the skip and swallow what + // follows. + {"BareScriptCloseNoUnderflow", "Before.\n\n\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