feat: add display name field for tasks (#20856)

## Problem

Tasks currently only expose a machine-friendly name field (e.g.
`task-python-debug-a1b2`), but this value is primarily an identifier
rather than a clean, descriptive label. We need a separate
display-friendly name for use in the UI.

This PR introduces a new `display_name` field and updates the task-name
generation flow. The Claude system prompt was updated to return valid
JSON with both `name` and `display_name`. The name generation logic
follows a fallback chain (Anthropic > prompt sanitization > random
fallback). To make task names more closely resemble their display names,
the legacy `task-` prefix has been removed. For context, PR
https://github.com/coder/coder/pull/20834 introduced a small Task icon
to the workspace list to help identify workspaces associated to tasks.

## Changes

- Database migration: Added `display_name` column to tasks table
- Updated system prompt to generate both task name and display name as
valid JSON
- Task name generation now follows a fallback chain: Anthropic > prompt
sanitization > random fallback
- Removed `task-` prefix from task names to allow more descriptive names
- Note: PR https://github.com/coder/coder/pull/20834 adds a Task icon to
workspaces in the workspace list to distinguish task-created workspaces

**Note:** UI changes will be addressed in a follow-up PR

Related to: https://github.com/coder/coder/issues/20801
This commit is contained in:
Susana Ferreira
2025-11-25 13:00:59 +00:00
committed by GitHub
parent e8bf074022
commit 3011207519
23 changed files with 780 additions and 124 deletions
+202 -61
View File
@@ -2,39 +2,82 @@ package taskname
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand/v2"
"os"
"regexp"
"strings"
"cdr.dev/slog"
"github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
"github.com/moby/moby/pkg/namesgenerator"
"golang.org/x/xerrors"
"github.com/coder/aisdk-go"
strutil "github.com/coder/coder/v2/coderd/util/strings"
"github.com/coder/coder/v2/codersdk"
)
const (
defaultModel = anthropic.ModelClaude3_5HaikuLatest
systemPrompt = `Generate a short workspace name from this AI task prompt.
systemPrompt = `Generate a short task display name and name from this AI task prompt.
Identify the main task (the core action and subject) and base both names on it.
The task display name and name should be as similar as possible so a human can easily associate them.
Requirements:
Requirements for task display name (generate this first):
- Human-readable description
- Maximum 64 characters total
- Should concisely describe the main task
Requirements for task name:
- Should be derived from the display name
- Only lowercase letters, numbers, and hyphens
- Start with "task-"
- No spaces or underscores
- Maximum 27 characters total
- Descriptive of the main task
- Should concisely describe the main task
Output format (must be valid JSON):
{
"display_name": "<display_name>",
"task_name": "<task_name>"
}
Examples:
- "Help me debug a Python script" → "task-python-debug"
- "Create a React dashboard component" → "task-react-dashboard"
- "Analyze sales data from Q3" → "task-analyze-q3-sales"
- "Set up CI/CD pipeline" → "task-setup-cicd"
Prompt: "Help me debug a Python script" →
{
"display_name": "Debug Python script",
"task_name": "python-debug"
}
If you cannot create a suitable name:
- Respond with "task-unnamed"`
Prompt: "Create a React dashboard component" →
{
"display_name": "React dashboard component",
"task_name": "react-dashboard"
}
Prompt: "Analyze sales data from Q3" →
{
"display_name": "Analyze Q3 sales data",
"task_name": "analyze-q3-sales"
}
Prompt: "Set up CI/CD pipeline" →
{
"display_name": "CI/CD pipeline setup",
"task_name": "setup-cicd"
}
If a suitable name cannot be created, output exactly:
{
"display_name": "Task Unnamed",
"task_name": "task-unnamed"
}
Do not include any additional keys, explanations, or text outside the JSON.`
)
var (
@@ -42,30 +85,16 @@ var (
ErrNoNameGenerated = xerrors.New("no task name generated")
)
type options struct {
apiKey string
model anthropic.Model
type TaskName struct {
Name string `json:"task_name"`
DisplayName string `json:"display_name"`
}
type Option func(o *options)
func WithAPIKey(apiKey string) Option {
return func(o *options) {
o.apiKey = apiKey
}
}
func WithModel(model anthropic.Model) Option {
return func(o *options) {
o.model = model
}
}
func GetAnthropicAPIKeyFromEnv() string {
func getAnthropicAPIKeyFromEnv() string {
return os.Getenv("ANTHROPIC_API_KEY")
}
func GetAnthropicModelFromEnv() anthropic.Model {
func getAnthropicModelFromEnv() anthropic.Model {
return anthropic.Model(os.Getenv("ANTHROPIC_MODEL"))
}
@@ -79,33 +108,85 @@ func generateSuffix() string {
return fmt.Sprintf("%04x", num)
}
func GenerateFallback() string {
// generateFallback generates a random task name when other methods fail.
// Uses Docker-style name generation with a collision-resistant suffix.
func generateFallback() TaskName {
// We have a 32 character limit for the name.
// We have a 5 character prefix `task-`.
// We have a 5 character suffix `-ffff`.
// This leaves us with 22 characters for the middle.
// This leaves us with 27 characters for the name.
//
// Unfortunately, `namesgenerator.GetRandomName(0)` will
// generate names that are longer than 22 characters, so
// we just trim these down to length.
// `namesgenerator.GetRandomName(0)` can generate names
// up to 27 characters, but we truncate defensively.
name := strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-")
name = name[:min(len(name), 22)]
name = name[:min(len(name), 27)]
name = strings.TrimSuffix(name, "-")
return fmt.Sprintf("task-%s-%s", name, generateSuffix())
taskName := fmt.Sprintf("%s-%s", name, generateSuffix())
displayName := strings.ReplaceAll(name, "-", " ")
if len(displayName) > 0 {
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
}
return TaskName{
Name: taskName,
DisplayName: displayName,
}
}
func Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
o := options{}
for _, opt := range opts {
opt(&o)
// generateFromPrompt creates a task name directly from the prompt by sanitizing it.
// This is used as a fallback when Claude fails to generate a name.
func generateFromPrompt(prompt string) (TaskName, error) {
// Normalize newlines and tabs to spaces
prompt = regexp.MustCompile(`[\n\r\t]+`).ReplaceAllString(prompt, " ")
// Truncate prompt to 27 chars with full words for task name generation
truncatedForName := prompt
if len(prompt) > 27 {
truncatedForName = strutil.Truncate(prompt, 27, strutil.TruncateWithFullWords)
}
if o.model == "" {
o.model = defaultModel
// Generate task name from truncated prompt
name := strings.ToLower(truncatedForName)
// Replace whitespace (\t \r \n and spaces) sequences with hyphens
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, "-")
// Remove all characters except lowercase letters, numbers, and hyphens
name = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(name, "")
// Collapse multiple consecutive hyphens into a single hyphen
name = regexp.MustCompile(`-+`).ReplaceAllString(name, "-")
// Remove leading and trailing hyphens
name = strings.Trim(name, "-")
if len(name) == 0 {
return TaskName{}, ErrNoNameGenerated
}
if o.apiKey == "" {
return "", ErrNoAPIKey
taskName := fmt.Sprintf("%s-%s", name, generateSuffix())
// Use the initial prompt as display name, truncated to 64 chars with full words
displayName := strutil.Truncate(prompt, 64, strutil.TruncateWithFullWords, strutil.TruncateWithEllipsis)
displayName = strings.TrimSpace(displayName)
if len(displayName) == 0 {
// Ensure display name is never empty
displayName = strings.ReplaceAll(name, "-", " ")
}
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
return TaskName{
Name: taskName,
DisplayName: displayName,
}, nil
}
// generateFromAnthropic uses Claude (Anthropic) to generate semantic task and display names from a user prompt.
// It sends the prompt to Claude with a structured system prompt requesting JSON output containing both names.
// Returns an error if the API call fails, the response is invalid, or Claude returns an "unnamed" placeholder.
func generateFromAnthropic(ctx context.Context, prompt string, apiKey string, model anthropic.Model) (TaskName, error) {
anthropicModel := model
if anthropicModel == "" {
anthropicModel = defaultModel
}
if apiKey == "" {
return TaskName{}, ErrNoAPIKey
}
conversation := []aisdk.Message{
@@ -126,42 +207,95 @@ func Generate(ctx context.Context, prompt string, opts ...Option) (string, error
}
anthropicOptions := anthropic.DefaultClientOptions()
anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(o.apiKey))
anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(apiKey))
anthropicClient := anthropic.NewClient(anthropicOptions...)
stream, err := anthropicDataStream(ctx, anthropicClient, o.model, conversation)
stream, err := anthropicDataStream(ctx, anthropicClient, anthropicModel, conversation)
if err != nil {
return "", xerrors.Errorf("create anthropic data stream: %w", err)
return TaskName{}, xerrors.Errorf("create anthropic data stream: %w", err)
}
var acc aisdk.DataStreamAccumulator
stream = stream.WithAccumulator(&acc)
if err := stream.Pipe(io.Discard); err != nil {
return "", xerrors.Errorf("pipe data stream")
return TaskName{}, xerrors.Errorf("pipe data stream")
}
if len(acc.Messages()) == 0 {
return "", ErrNoNameGenerated
return TaskName{}, ErrNoNameGenerated
}
taskName := acc.Messages()[0].Content
if taskName == "task-unnamed" {
return "", ErrNoNameGenerated
// Parse the JSON response
var taskNameResponse TaskName
if err := json.Unmarshal([]byte(acc.Messages()[0].Content), &taskNameResponse); err != nil {
return TaskName{}, xerrors.Errorf("failed to parse anthropic response: %w", err)
}
taskNameResponse.Name = strings.TrimSpace(taskNameResponse.Name)
taskNameResponse.DisplayName = strings.TrimSpace(taskNameResponse.DisplayName)
if taskNameResponse.Name == "" || taskNameResponse.Name == "task-unnamed" {
return TaskName{}, xerrors.Errorf("anthropic returned invalid task name: %q", taskNameResponse.Name)
}
if taskNameResponse.DisplayName == "" || taskNameResponse.DisplayName == "Task Unnamed" {
return TaskName{}, xerrors.Errorf("anthropic returned invalid task display name: %q", taskNameResponse.DisplayName)
}
// We append a suffix to the end of the task name to reduce
// the chance of collisions. We truncate the task name to
// to a maximum of 27 bytes, so that when we append the
// a maximum of 27 bytes, so that when we append the
// 5 byte suffix (`-` and 4 byte hex slug), it should
// remain within the 32 byte workspace name limit.
taskName = taskName[:min(len(taskName), 27)]
taskName = fmt.Sprintf("%s-%s", taskName, generateSuffix())
if err := codersdk.NameValid(taskName); err != nil {
return "", xerrors.Errorf("generated name %v not valid: %w", taskName, err)
name := taskNameResponse.Name[:min(len(taskNameResponse.Name), 27)]
name = strings.TrimSuffix(name, "-")
name = fmt.Sprintf("%s-%s", name, generateSuffix())
if err := codersdk.NameValid(name); err != nil {
return TaskName{}, xerrors.Errorf("generated name %v not valid: %w", name, err)
}
return taskName, nil
displayName := taskNameResponse.DisplayName
displayName = strings.TrimSpace(displayName)
if len(displayName) == 0 {
// Ensure display name is never empty
displayName = strings.ReplaceAll(taskNameResponse.Name, "-", " ")
}
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
return TaskName{
Name: name,
DisplayName: displayName,
}, nil
}
// Generate creates a task name and display name from a user prompt.
// It attempts multiple strategies in order of preference:
// 1. Use Claude (Anthropic) to generate semantic names from the prompt if an API key is available
// 2. Sanitize the prompt directly into a valid task name
// 3. Generate a random name as a final fallback
//
// A suffix is always appended to task names to reduce collision risk.
// This function always succeeds and returns a valid TaskName.
func Generate(ctx context.Context, logger slog.Logger, prompt string) TaskName {
if anthropicAPIKey := getAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" {
taskName, err := generateFromAnthropic(ctx, prompt, anthropicAPIKey, getAnthropicModelFromEnv())
if err == nil {
return taskName
}
// Anthropic failed, fall through to next fallback
logger.Error(ctx, "unable to generate task name and display name from Anthropic", slog.Error(err))
}
// Try generating from prompt
taskName, err := generateFromPrompt(prompt)
if err == nil {
return taskName
}
logger.Warn(ctx, "unable to generate task name and display name from prompt", slog.Error(err))
// Final fallback
return generateFallback()
}
func anthropicDataStream(ctx context.Context, client anthropic.Client, model anthropic.Model, input []aisdk.Message) (aisdk.DataStream, error) {
@@ -171,8 +305,15 @@ func anthropicDataStream(ctx context.Context, client anthropic.Client, model ant
}
return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
Model: model,
MaxTokens: 24,
Model: model,
// MaxTokens is set to 100 based on the maximum expected output size.
// The worst-case JSON output is 134 characters:
// - Base structure: 43 chars (including formatting)
// - task_name: 27 chars max
// - display_name: 64 chars max
// Using Anthropic's token counting API, this worst-case output tokenizes to 70 tokens.
// We set MaxTokens to 100 to provide a safety buffer.
MaxTokens: 100,
System: system,
Messages: messages,
})), nil
+164
View File
@@ -0,0 +1,164 @@
package taskname
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestGenerateFallback(t *testing.T) {
t.Parallel()
taskName := generateFallback()
err := codersdk.NameValid(taskName.Name)
require.NoErrorf(t, err, "expected fallback to be valid workspace name, instead found %s", taskName.Name)
require.NotEmpty(t, taskName.DisplayName)
}
func TestGenerateFromPrompt(t *testing.T) {
t.Parallel()
tests := []struct {
name string
prompt string
expectError bool
expectedName string
expectedDisplayName string
}{
{
name: "EmptyPrompt",
prompt: "",
expectError: true,
},
{
name: "OnlySpaces",
prompt: " ",
expectError: true,
},
{
name: "OnlySpecialCharacters",
prompt: "!@#$%^&*()",
expectError: true,
},
{
name: "UppercasePrompt",
prompt: "BUILD MY APP",
expectError: false,
expectedName: "build-my-app",
expectedDisplayName: "BUILD MY APP",
},
{
name: "PromptWithApostrophes",
prompt: "fix user's dashboard",
expectError: false,
expectedName: "fix-users-dashboard",
expectedDisplayName: "Fix user's dashboard",
},
{
name: "LongPrompt",
prompt: strings.Repeat("a", 100),
expectError: false,
expectedName: strings.Repeat("a", 27),
expectedDisplayName: "A" + strings.Repeat("a", 62) + "…",
},
{
name: "PromptWithMultipleSpaces",
prompt: "build my app",
expectError: false,
expectedName: "build-my-app",
expectedDisplayName: "Build my app",
},
{
name: "PromptWithNewlines",
prompt: "build\nmy\napp",
expectError: false,
expectedName: "build-my-app",
expectedDisplayName: "Build my app",
},
{
name: "TruncatesLongPromptAtWordBoundary",
prompt: "implement real-time notifications dashboard",
expectError: false,
expectedName: "implement-real-time",
expectedDisplayName: "Implement real-time notifications dashboard",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
taskName, err := generateFromPrompt(tc.prompt)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
// Validate task name
require.Contains(t, taskName.Name, fmt.Sprintf("%s-", tc.expectedName))
require.NoError(t, codersdk.NameValid(taskName.Name))
// Validate task display name
require.NotEmpty(t, taskName.DisplayName)
require.Equal(t, tc.expectedDisplayName, taskName.DisplayName)
})
}
}
func TestGenerateFromAnthropic(t *testing.T) {
t.Parallel()
apiKey := getAnthropicAPIKeyFromEnv()
if apiKey == "" {
t.Skip("Skipping test as ANTHROPIC_API_KEY not set")
}
tests := []struct {
name string
prompt string
}{
{
name: "SimplePrompt",
prompt: "Create a finance planning app",
},
{
name: "TechnicalPrompt",
prompt: "Debug authentication middleware for OAuth2",
},
{
name: "ShortPrompt",
prompt: "Fix bug",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
taskName, err := generateFromAnthropic(ctx, tc.prompt, apiKey, getAnthropicModelFromEnv())
require.NoError(t, err)
t.Log("Task name:", taskName.Name)
t.Log("Task display name:", taskName.DisplayName)
// Validate task name
require.NotEmpty(t, taskName.DisplayName)
require.NoError(t, codersdk.NameValid(taskName.Name))
// Validate display name
require.NotEmpty(t, taskName.DisplayName)
require.NotEqual(t, "task-unnamed", taskName.Name)
require.NotEqual(t, "Task Unnamed", taskName.DisplayName)
})
}
}
+33 -24
View File
@@ -15,42 +15,51 @@ const (
anthropicEnvVar = "ANTHROPIC_API_KEY"
)
func TestGenerateFallback(t *testing.T) {
t.Parallel()
name := taskname.GenerateFallback()
err := codersdk.NameValid(name)
require.NoErrorf(t, err, "expected fallback to be valid workspace name, instead found %s", name)
}
func TestGenerateTaskName(t *testing.T) {
t.Parallel()
t.Run("Fallback", func(t *testing.T) {
t.Parallel()
func TestGenerate(t *testing.T) {
t.Run("FromPrompt", func(t *testing.T) {
// Ensure no API key in env for this test
t.Setenv("ANTHROPIC_API_KEY", "")
ctx := testutil.Context(t, testutil.WaitShort)
name, err := taskname.Generate(ctx, "Some random prompt")
require.ErrorIs(t, err, taskname.ErrNoAPIKey)
require.Equal(t, "", name)
taskName := taskname.Generate(ctx, testutil.Logger(t), "Create a finance planning app")
// Should succeed via prompt sanitization
require.NoError(t, codersdk.NameValid(taskName.Name))
require.Contains(t, taskName.Name, "create-a-finance-planning-")
require.NotEmpty(t, taskName.DisplayName)
require.Equal(t, "Create a finance planning app", taskName.DisplayName)
})
t.Run("Anthropic", func(t *testing.T) {
t.Parallel()
t.Run("FromAnthropic", func(t *testing.T) {
apiKey := os.Getenv(anthropicEnvVar)
if apiKey == "" {
t.Skipf("Skipping test as %s not set", anthropicEnvVar)
}
// Set API key for this test
t.Setenv("ANTHROPIC_API_KEY", apiKey)
ctx := testutil.Context(t, testutil.WaitShort)
name, err := taskname.Generate(ctx, "Create a finance planning app", taskname.WithAPIKey(apiKey))
require.NoError(t, err)
require.NotEqual(t, "", name)
taskName := taskname.Generate(ctx, testutil.Logger(t), "Create a finance planning app")
err = codersdk.NameValid(name)
require.NoError(t, err, "name should be valid")
// Should succeed with Claude-generated names
require.NoError(t, codersdk.NameValid(taskName.Name))
require.NotEmpty(t, taskName.DisplayName)
})
t.Run("Fallback", func(t *testing.T) {
// Ensure no API key
t.Setenv("ANTHROPIC_API_KEY", "")
ctx := testutil.Context(t, testutil.WaitShort)
// Use a prompt that can't be sanitized (only special chars)
taskName := taskname.Generate(ctx, testutil.Logger(t), "!@#$%^&*()")
// Should fall back to random name
require.NoError(t, codersdk.NameValid(taskName.Name))
require.NotEmpty(t, taskName.DisplayName)
})
}