fix(coderd/taskname): parse task name JSON with trailing text (#25005)

Anthropic task name responses can include valid JSON followed by a
closing fence or extra text, which made `json.Unmarshal` fail with
trailing-character errors and forced fallback naming.

This updates task name JSON extraction to accept the first JSON value
after optional fences and adds regression coverage for fenced and bare
JSON with trailing content.
This commit is contained in:
Max Schwenk
2026-05-07 12:10:50 -04:00
committed by GitHub
parent e1b1c7ec5b
commit 87d580d3fe
2 changed files with 41 additions and 15 deletions
+14 -15
View File
@@ -94,26 +94,25 @@ Do not include any additional keys, explanations, or text outside the JSON.`
var (
ErrNoAPIKey = xerrors.New("no api key provided")
ErrNoNameGenerated = xerrors.New("no task name generated")
markdownCodeFenceRE = regexp.MustCompile("(?s)^```[^\n]*\n(.*?)(?:\n```.*|```\\s*)?$")
)
// extractJSON strips optional markdown code fences (```json or
// ```) that LLMs sometimes wrap around JSON output, returning
// only the inner JSON string. Only well-formed fences with a
// newline after the opening backticks are stripped; malformed
// fences are left untouched so that json.Unmarshal fails
// cleanly and the caller can fall back to other strategies.
// extractJSON strips optional markdown code fences (```json or ```) that
// LLMs sometimes wrap around JSON output, returning only the inner JSON
// string. If the response starts with JSON, it returns the first JSON value so
// trailing commentary or dangling fences do not break parsing.
func extractJSON(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
// Only strip when there is a newline separating the
// fence line from the body. Without one we cannot
// reliably tell the fence from the content.
if idx := strings.Index(s, "\n"); idx != -1 {
s = s[idx+1:]
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
}
if matches := markdownCodeFenceRE.FindStringSubmatch(s); matches != nil {
s = strings.TrimSpace(matches[1])
}
var raw json.RawMessage
if err := json.NewDecoder(strings.NewReader(s)).Decode(&raw); err == nil {
return string(raw)
}
return s
}
+27
View File
@@ -156,6 +156,21 @@ func TestExtractJSON(t *testing.T) {
input: "```json\n{\n \"display_name\": \"Fix bug\",\n \"task_name\": \"fix-bug\"\n}\n```",
expected: "{\n \"display_name\": \"Fix bug\",\n \"task_name\": \"fix-bug\"\n}",
},
{
name: "FencedJSONWithTrailingText",
input: "```json\n{\"display_name\": \"Fix bug\", \"task_name\": \"fix-bug\"}\n```\n\nDone.",
expected: `{"display_name": "Fix bug", "task_name": "fix-bug"}`,
},
{
name: "BareJSONWithTrailingFence",
input: "{\"display_name\": \"Fix bug\", \"task_name\": \"fix-bug\"}\n```",
expected: `{"display_name": "Fix bug", "task_name": "fix-bug"}`,
},
{
name: "BareJSONWithTrailingText",
input: "{\"display_name\": \"Fix bug\", \"task_name\": \"fix-bug\"}\n\nDone.",
expected: `{"display_name": "Fix bug", "task_name": "fix-bug"}`,
},
{
name: "FencedNoNewlinePassthrough",
input: "```json{\"display_name\": \"Fix bug\", \"task_name\": \"fix-bug\"}```",
@@ -235,6 +250,18 @@ func TestGenerateFromAnthropicMock(t *testing.T) {
expectedDisplayName: "Setup CI",
expectedNamePrefix: "setup-ci-",
},
{
name: "FencedJSONWithTrailingText",
responseText: "```json\n{\"display_name\": \"Debug auth\", \"task_name\": \"debug-auth\"}\n```\n\nDone.",
expectedDisplayName: "Debug auth",
expectedNamePrefix: "debug-auth-",
},
{
name: "BareJSONWithTrailingFence",
responseText: "{\"display_name\": \"Setup CI\", \"task_name\": \"setup-ci\"}\n```",
expectedDisplayName: "Setup CI",
expectedNamePrefix: "setup-ci-",
},
}
for _, tc := range tests {