From 87d580d3fe10880c685a94252b7dc747d59755ef Mon Sep 17 00:00:00 2001 From: Max Schwenk Date: Thu, 7 May 2026 12:10:50 -0400 Subject: [PATCH] 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. --- coderd/taskname/taskname.go | 29 +++++++++++------------ coderd/taskname/taskname_internal_test.go | 27 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/coderd/taskname/taskname.go b/coderd/taskname/taskname.go index 3351a288cf..c1382c0c62 100644 --- a/coderd/taskname/taskname.go +++ b/coderd/taskname/taskname.go @@ -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 } diff --git a/coderd/taskname/taskname_internal_test.go b/coderd/taskname/taskname_internal_test.go index eff0b30de6..b6c977a6be 100644 --- a/coderd/taskname/taskname_internal_test.go +++ b/coderd/taskname/taskname_internal_test.go @@ -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 {