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 {