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
+5 -3
View File
@@ -189,6 +189,7 @@ func Test_TaskStatus(t *testing.T) {
"owner_id": "00000000-0000-0000-0000-000000000000",
"owner_name": "me",
"name": "exists",
"display_name": "Task exists",
"template_id": "00000000-0000-0000-0000-000000000000",
"template_version_id": "00000000-0000-0000-0000-000000000000",
"template_name": "",
@@ -220,9 +221,10 @@ func Test_TaskStatus(t *testing.T) {
switch r.URL.Path {
case "/api/experimental/tasks/me/exists":
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Name: "exists",
OwnerName: "me",
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Name: "exists",
DisplayName: "Task exists",
OwnerName: "me",
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
+21 -14
View File
@@ -13,8 +13,9 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/taskname"
aiagentapi "github.com/coder/agentapi-sdk-go"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
@@ -24,12 +25,9 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/coderd/taskname"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
aiagentapi "github.com/coder/agentapi-sdk-go"
)
// @Summary Create a new AI task
@@ -111,18 +109,25 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
}
}
if taskName == "" {
taskName = taskname.GenerateFallback()
taskDisplayName := strings.TrimSpace(req.DisplayName)
if taskDisplayName != "" {
if len(taskDisplayName) > 64 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Display name must be 64 characters or less.",
})
return
}
}
if anthropicAPIKey := taskname.GetAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" {
anthropicModel := taskname.GetAnthropicModelFromEnv()
// Generate task name and display name if either is not provided
if taskName == "" || taskDisplayName == "" {
generatedTaskName := taskname.Generate(ctx, api.Logger, req.Input)
generatedName, err := taskname.Generate(ctx, req.Input, taskname.WithAPIKey(anthropicAPIKey), taskname.WithModel(anthropicModel))
if err != nil {
api.Logger.Error(ctx, "unable to generate task name", slog.Error(err))
} else {
taskName = generatedName
}
if taskName == "" {
taskName = generatedTaskName.Name
}
if taskDisplayName == "" {
taskDisplayName = generatedTaskName.DisplayName
}
}
@@ -215,6 +220,7 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
OrganizationID: templateVersion.OrganizationID,
OwnerID: owner.ID,
Name: taskName,
DisplayName: taskDisplayName,
WorkspaceID: uuid.NullUUID{}, // Will be set after workspace creation.
TemplateVersionID: templateVersion.ID,
TemplateParameters: []byte("{}"),
@@ -304,6 +310,7 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod
OwnerName: dbTask.OwnerUsername,
OwnerAvatarURL: dbTask.OwnerAvatarUrl,
Name: dbTask.Name,
DisplayName: dbTask.DisplayName,
TemplateID: ws.TemplateID,
TemplateVersionID: dbTask.TemplateVersionID,
TemplateName: ws.TemplateName,
+49 -7
View File
@@ -1049,14 +1049,17 @@ func TestTasksCreate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
taskName string
expectFallbackName bool
expectError string
name string
taskName string
taskDisplayName string
expectFallbackName bool
expectFallbackDisplayName bool
expectError string
}{
{
name: "ValidName",
taskName: "a-valid-task-name",
name: "ValidName",
taskName: "a-valid-task-name",
expectFallbackDisplayName: true,
},
{
name: "NotValidName",
@@ -1066,8 +1069,37 @@ func TestTasksCreate(t *testing.T) {
{
name: "NoNameProvided",
taskName: "",
taskDisplayName: "A valid task display name",
expectFallbackName: true,
},
{
name: "ValidDisplayName",
taskDisplayName: "A valid task display name",
expectFallbackName: true,
},
{
name: "NotValidDisplayName",
taskDisplayName: "This is a task display name with a length greater than 64 characters.",
expectError: "Display name must be 64 characters or less.",
},
{
name: "NoDisplayNameProvided",
taskName: "a-valid-task-name",
taskDisplayName: "",
expectFallbackDisplayName: true,
},
{
name: "ValidNameAndDisplayName",
taskName: "a-valid-task-name",
taskDisplayName: "A valid task display name",
},
{
name: "NoNameAndDisplayNameProvided",
taskName: "",
taskDisplayName: "",
expectFallbackName: true,
expectFallbackDisplayName: true,
},
}
for _, tt := range tests {
@@ -1098,6 +1130,7 @@ func TestTasksCreate(t *testing.T) {
TemplateVersionID: template.ActiveVersionID,
Input: "Some prompt",
Name: tt.taskName,
DisplayName: tt.taskDisplayName,
})
if tt.expectError == "" {
require.NoError(t, err)
@@ -1111,8 +1144,17 @@ func TestTasksCreate(t *testing.T) {
if !tt.expectFallbackName {
require.Equal(t, tt.taskName, task.Name)
}
// Then: We expect the correct display name to have been picked.
require.NotEmpty(t, task.DisplayName)
if !tt.expectFallbackDisplayName {
require.Equal(t, tt.taskDisplayName, task.DisplayName)
}
} else {
require.ErrorContains(t, err, tt.expectError)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Equal(t, apiErr.Message, tt.expectError)
}
})
}
+6
View File
@@ -13277,6 +13277,9 @@ const docTemplate = `{
"codersdk.CreateTaskRequest": {
"type": "object",
"properties": {
"display_name": {
"type": "string"
},
"input": {
"type": "string"
},
@@ -17876,6 +17879,9 @@ const docTemplate = `{
"current_state": {
"$ref": "#/definitions/codersdk.TaskStateEntry"
},
"display_name": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
+6
View File
@@ -11921,6 +11921,9 @@
"codersdk.CreateTaskRequest": {
"type": "object",
"properties": {
"display_name": {
"type": "string"
},
"input": {
"type": "string"
},
@@ -16358,6 +16361,9 @@
"current_state": {
"$ref": "#/definitions/codersdk.TaskStateEntry"
},
"display_name": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
+5 -1
View File
@@ -14,6 +14,8 @@ import (
"testing"
"time"
"cdr.dev/slog"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
@@ -1582,11 +1584,13 @@ func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Tas
parameters = json.RawMessage([]byte("{}"))
}
taskName := taskname.Generate(genCtx, slog.Make(), orig.Prompt)
task, err := db.InsertTask(genCtx, database.InsertTaskParams{
ID: takeFirst(orig.ID, uuid.New()),
OrganizationID: orig.OrganizationID,
OwnerID: orig.OwnerID,
Name: takeFirst(orig.Name, taskname.GenerateFallback()),
Name: takeFirst(orig.Name, taskName.Name),
DisplayName: takeFirst(orig.DisplayName, taskName.DisplayName),
WorkspaceID: orig.WorkspaceID,
TemplateVersionID: orig.TemplateVersionID,
TemplateParameters: parameters,
+5 -1
View File
@@ -1826,9 +1826,12 @@ CREATE TABLE tasks (
template_parameters jsonb DEFAULT '{}'::jsonb NOT NULL,
prompt text NOT NULL,
created_at timestamp with time zone NOT NULL,
deleted_at timestamp with time zone
deleted_at timestamp with time zone,
display_name character varying(127) DEFAULT ''::character varying NOT NULL
);
COMMENT ON COLUMN tasks.display_name IS 'Display name is a custom, human-friendly task name.';
CREATE VIEW visible_users AS
SELECT users.id,
users.username,
@@ -1964,6 +1967,7 @@ CREATE VIEW tasks_with_status AS
tasks.prompt,
tasks.created_at,
tasks.deleted_at,
tasks.display_name,
CASE
WHEN (tasks.workspace_id IS NULL) THEN 'pending'::task_status
WHEN (build_status.status <> 'active'::task_status) THEN build_status.status
@@ -0,0 +1,87 @@
-- Drop view first before removing the display_name column from tasks
DROP VIEW IF EXISTS tasks_with_status;
-- Remove display_name column from tasks
ALTER TABLE tasks DROP COLUMN display_name;
-- Recreate view without the display_name column.
-- This restores the view to its previous state after removing display_name from tasks.
CREATE VIEW
tasks_with_status
AS
SELECT
tasks.*,
CASE
WHEN tasks.workspace_id IS NULL OR latest_build.job_status IS NULL THEN 'pending'::task_status
WHEN latest_build.job_status = 'failed' THEN 'error'::task_status
WHEN latest_build.transition IN ('stop', 'delete')
AND latest_build.job_status = 'succeeded' THEN 'paused'::task_status
WHEN latest_build.transition = 'start'
AND latest_build.job_status = 'pending' THEN 'initializing'::task_status
WHEN latest_build.transition = 'start' AND latest_build.job_status IN ('running', 'succeeded') THEN
CASE
WHEN agent_status.none THEN 'initializing'::task_status
WHEN agent_status.connecting THEN 'initializing'::task_status
WHEN agent_status.connected THEN
CASE
WHEN app_status.any_unhealthy THEN 'error'::task_status
WHEN app_status.any_initializing THEN 'initializing'::task_status
WHEN app_status.all_healthy_or_disabled THEN 'active'::task_status
ELSE 'unknown'::task_status
END
ELSE 'unknown'::task_status
END
ELSE 'unknown'::task_status
END AS status,
task_app.*,
task_owner.*
FROM
tasks
CROSS JOIN LATERAL (
SELECT
vu.username AS owner_username,
vu.name AS owner_name,
vu.avatar_url AS owner_avatar_url
FROM visible_users vu
WHERE vu.id = tasks.owner_id
) task_owner
LEFT JOIN LATERAL (
SELECT workspace_build_number, workspace_agent_id, workspace_app_id
FROM task_workspace_apps task_app
WHERE task_id = tasks.id
ORDER BY workspace_build_number DESC
LIMIT 1
) task_app ON TRUE
LEFT JOIN LATERAL (
SELECT
workspace_build.transition,
provisioner_job.job_status,
workspace_build.job_id
FROM workspace_builds workspace_build
JOIN provisioner_jobs provisioner_job ON provisioner_job.id = workspace_build.job_id
WHERE workspace_build.workspace_id = tasks.workspace_id
AND workspace_build.build_number = task_app.workspace_build_number
) latest_build ON TRUE
CROSS JOIN LATERAL (
SELECT
COUNT(*) = 0 AS none,
bool_or(workspace_agent.lifecycle_state IN ('created', 'starting')) AS connecting,
bool_and(workspace_agent.lifecycle_state = 'ready') AS connected
FROM workspace_agents workspace_agent
WHERE workspace_agent.id = task_app.workspace_agent_id
) agent_status
CROSS JOIN LATERAL (
SELECT
bool_or(workspace_app.health = 'unhealthy') AS any_unhealthy,
bool_or(workspace_app.health = 'initializing') AS any_initializing,
bool_and(workspace_app.health IN ('healthy', 'disabled')) AS all_healthy_or_disabled
FROM workspace_apps workspace_app
WHERE workspace_app.id = task_app.workspace_app_id
) app_status
WHERE
tasks.deleted_at IS NULL;
@@ -0,0 +1,158 @@
-- Add display_name column to tasks table
ALTER TABLE tasks ADD COLUMN display_name VARCHAR(127) NOT NULL DEFAULT '';
COMMENT ON COLUMN tasks.display_name IS 'Display name is a custom, human-friendly task name.';
-- Backfill existing tasks with truncated prompt as display name
-- Replace newlines/tabs with spaces, truncate to 64 characters and add ellipsis if truncated
UPDATE tasks
SET display_name = CASE
WHEN LENGTH(REGEXP_REPLACE(prompt, E'[\\n\\r\\t]+', ' ', 'g')) > 64
THEN LEFT(REGEXP_REPLACE(prompt, E'[\\n\\r\\t]+', ' ', 'g'), 63) || ''
ELSE REGEXP_REPLACE(prompt, E'[\\n\\r\\t]+', ' ', 'g')
END
WHERE display_name = '';
-- Recreate the tasks_with_status view to pick up the new display_name column.
-- PostgreSQL resolves the tasks.* wildcard when the view is created, not when
-- it's queried, so the view must be recreated after adding columns to tasks.
DROP VIEW IF EXISTS tasks_with_status;
CREATE VIEW
tasks_with_status
AS
SELECT
tasks.*,
-- Combine component statuses with precedence: build -> agent -> app.
CASE
WHEN tasks.workspace_id IS NULL THEN 'pending'::task_status
WHEN build_status.status != 'active' THEN build_status.status::task_status
WHEN agent_status.status != 'active' THEN agent_status.status::task_status
ELSE app_status.status::task_status
END AS status,
-- Attach debug information for troubleshooting status.
jsonb_build_object(
'build', jsonb_build_object(
'transition', latest_build_raw.transition,
'job_status', latest_build_raw.job_status,
'computed', build_status.status
),
'agent', jsonb_build_object(
'lifecycle_state', agent_raw.lifecycle_state,
'computed', agent_status.status
),
'app', jsonb_build_object(
'health', app_raw.health,
'computed', app_status.status
)
) AS status_debug,
task_app.*,
agent_raw.lifecycle_state AS workspace_agent_lifecycle_state,
app_raw.health AS workspace_app_health,
task_owner.*
FROM
tasks
CROSS JOIN LATERAL (
SELECT
vu.username AS owner_username,
vu.name AS owner_name,
vu.avatar_url AS owner_avatar_url
FROM
visible_users vu
WHERE
vu.id = tasks.owner_id
) task_owner
LEFT JOIN LATERAL (
SELECT
task_app.workspace_build_number,
task_app.workspace_agent_id,
task_app.workspace_app_id
FROM
task_workspace_apps task_app
WHERE
task_id = tasks.id
ORDER BY
task_app.workspace_build_number DESC
LIMIT 1
) task_app ON TRUE
-- Join the raw data for computing task status.
LEFT JOIN LATERAL (
SELECT
workspace_build.transition,
provisioner_job.job_status,
workspace_build.job_id
FROM
workspace_builds workspace_build
JOIN
provisioner_jobs provisioner_job
ON provisioner_job.id = workspace_build.job_id
WHERE
workspace_build.workspace_id = tasks.workspace_id
AND workspace_build.build_number = task_app.workspace_build_number
) latest_build_raw ON TRUE
LEFT JOIN LATERAL (
SELECT
workspace_agent.lifecycle_state
FROM
workspace_agents workspace_agent
WHERE
workspace_agent.id = task_app.workspace_agent_id
) agent_raw ON TRUE
LEFT JOIN LATERAL (
SELECT
workspace_app.health
FROM
workspace_apps workspace_app
WHERE
workspace_app.id = task_app.workspace_app_id
) app_raw ON TRUE
-- Compute the status for each component.
CROSS JOIN LATERAL (
SELECT
CASE
WHEN latest_build_raw.job_status IS NULL THEN 'pending'::task_status
WHEN latest_build_raw.job_status IN ('failed', 'canceling', 'canceled') THEN 'error'::task_status
WHEN
latest_build_raw.transition IN ('stop', 'delete')
AND latest_build_raw.job_status = 'succeeded' THEN 'paused'::task_status
WHEN
latest_build_raw.transition = 'start'
AND latest_build_raw.job_status = 'pending' THEN 'initializing'::task_status
-- Build is running or done, defer to agent/app status.
WHEN
latest_build_raw.transition = 'start'
AND latest_build_raw.job_status IN ('running', 'succeeded') THEN 'active'::task_status
ELSE 'unknown'::task_status
END AS status
) build_status
CROSS JOIN LATERAL (
SELECT
CASE
-- No agent or connecting.
WHEN
agent_raw.lifecycle_state IS NULL
OR agent_raw.lifecycle_state IN ('created', 'starting') THEN 'initializing'::task_status
-- Agent is running, defer to app status.
-- NOTE(mafredri): The start_error/start_timeout states means connected, but some startup script failed.
-- This may or may not affect the task status but this has to be caught by app health check.
WHEN agent_raw.lifecycle_state IN ('ready', 'start_timeout', 'start_error') THEN 'active'::task_status
-- If the agent is shutting down or turned off, this is an unknown state because we would expect a stop
-- build to be running.
-- This is essentially equal to: `IN ('shutting_down', 'shutdown_timeout', 'shutdown_error', 'off')`,
-- but we cannot use them because the values were added in a migration.
WHEN agent_raw.lifecycle_state NOT IN ('created', 'starting', 'ready', 'start_timeout', 'start_error') THEN 'unknown'::task_status
ELSE 'unknown'::task_status
END AS status
) agent_status
CROSS JOIN LATERAL (
SELECT
CASE
WHEN app_raw.health = 'initializing' THEN 'initializing'::task_status
WHEN app_raw.health = 'unhealthy' THEN 'error'::task_status
WHEN app_raw.health IN ('healthy', 'disabled') THEN 'active'::task_status
ELSE 'unknown'::task_status
END AS status
) app_status
WHERE
tasks.deleted_at IS NULL;
+1
View File
@@ -143,6 +143,7 @@ func (t Task) TaskTable() TaskTable {
OrganizationID: t.OrganizationID,
OwnerID: t.OwnerID,
Name: t.Name,
DisplayName: t.DisplayName,
WorkspaceID: t.WorkspaceID,
TemplateVersionID: t.TemplateVersionID,
TemplateParameters: t.TemplateParameters,
+3
View File
@@ -4218,6 +4218,7 @@ type Task struct {
Prompt string `db:"prompt" json:"prompt"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"`
DisplayName string `db:"display_name" json:"display_name"`
Status TaskStatus `db:"status" json:"status"`
StatusDebug json.RawMessage `db:"status_debug" json:"status_debug"`
WorkspaceBuildNumber sql.NullInt32 `db:"workspace_build_number" json:"workspace_build_number"`
@@ -4241,6 +4242,8 @@ type TaskTable struct {
Prompt string `db:"prompt" json:"prompt"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"`
// Display name is a custom, human-friendly task name.
DisplayName string `db:"display_name" json:"display_name"`
}
type TaskWorkspaceApp struct {
+20 -10
View File
@@ -13187,7 +13187,7 @@ SET
WHERE
id = $2::uuid
AND deleted_at IS NULL
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name
`
type DeleteTaskParams struct {
@@ -13209,12 +13209,13 @@ func (q *sqlQuerier) DeleteTask(ctx context.Context, arg DeleteTaskParams) (Task
&i.Prompt,
&i.CreatedAt,
&i.DeletedAt,
&i.DisplayName,
)
return i, err
}
const getTaskByID = `-- name: GetTaskByID :one
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE id = $1::uuid
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE id = $1::uuid
`
func (q *sqlQuerier) GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error) {
@@ -13231,6 +13232,7 @@ func (q *sqlQuerier) GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error
&i.Prompt,
&i.CreatedAt,
&i.DeletedAt,
&i.DisplayName,
&i.Status,
&i.StatusDebug,
&i.WorkspaceBuildNumber,
@@ -13246,7 +13248,7 @@ func (q *sqlQuerier) GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error
}
const getTaskByOwnerIDAndName = `-- name: GetTaskByOwnerIDAndName :one
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status
WHERE
owner_id = $1::uuid
AND deleted_at IS NULL
@@ -13272,6 +13274,7 @@ func (q *sqlQuerier) GetTaskByOwnerIDAndName(ctx context.Context, arg GetTaskByO
&i.Prompt,
&i.CreatedAt,
&i.DeletedAt,
&i.DisplayName,
&i.Status,
&i.StatusDebug,
&i.WorkspaceBuildNumber,
@@ -13287,7 +13290,7 @@ func (q *sqlQuerier) GetTaskByOwnerIDAndName(ctx context.Context, arg GetTaskByO
}
const getTaskByWorkspaceID = `-- name: GetTaskByWorkspaceID :one
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE workspace_id = $1::uuid
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE workspace_id = $1::uuid
`
func (q *sqlQuerier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (Task, error) {
@@ -13304,6 +13307,7 @@ func (q *sqlQuerier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.
&i.Prompt,
&i.CreatedAt,
&i.DeletedAt,
&i.DisplayName,
&i.Status,
&i.StatusDebug,
&i.WorkspaceBuildNumber,
@@ -13320,10 +13324,10 @@ func (q *sqlQuerier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.
const insertTask = `-- name: InsertTask :one
INSERT INTO tasks
(id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at)
(id, organization_id, owner_id, name, display_name, workspace_id, template_version_id, template_parameters, prompt, created_at)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name
`
type InsertTaskParams struct {
@@ -13331,6 +13335,7 @@ type InsertTaskParams struct {
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"`
TemplateParameters json.RawMessage `db:"template_parameters" json:"template_parameters"`
@@ -13344,6 +13349,7 @@ func (q *sqlQuerier) InsertTask(ctx context.Context, arg InsertTaskParams) (Task
arg.OrganizationID,
arg.OwnerID,
arg.Name,
arg.DisplayName,
arg.WorkspaceID,
arg.TemplateVersionID,
arg.TemplateParameters,
@@ -13362,12 +13368,13 @@ func (q *sqlQuerier) InsertTask(ctx context.Context, arg InsertTaskParams) (Task
&i.Prompt,
&i.CreatedAt,
&i.DeletedAt,
&i.DisplayName,
)
return i, err
}
const listTasks = `-- name: ListTasks :many
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status tws
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status tws
WHERE tws.deleted_at IS NULL
AND CASE WHEN $1::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.owner_id = $1::UUID ELSE TRUE END
AND CASE WHEN $2::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.organization_id = $2::UUID ELSE TRUE END
@@ -13401,6 +13408,7 @@ func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task
&i.Prompt,
&i.CreatedAt,
&i.DeletedAt,
&i.DisplayName,
&i.Status,
&i.StatusDebug,
&i.WorkspaceBuildNumber,
@@ -13433,7 +13441,7 @@ SET
WHERE
id = $2::uuid
AND deleted_at IS NULL
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name
`
type UpdateTaskPromptParams struct {
@@ -13455,6 +13463,7 @@ func (q *sqlQuerier) UpdateTaskPrompt(ctx context.Context, arg UpdateTaskPromptP
&i.Prompt,
&i.CreatedAt,
&i.DeletedAt,
&i.DisplayName,
)
return i, err
}
@@ -13476,7 +13485,7 @@ WHERE
AND w.id = $2
AND tv.id = tasks.template_version_id
RETURNING
tasks.id, tasks.organization_id, tasks.owner_id, tasks.name, tasks.workspace_id, tasks.template_version_id, tasks.template_parameters, tasks.prompt, tasks.created_at, tasks.deleted_at
tasks.id, tasks.organization_id, tasks.owner_id, tasks.name, tasks.workspace_id, tasks.template_version_id, tasks.template_parameters, tasks.prompt, tasks.created_at, tasks.deleted_at, tasks.display_name
`
type UpdateTaskWorkspaceIDParams struct {
@@ -13498,6 +13507,7 @@ func (q *sqlQuerier) UpdateTaskWorkspaceID(ctx context.Context, arg UpdateTaskWo
&i.Prompt,
&i.CreatedAt,
&i.DeletedAt,
&i.DisplayName,
)
return i, err
}
+2 -2
View File
@@ -1,8 +1,8 @@
-- name: InsertTask :one
INSERT INTO tasks
(id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at)
(id, organization_id, owner_id, name, display_name, workspace_id, template_version_id, template_parameters, prompt, created_at)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9)
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *;
-- name: UpdateTaskWorkspaceID :one
+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)
})
}
+2
View File
@@ -35,6 +35,7 @@ type CreateTaskRequest struct {
TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"`
Input string `json:"input"`
Name string `json:"name,omitempty"`
DisplayName string `json:"display_name,omitempty"`
}
// CreateTask creates a new task.
@@ -128,6 +129,7 @@ type Task struct {
OwnerName string `json:"owner_name" table:"owner name"`
OwnerAvatarURL string `json:"owner_avatar_url,omitempty" table:"owner avatar url"`
Name string `json:"name" table:"name,default_sort"`
DisplayName string `json:"display_name" table:"display_name"`
TemplateID uuid.UUID `json:"template_id" format:"uuid" table:"template id"`
TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid" table:"template version id"`
TemplateName string `json:"template_name" table:"template name"`
+1 -1
View File
@@ -32,7 +32,7 @@ We track the following resources:
| OrganizationSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>assign_default</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
| PrebuildsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>reconciliation_paused</td><td>true</td></tr></tbody></table> |
| RoleSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
| TaskTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>deleted_at</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>prompt</td><td>true</td></tr><tr><td>template_parameters</td><td>true</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>workspace_id</td><td>true</td></tr></tbody></table> |
| TaskTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>deleted_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>prompt</td><td>true</td></tr><tr><td>template_parameters</td><td>true</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>workspace_id</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>cors_behavior</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_port_sharing_level</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_display_name</td><td>false</td></tr><tr><td>organization_icon</td><td>false</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>organization_name</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>use_classic_parameter_flow</td><td>true</td></tr><tr><td>use_terraform_workspace_cache</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_system</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
+1
View File
@@ -49,6 +49,7 @@ curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user} \
```json
{
"display_name": "string",
"input": "string",
"name": "string",
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
+5
View File
@@ -2085,6 +2085,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
```json
{
"display_name": "string",
"input": "string",
"name": "string",
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
@@ -2096,6 +2097,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| Name | Type | Required | Restrictions | Description |
|------------------------------|--------|----------|--------------|-------------|
| `display_name` | string | false | | |
| `input` | string | false | | |
| `name` | string | false | | |
| `template_version_id` | string | false | | |
@@ -7805,6 +7807,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
"timestamp": "2019-08-24T14:15:22Z",
"uri": "string"
},
"display_name": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initial_prompt": "string",
"name": "string",
@@ -7848,6 +7851,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|-----------------------------|----------------------------------------------------------------------|----------|--------------|-------------|
| `created_at` | string | false | | |
| `current_state` | [codersdk.TaskStateEntry](#codersdktaskstateentry) | false | | |
| `display_name` | string | false | | |
| `id` | string | false | | |
| `initial_prompt` | string | false | | |
| `name` | string | false | | |
@@ -8032,6 +8036,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
"timestamp": "2019-08-24T14:15:22Z",
"uri": "string"
},
"display_name": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initial_prompt": "string",
"name": "string",
+1
View File
@@ -353,6 +353,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"organization_id": ActionIgnore, // Never changes.
"owner_id": ActionTrack,
"name": ActionTrack,
"display_name": ActionTrack,
"workspace_id": ActionTrack,
"template_version_id": ActionTrack,
"template_parameters": ActionTrack,
+2
View File
@@ -1203,6 +1203,7 @@ export interface CreateTaskRequest {
readonly template_version_preset_id?: string;
readonly input: string;
readonly name?: string;
readonly display_name?: string;
}
// From codersdk/organizations.go
@@ -4732,6 +4733,7 @@ export interface Task {
readonly owner_name: string;
readonly owner_avatar_url?: string;
readonly name: string;
readonly display_name: string;
readonly template_id: string;
readonly template_version_id: string;
readonly template_name: string;
+1
View File
@@ -4997,6 +4997,7 @@ export const MockAIPromptPresets: TypesGen.Preset[] = [
export const MockTask = {
id: "test-task",
name: "task-wild-test-123",
display_name: "Task wild test 123",
organization_id: MockOrganization.id,
owner_id: MockUserOwner.id,
owner_name: MockUserOwner.username,