Files
Shawn 075387c990 fix(markdown): stop StripMarkdown corrupting snake_case and dunder identifiers (#902)
StripMarkdown is used to flatten Markdown to plain text for platforms
without Markdown support (LINE, WeCom) and for TTS synthesis. It
already covered `**bold**` / `*italic*` / `~~strike~~` / `[text](url)`
/ headings / fenced code, plus the underscore variants `__bold__` and
`_italic_`. The two underscore patterns are dangerous:

  reBoldUnd:   `__(.+?)__`
  reItalicUnd: `_(.+?)_`

Both happily match identifier fragments. With these enabled,
StripMarkdown silently rewrites:

  call my_func_name() to start  ->  call myfuncname() to start
  use module_name_var here      ->  use modulenamevar here
  Python's __init__ method      ->  Python's init method
  the __version__ string        ->  the version string

i.e. exactly the things an agent writes whenever it discusses code.
The corrupted text then ships out to LINE / WeCom messages and TTS
audio, where the underscores are gone and the user can no longer
copy-paste or pronounce the identifier correctly.

Go's regexp engine has no lookbehind, so the underscore form can't be
narrowed to "not adjacent to a word char" with a one-line regex
change. The dunder case (`__init__` surrounded by spaces) is also
shape-identical to `__bold__` and can't be distinguished structurally.

Real-world agent output overwhelmingly uses `**bold**` / `*italic*`,
so the underscore forms are mostly a hazard with little upside. Drop
them. Inputs containing literal `_italic_` now keep the underscores —
a small cosmetic loss compared to losing identifier integrity in TTS
or plain-text channels.

Add core/markdown_test.go (no test file existed) covering both the
preserved asterisk paths and the regression cases above so we don't
re-introduce the corruption.
2026-05-18 09:58:00 +08:00

47 lines
1.8 KiB
Go

package core
import "testing"
func TestStripMarkdown(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
// Asterisk forms keep working — these are what agents typically emit.
{"bold asterisk", "this is **bold** text", "this is bold text"},
{"italic asterisk", "this is *italic* text", "this is italic text"},
{"bold italic combined", "**bold** and *italic*", "bold and italic"},
// Strikethrough, links, headings, code — unchanged behavior.
{"strikethrough", "~~struck~~", "struck"},
{"link", "[GitHub](https://github.com)", "GitHub (https://github.com)"},
{"heading", "# Title\nbody", "Title\nbody"},
{"inline code", "use `os.path.join`", "use os.path.join"},
{"fenced code block", "```go\nfmt.Println(\"hi\")\n```", "fmt.Println(\"hi\")"},
// Regression: underscore forms must NOT be stripped, since the same
// patterns appear in legitimate identifiers. Stripping them used to
// produce e.g. "mysnakecase_var" and "init", corrupting code
// references in TTS / LINE / WeChat output.
{"snake_case identifier", "call my_func_name() to start", "call my_func_name() to start"},
{"two-underscore snake_case", "use module_name_var here", "use module_name_var here"},
{"python dunder", "Python's __init__ method", "Python's __init__ method"},
{"version dunder", "the __version__ string", "the __version__ string"},
{"single underscore identifier", "function_name", "function_name"},
// Underscore-form bold/italic is now kept literal (rare in practice).
{"underscore italic kept literal", "_italic_ text", "_italic_ text"},
{"underscore bold kept literal", "__bold__ text", "__bold__ text"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := StripMarkdown(tt.in)
if got != tt.want {
t.Errorf("StripMarkdown(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}