From 243b4a5a9611f7b27208d380429354032497bb3e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Nov 2025 23:59:24 +0000 Subject: [PATCH 01/10] feat(tasks): add task prompt modification endpoint This adds a new PATCH endpoint to modify the prompt of an existing task and restart its build. This is the backend implementation including: - New UpdateTaskPrompt database query - New PATCH /api/v2/tasks/{task}/prompt endpoint - Authorization checks for task modification - Database changes to support prompt updates - Generated API documentation and TypeScript types --- coderd/aitasks.go | 107 +++++++++ coderd/aitasks_test.go | 222 ++++++++++++++++++ coderd/apidoc/docs.go | 53 +++++ coderd/apidoc/swagger.json | 51 ++++ coderd/coderd.go | 1 + coderd/database/dbauthz/dbauthz.go | 15 ++ coderd/database/dbauthz/dbauthz_test.go | 16 ++ coderd/database/dbmetrics/querymetrics.go | 7 + coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/modelmethods.go | 25 +- coderd/database/modelqueries_internal_test.go | 38 +++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 33 +++ coderd/database/queries/tasks.sql | 10 + codersdk/aitasks.go | 22 ++ docs/reference/api/experimental.md | 37 +++ docs/reference/api/schemas.md | 14 ++ site/src/api/typesGenerated.ts | 10 + 18 files changed, 673 insertions(+), 4 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index d5cca9e4f0b3f..49ce0bd9cd1f5 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -585,6 +585,113 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusAccepted) } +// @Summary Update AI task prompt +// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable. +// @ID update-task-prompt +// @Security CoderSessionToken +// @Tags Experimental +// @Param user path string true "Username, user ID, or 'me' for the authenticated user" +// @Param task path string true "Task ID" format(uuid) +// @Param request body codersdk.UpdateTaskPromptRequest true "Update task prompt request" +// @Success 204 +// @Router /api/experimental/tasks/{user}/{task}/prompt [patch] +// +// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. +// taskUpdatePrompt allows modifying a task's prompt before the agent executes it. +func (api *API) taskUpdatePrompt(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + task = httpmw.TaskParam(r) + auditor = api.Auditor.Load() + taskResourceInfo = audit.AdditionalFields{} + ) + + aReq, commitAudit := audit.InitRequest[database.TaskTable](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + AdditionalFields: taskResourceInfo, + }) + defer commitAudit() + aReq.Old = task.TaskTable() + aReq.UpdateOrganizationID(task.OrganizationID) + + var req codersdk.UpdateTaskPromptRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if req.Prompt == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Prompt is required.", + }) + return + } + + // NOTE(DanielleMaywood): + // If there is a workspace associated with this task, we should check it isn't actively running. + // If it is running, we do not want to update the prompt as updating the prompt of a running + // workspace doesn't make sense. + if task.WorkspaceID.Valid { + build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, task.WorkspaceID.UUID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get workspace build.", + Detail: err.Error(), + }) + return + } + + job, err := api.Database.GetProvisionerJobByID(ctx, build.JobID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get provisioner job.", + Detail: err.Error(), + }) + return + } + + // Check workspace build status - must NOT be in active/transitional state + // Block updates if: + // 1. Job is actively running (pending, running, canceling) + // 2. Workspace is running (job succeeded with start transition) + if job.JobStatus == database.ProvisionerJobStatusPending || + job.JobStatus == database.ProvisionerJobStatusRunning || + job.JobStatus == database.ProvisionerJobStatusCanceling { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Cannot update prompt while workspace build is active.", + Detail: fmt.Sprintf("Current build status: %s. Please wait for the build to complete or stop the workspace.", job.JobStatus), + }) + return + } + + // Also block if workspace is running (successful start transition) + if job.JobStatus == database.ProvisionerJobStatusSucceeded && build.Transition == database.WorkspaceTransitionStart { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Cannot update prompt while workspace is running.", + Detail: "Please stop the workspace before updating the prompt.", + }) + return + } + } + + updatedTask, err := api.Database.UpdateTaskPrompt(ctx, database.UpdateTaskPromptParams{ + ID: task.ID, + Prompt: req.Prompt, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update task prompt.", + Detail: err.Error(), + }) + return + } + aReq.New = updatedTask + + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + // @Summary Send input to AI task // @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable. // @ID send-task-input diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index eaac973b159e6..b2aa6bb97757c 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -734,6 +734,228 @@ func TestTasks(t *testing.T) { require.Equal(t, http.StatusBadGateway, sdkErr.StatusCode()) }) }) + + t.Run("UpdatePrompt", func(t *testing.T) { + t.Run("Stopped", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + // Create a task with workspace + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "initial prompt", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") + + // Wait for the initial workspace build to complete (start transition) + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Stop the workspace + build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + // Verify build succeeded (stop transition completed) + build, err = client.WorkspaceBuild(ctx, build.ID) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceStatusStopped, build.Status) + + // Now update prompt should succeed + err = exp.UpdateTaskPrompt(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskPromptRequest{ + Prompt: "Updated prompt after stop", + }) + require.NoError(t, err) + }) + + t.Run("Canceled", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + // Create a task with workspace + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "initial prompt", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + // Wait for the initial workspace build to complete + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Stop the workspace first + stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, stopBuild.ID) + + // Start a new build and cancel it + startBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) + err = client.CancelWorkspaceBuild(ctx, startBuild.ID, codersdk.CancelWorkspaceBuildParams{}) + require.NoError(t, err) + + // Wait for cancellation to complete + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, startBuild.ID) + startBuild, err = client.WorkspaceBuild(ctx, startBuild.ID) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceStatusCanceled, startBuild.Status) + + // Now update prompt should succeed + err = exp.UpdateTaskPrompt(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskPromptRequest{ + Prompt: "Updated prompt after cancel", + }) + require.NoError(t, err) + }) + + t.Run("Running", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + // Create a task with workspace + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "initial prompt", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + // Wait for workspace to be running + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Verify workspace is running (start transition succeeded) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status) + require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) + + // Attempt to update prompt should fail with 409 Conflict + err = exp.UpdateTaskPrompt(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskPromptRequest{ + Prompt: "Should fail", + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "running") + }) + + t.Run("EmptyPrompt", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + // Create a task with workspace + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "initial prompt", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + // Wait for workspace to be running + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Stop the workspace so we can test the empty prompt validation + build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + // Attempt to update with empty prompt should fail with 400 Bad Request + err = exp.UpdateTaskPrompt(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskPromptRequest{ + Prompt: "", + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "required") + }) + + t.Run("NonExistentTask", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitShort) + + exp := codersdk.NewExperimentalClient(client) + + // Attempt to update prompt for non-existent task + err := exp.UpdateTaskPrompt(ctx, user.UserID.String(), uuid.New(), codersdk.UpdateTaskPromptRequest{ + Prompt: "Should fail", + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + + t.Run("UnauthorizedUser", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + anotherUser, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + // Create a task as the first user + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "initial prompt", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + // Wait for workspace to complete + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Stop the workspace + build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + // Attempt to update prompt as another user should fail with 404 Not Found + otherExp := codersdk.NewExperimentalClient(anotherUser) + err = otherExp.UpdateTaskPrompt(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskPromptRequest{ + Prompt: "Should fail - unauthorized", + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + }) } func TestTasksCreate(t *testing.T) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index de7cd416f287d..d54487f7a8c59 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -318,6 +318,51 @@ const docTemplate = `{ } } }, + "/api/experimental/tasks/{user}/{task}/prompt": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Experimental" + ], + "summary": "Update AI task prompt", + "operationId": "update-task-prompt", + "parameters": [ + { + "type": "string", + "description": "Username, user ID, or 'me' for the authenticated user", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Task ID", + "name": "task", + "in": "path", + "required": true + }, + { + "description": "Update task prompt request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTaskPromptRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/api/experimental/tasks/{user}/{task}/send": { "post": { "security": [ @@ -18962,6 +19007,14 @@ const docTemplate = `{ } } }, + "codersdk.UpdateTaskPromptRequest": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + } + } + }, "codersdk.UpdateTemplateACL": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 80d705f335f13..b19899eb8af5c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -284,6 +284,49 @@ } } }, + "/api/experimental/tasks/{user}/{task}/prompt": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Experimental"], + "summary": "Update AI task prompt", + "operationId": "update-task-prompt", + "parameters": [ + { + "type": "string", + "description": "Username, user ID, or 'me' for the authenticated user", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Task ID", + "name": "task", + "in": "path", + "required": true + }, + { + "description": "Update task prompt request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTaskPromptRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/api/experimental/tasks/{user}/{task}/send": { "post": { "security": [ @@ -17393,6 +17436,14 @@ } } }, + "codersdk.UpdateTaskPromptRequest": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + } + } + }, "codersdk.UpdateTemplateACL": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index a9683ace20004..3900553d217c0 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1035,6 +1035,7 @@ func New(options *Options) *API { r.Use(httpmw.ExtractTaskParam(options.Database)) r.Get("/", api.taskGet) r.Delete("/", api.taskDelete) + r.Patch("/prompt", api.taskUpdatePrompt) r.Post("/send", api.taskSend) r.Get("/logs", api.taskLogs) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 87b5de36009bf..c761811e33c99 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5100,6 +5100,21 @@ func (q *querier) UpdateTailnetPeerStatusByCoordinator(ctx context.Context, arg return q.db.UpdateTailnetPeerStatusByCoordinator(ctx, arg) } +func (q *querier) UpdateTaskPrompt(ctx context.Context, arg database.UpdateTaskPromptParams) (database.TaskTable, error) { + // An actor is allowed to update the prompt of a task if they have + // permission to update the task (same as UpdateTaskWorkspaceID). + task, err := q.db.GetTaskByID(ctx, arg.ID) + if err != nil { + return database.TaskTable{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionUpdate, task.RBACObject()); err != nil { + return database.TaskTable{}, err + } + + return q.db.UpdateTaskPrompt(ctx, arg) +} + func (q *querier) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) { // An actor is allowed to update the workspace ID of a task if they are the // owner of the task and workspace or have the appropriate permissions. diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index fc98700c548f6..640b532d1cf6a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2442,6 +2442,22 @@ func (s *MethodTestSuite) TestTasks() { check.Args(arg).Asserts(task, policy.ActionUpdate, ws, policy.ActionUpdate).Returns(database.TaskTable{}) })) + s.Run("UpdateTaskPrompt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + task := testutil.Fake(s.T(), faker, database.Task{}) + arg := database.UpdateTaskPromptParams{ + ID: task.ID, + Prompt: "Updated prompt text", + } + + // Create a copy of the task with the updated prompt + updatedTask := task + updatedTask.Prompt = arg.Prompt + + dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes() + dbm.EXPECT().UpdateTaskPrompt(gomock.Any(), arg).Return(updatedTask.TaskTable(), nil).AnyTimes() + + check.Args(arg).Asserts(task, policy.ActionUpdate).Returns(updatedTask.TaskTable()) + })) s.Run("GetTaskByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { task := testutil.Fake(s.T(), faker, database.Task{}) task.WorkspaceID = uuid.NullUUID{UUID: uuid.New(), Valid: true} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index d841315924a15..26553e77c20e9 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3133,6 +3133,13 @@ func (m queryMetricsStore) UpdateTailnetPeerStatusByCoordinator(ctx context.Cont return r0 } +func (m queryMetricsStore) UpdateTaskPrompt(ctx context.Context, arg database.UpdateTaskPromptParams) (database.TaskTable, error) { + start := time.Now() + r0, r1 := m.s.UpdateTaskPrompt(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTaskPrompt").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) { start := time.Now() r0, r1 := m.s.UpdateTaskWorkspaceID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 313bb988979a1..20dcf97bcbc07 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6725,6 +6725,21 @@ func (mr *MockStoreMockRecorder) UpdateTailnetPeerStatusByCoordinator(ctx, arg a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTailnetPeerStatusByCoordinator", reflect.TypeOf((*MockStore)(nil).UpdateTailnetPeerStatusByCoordinator), ctx, arg) } +// UpdateTaskPrompt mocks base method. +func (m *MockStore) UpdateTaskPrompt(ctx context.Context, arg database.UpdateTaskPromptParams) (database.TaskTable, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTaskPrompt", ctx, arg) + ret0, _ := ret[0].(database.TaskTable) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateTaskPrompt indicates an expected call of UpdateTaskPrompt. +func (mr *MockStoreMockRecorder) UpdateTaskPrompt(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTaskPrompt", reflect.TypeOf((*MockStore)(nil).UpdateTaskPrompt), ctx, arg) +} + // UpdateTaskWorkspaceID mocks base method. func (m *MockStore) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index b3202342e3ffa..c0c0e2b40aeb9 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -132,11 +132,28 @@ func (w ConnectionLog) RBACObject() rbac.Object { return obj } +// TaskTable converts a Task to it's reduced version. +// A more generalized solution is to use json marshaling to +// consistently keep these two structs in sync. +// That would be a lot of overhead, and a more costly unit test is +// written to make sure these match up. +func (t Task) TaskTable() TaskTable { + return TaskTable{ + ID: t.ID, + OrganizationID: t.OrganizationID, + OwnerID: t.OwnerID, + Name: t.Name, + WorkspaceID: t.WorkspaceID, + TemplateVersionID: t.TemplateVersionID, + TemplateParameters: t.TemplateParameters, + Prompt: t.Prompt, + CreatedAt: t.CreatedAt, + DeletedAt: t.DeletedAt, + } +} + func (t Task) RBACObject() rbac.Object { - return rbac.ResourceTask. - WithID(t.ID). - WithOwner(t.OwnerID.String()). - InOrg(t.OrganizationID) + return t.TaskTable().RBACObject() } func (t TaskTable) RBACObject() rbac.Object { diff --git a/coderd/database/modelqueries_internal_test.go b/coderd/database/modelqueries_internal_test.go index 275ed947a3e4c..1ee4ab3fd5f03 100644 --- a/coderd/database/modelqueries_internal_test.go +++ b/coderd/database/modelqueries_internal_test.go @@ -58,6 +58,44 @@ func TestWorkspaceTableConvert(t *testing.T) { "To resolve this, go to the 'func (w Workspace) WorkspaceTable()' and ensure all fields are converted.") } +// TestTaskTableConvert verifies all task fields are converted +// when reducing a `Task` to a `TaskTable`. +// This test is a guard rail to prevent developer oversight mistakes. +func TestTaskTableConvert(t *testing.T) { + t.Parallel() + + staticRandoms := &testutil.Random{ + String: func() string { return "foo" }, + Bool: func() bool { return true }, + Int: func() int64 { return 500 }, + Uint: func() uint64 { return 126 }, + Float: func() float64 { return 3.14 }, + Complex: func() complex128 { return 6.24 }, + Time: func() time.Time { + return time.Date(2020, 5, 2, 5, 19, 21, 30, time.UTC) + }, + } + + // This feels a bit janky, but it works. + // If you use 'PopulateStruct' to create 2 workspaces, using the same + // "random" values for each type. Then they should be identical. + // + // So if 'workspace.WorkspaceTable()' was missing any fields in its + // conversion, the comparison would fail. + + var task Task + err := testutil.PopulateStruct(&task, staticRandoms) + require.NoError(t, err) + + var subset TaskTable + err = testutil.PopulateStruct(&subset, staticRandoms) + require.NoError(t, err) + + require.Equal(t, task.TaskTable(), subset, + "'task.TaskTable()' is not missing at least 1 field when converting to 'TaskTable'. "+ + "To resolve this, go to the 'func (t Task) TaskTable()' and ensure all fields are converted.") +} + // TestAuditLogsQueryConsistency ensures that GetAuditLogsOffset and CountAuditLogs // have identical WHERE clauses to prevent filtering inconsistencies. // This test is a guard rail to prevent developer oversight mistakes. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3e5771f96de04..f06e858fdd94d 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -682,6 +682,7 @@ type sqlcQuerier interface { UpdateProvisionerJobWithCompleteWithStartedAtByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteWithStartedAtByIDParams) error UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) UpdateTailnetPeerStatusByCoordinator(ctx context.Context, arg UpdateTailnetPeerStatusByCoordinatorParams) error + UpdateTaskPrompt(ctx context.Context, arg UpdateTaskPromptParams) (TaskTable, error) UpdateTaskWorkspaceID(ctx context.Context, arg UpdateTaskWorkspaceIDParams) (TaskTable, error) UpdateTemplateACLByID(ctx context.Context, arg UpdateTemplateACLByIDParams) error UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 21cb7b1874b5e..1a2d6fda2802f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13283,6 +13283,39 @@ func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task return items, nil } +const updateTaskPrompt = `-- name: UpdateTaskPrompt :one +UPDATE tasks +SET + prompt = $1::text +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 +` + +type UpdateTaskPromptParams struct { + Prompt string `db:"prompt" json:"prompt"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateTaskPrompt(ctx context.Context, arg UpdateTaskPromptParams) (TaskTable, error) { + row := q.db.QueryRowContext(ctx, updateTaskPrompt, arg.Prompt, arg.ID) + var i TaskTable + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.OwnerID, + &i.Name, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.TemplateParameters, + &i.Prompt, + &i.CreatedAt, + &i.DeletedAt, + ) + return i, err +} + const updateTaskWorkspaceID = `-- name: UpdateTaskWorkspaceID :one UPDATE tasks diff --git a/coderd/database/queries/tasks.sql b/coderd/database/queries/tasks.sql index 5cbbefd458881..5df792f294163 100644 --- a/coderd/database/queries/tasks.sql +++ b/coderd/database/queries/tasks.sql @@ -64,3 +64,13 @@ WHERE id = @id::uuid AND deleted_at IS NULL RETURNING *; + + +-- name: UpdateTaskPrompt :one +UPDATE tasks +SET + prompt = @prompt::text +WHERE + id = @id::uuid + AND deleted_at IS NULL +RETURNING *; diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index db8db8abca119..85478db71d1ec 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -352,6 +352,28 @@ func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid. return nil } +// UpdateTaskPromptRequest is used to update a task's prompt. +// +// Experimental: This type is experimental and may change in the future. +type UpdateTaskPromptRequest struct { + Prompt string `json:"prompt"` +} + +// UpdateTaskPrompt updates the task's prompt. +// +// Experimental: This method is experimental and may change in the future. +func (c *ExperimentalClient) UpdateTaskPrompt(ctx context.Context, user string, id uuid.UUID, req UpdateTaskPromptRequest) error { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/tasks/%s/%s/prompt", user, id.String()), req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // TaskLogType indicates the source of a task log entry. // // Experimental: This type is experimental and may change in the future. diff --git a/docs/reference/api/experimental.md b/docs/reference/api/experimental.md index 34ad224bd3538..e044a6cc6d165 100644 --- a/docs/reference/api/experimental.md +++ b/docs/reference/api/experimental.md @@ -166,6 +166,43 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task} To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Update AI task prompt + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/prompt \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /api/experimental/tasks/{user}/{task}/prompt` + +> Body parameter + +```json +{ + "prompt": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------------------------------------------------------------------------------|----------|-------------------------------------------------------| +| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | +| `task` | path | string(uuid) | true | Task ID | +| `body` | body | [codersdk.UpdateTaskPromptRequest](schemas.md#codersdkupdatetaskpromptrequest) | true | Update task prompt request | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Send input to AI task ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 0f43255ad60c7..090cc3ef30387 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9155,6 +9155,20 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |---------|-----------------|----------|--------------|-------------| | `roles` | array of string | false | | | +## codersdk.UpdateTaskPromptRequest + +```json +{ + "prompt": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------|--------|----------|--------------|-------------| +| `prompt` | string | false | | | + ## codersdk.UpdateTemplateACL ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c2c94aa314b3d..fddb0854dd132 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5358,6 +5358,16 @@ export interface UpdateRoles { readonly roles: readonly string[]; } +// From codersdk/aitasks.go +/** + * UpdateTaskPromptRequest is used to update a task's prompt. + * + * Experimental: This type is experimental and may change in the future. + */ +export interface UpdateTaskPromptRequest { + readonly prompt: string; +} + // From codersdk/templates.go export interface UpdateTemplateACL { /** From 94453b48dc7339b3f393d87739bba54ae585caeb Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 18 Nov 2025 17:04:42 +0000 Subject: [PATCH 02/10] chore: listen to feedback --- coderd/aitasks.go | 97 +++++++++++-------------------- coderd/aitasks_test.go | 60 ++++--------------- coderd/coderd.go | 2 +- coderd/database/queries/tasks.sql | 3 +- codersdk/aitasks.go | 12 ++-- 5 files changed, 53 insertions(+), 121 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 49ce0bd9cd1f5..b22fd3e29f313 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -439,7 +439,7 @@ func (api *API) convertTasks(ctx context.Context, requesterID uuid.UUID, dbTasks // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param task path string true "Task ID" format(uuid) +// @Param task path string true "Task ID, or task name" // @Success 200 {object} codersdk.Task // @Router /api/experimental/tasks/{user}/{task} [get] // @@ -517,7 +517,7 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param task path string true "Task ID" format(uuid) +// @Param task path string true "Task ID, or task name" // @Success 202 "Task deletion initiated" // @Router /api/experimental/tasks/{user}/{task} [delete] // @@ -585,20 +585,20 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusAccepted) } -// @Summary Update AI task prompt +// @Summary Update AI task input // @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable. -// @ID update-task-prompt +// @ID update-task-input // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param task path string true "Task ID" format(uuid) -// @Param request body codersdk.UpdateTaskPromptRequest true "Update task prompt request" +// @Param task path string true "Task ID, or task name" +// @Param request body codersdk.UpdateTaskInputRequest true "Update task input request" // @Success 204 -// @Router /api/experimental/tasks/{user}/{task}/prompt [patch] +// @Router /api/experimental/tasks/{user}/{task}/input [patch] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. -// taskUpdatePrompt allows modifying a task's prompt before the agent executes it. -func (api *API) taskUpdatePrompt(rw http.ResponseWriter, r *http.Request) { +// taskUpdateInput allows modifying a task's prompt before the agent executes it. +func (api *API) taskUpdateInput(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() task = httpmw.TaskParam(r) @@ -617,76 +617,45 @@ func (api *API) taskUpdatePrompt(rw http.ResponseWriter, r *http.Request) { aReq.Old = task.TaskTable() aReq.UpdateOrganizationID(task.OrganizationID) - var req codersdk.UpdateTaskPromptRequest + var req codersdk.UpdateTaskInputRequest if !httpapi.Read(ctx, rw, r, &req) { return } - if req.Prompt == "" { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Prompt is required.", - }) - return - } - - // NOTE(DanielleMaywood): - // If there is a workspace associated with this task, we should check it isn't actively running. - // If it is running, we do not want to update the prompt as updating the prompt of a running - // workspace doesn't make sense. - if task.WorkspaceID.Valid { - build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, task.WorkspaceID.UUID) + var updatedTask database.TaskTable + if err := api.Database.InTx(func(s database.Store) error { + task, err := api.Database.GetTaskByID(ctx, task.ID) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get workspace build.", + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to fetch task.", Detail: err.Error(), }) - return } - job, err := api.Database.GetProvisionerJobByID(ctx, build.JobID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get provisioner job.", - Detail: err.Error(), + if task.Status == database.TaskStatusInitializing || task.Status == database.TaskStatusActive { + return httperror.NewResponseError(http.StatusConflict, codersdk.Response{ + Message: "Cannot update input while task is initializing or active.", + Detail: "Please stop the task's workspace before updating the input.", }) - return } - // Check workspace build status - must NOT be in active/transitional state - // Block updates if: - // 1. Job is actively running (pending, running, canceling) - // 2. Workspace is running (job succeeded with start transition) - if job.JobStatus == database.ProvisionerJobStatusPending || - job.JobStatus == database.ProvisionerJobStatusRunning || - job.JobStatus == database.ProvisionerJobStatusCanceling { - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: "Cannot update prompt while workspace build is active.", - Detail: fmt.Sprintf("Current build status: %s. Please wait for the build to complete or stop the workspace.", job.JobStatus), - }) - return - } - - // Also block if workspace is running (successful start transition) - if job.JobStatus == database.ProvisionerJobStatusSucceeded && build.Transition == database.WorkspaceTransitionStart { - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: "Cannot update prompt while workspace is running.", - Detail: "Please stop the workspace before updating the prompt.", + updatedTask, err = api.Database.UpdateTaskPrompt(ctx, database.UpdateTaskPromptParams{ + ID: task.ID, + Prompt: req.Input, + }) + if err != nil { + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update task input.", + Detail: err.Error(), }) - return } - } - updatedTask, err := api.Database.UpdateTaskPrompt(ctx, database.UpdateTaskPromptParams{ - ID: task.ID, - Prompt: req.Prompt, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to update task prompt.", - Detail: err.Error(), - }) + return nil + }, nil); err != nil { + httperror.WriteResponseError(ctx, rw, err) return } + aReq.New = updatedTask httpapi.Write(ctx, rw, http.StatusNoContent, nil) @@ -698,7 +667,7 @@ func (api *API) taskUpdatePrompt(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param task path string true "Task ID" format(uuid) +// @Param task path string true "Task ID, or task name" // @Param request body codersdk.TaskSendRequest true "Task input request" // @Success 204 "Input sent successfully" // @Router /api/experimental/tasks/{user}/{task}/send [post] @@ -772,7 +741,7 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param task path string true "Task ID" format(uuid) +// @Param task path string true "Task ID, or task name" // @Success 200 {object} codersdk.TaskLogsResponse // @Router /api/experimental/tasks/{user}/{task}/logs [get] // diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index b2aa6bb97757c..e636e1e249f84 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -769,8 +769,8 @@ func TestTasks(t *testing.T) { require.Equal(t, codersdk.WorkspaceStatusStopped, build.Status) // Now update prompt should succeed - err = exp.UpdateTaskPrompt(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskPromptRequest{ - Prompt: "Updated prompt after stop", + err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ + Input: "Updated prompt after stop", }) require.NoError(t, err) }) @@ -814,8 +814,8 @@ func TestTasks(t *testing.T) { require.Equal(t, codersdk.WorkspaceStatusCanceled, startBuild.Status) // Now update prompt should succeed - err = exp.UpdateTaskPrompt(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskPromptRequest{ - Prompt: "Updated prompt after cancel", + err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ + Input: "Updated prompt after cancel", }) require.NoError(t, err) }) @@ -850,52 +850,14 @@ func TestTasks(t *testing.T) { require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) // Attempt to update prompt should fail with 409 Conflict - err = exp.UpdateTaskPrompt(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskPromptRequest{ - Prompt: "Should fail", + err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ + Input: "Should fail", }) require.Error(t, err) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusConflict, apiErr.StatusCode()) - require.Contains(t, apiErr.Message, "running") - }) - - t.Run("EmptyPrompt", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - template := createAITemplate(t, client, user) - - // Create a task with workspace - exp := codersdk.NewExperimentalClient(client) - task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ - TemplateVersionID: template.ActiveVersionID, - Input: "initial prompt", - }) - require.NoError(t, err) - require.True(t, task.WorkspaceID.Valid) - - // Wait for workspace to be running - workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) - require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - - // Stop the workspace so we can test the empty prompt validation - build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) - - // Attempt to update with empty prompt should fail with 400 Bad Request - err = exp.UpdateTaskPrompt(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskPromptRequest{ - Prompt: "", - }) - require.Error(t, err) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) - require.Contains(t, apiErr.Message, "required") + require.Contains(t, apiErr.Message, "Cannot update input") }) t.Run("NonExistentTask", func(t *testing.T) { @@ -908,8 +870,8 @@ func TestTasks(t *testing.T) { exp := codersdk.NewExperimentalClient(client) // Attempt to update prompt for non-existent task - err := exp.UpdateTaskPrompt(ctx, user.UserID.String(), uuid.New(), codersdk.UpdateTaskPromptRequest{ - Prompt: "Should fail", + err := exp.UpdateTaskInput(ctx, user.UserID.String(), uuid.New(), codersdk.UpdateTaskInputRequest{ + Input: "Should fail", }) require.Error(t, err) var apiErr *codersdk.Error @@ -947,8 +909,8 @@ func TestTasks(t *testing.T) { // Attempt to update prompt as another user should fail with 404 Not Found otherExp := codersdk.NewExperimentalClient(anotherUser) - err = otherExp.UpdateTaskPrompt(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskPromptRequest{ - Prompt: "Should fail - unauthorized", + err = otherExp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ + Input: "Should fail - unauthorized", }) require.Error(t, err) var apiErr *codersdk.Error diff --git a/coderd/coderd.go b/coderd/coderd.go index 3900553d217c0..0747f0120f5bc 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1035,7 +1035,7 @@ func New(options *Options) *API { r.Use(httpmw.ExtractTaskParam(options.Database)) r.Get("/", api.taskGet) r.Delete("/", api.taskDelete) - r.Patch("/prompt", api.taskUpdatePrompt) + r.Patch("/input", api.taskUpdateInput) r.Post("/send", api.taskSend) r.Get("/logs", api.taskLogs) }) diff --git a/coderd/database/queries/tasks.sql b/coderd/database/queries/tasks.sql index 5df792f294163..32b048f35e455 100644 --- a/coderd/database/queries/tasks.sql +++ b/coderd/database/queries/tasks.sql @@ -67,7 +67,8 @@ RETURNING *; -- name: UpdateTaskPrompt :one -UPDATE tasks +UPDATE + tasks SET prompt = @prompt::text WHERE diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 85478db71d1ec..d2251a3a60d7c 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -352,18 +352,18 @@ func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid. return nil } -// UpdateTaskPromptRequest is used to update a task's prompt. +// UpdateTaskInputRequest is used to update a task's prompt. // // Experimental: This type is experimental and may change in the future. -type UpdateTaskPromptRequest struct { - Prompt string `json:"prompt"` +type UpdateTaskInputRequest struct { + Input string `json:"prompt"` } -// UpdateTaskPrompt updates the task's prompt. +// UpdateTaskInput updates the task's input. // // Experimental: This method is experimental and may change in the future. -func (c *ExperimentalClient) UpdateTaskPrompt(ctx context.Context, user string, id uuid.UUID, req UpdateTaskPromptRequest) error { - res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/tasks/%s/%s/prompt", user, id.String()), req) +func (c *ExperimentalClient) UpdateTaskInput(ctx context.Context, user string, id uuid.UUID, req UpdateTaskInputRequest) error { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/tasks/%s/%s/input", user, id.String()), req) if err != nil { return err } From b8d3c21be851075dcbfcfc3dd90b2908e826316f Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 18 Nov 2025 17:39:24 +0000 Subject: [PATCH 03/10] chore: oops --- coderd/aitasks.go | 6 +-- coderd/apidoc/docs.go | 65 +++++++++++------------ coderd/apidoc/swagger.json | 65 +++++++++++------------ coderd/database/queries.sql.go | 3 +- docs/reference/api/experimental.md | 84 +++++++++++++++--------------- docs/reference/api/schemas.md | 2 +- site/src/api/typesGenerated.ts | 4 +- 7 files changed, 110 insertions(+), 119 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index b22fd3e29f313..dc5571446164f 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -623,8 +623,8 @@ func (api *API) taskUpdateInput(rw http.ResponseWriter, r *http.Request) { } var updatedTask database.TaskTable - if err := api.Database.InTx(func(s database.Store) error { - task, err := api.Database.GetTaskByID(ctx, task.ID) + if err := api.Database.InTx(func(tx database.Store) error { + task, err := tx.GetTaskByID(ctx, task.ID) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to fetch task.", @@ -639,7 +639,7 @@ func (api *API) taskUpdateInput(rw http.ResponseWriter, r *http.Request) { }) } - updatedTask, err = api.Database.UpdateTaskPrompt(ctx, database.UpdateTaskPromptParams{ + updatedTask, err = tx.UpdateTaskPrompt(ctx, database.UpdateTaskPromptParams{ ID: task.ID, Prompt: req.Input, }) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d54487f7a8c59..a3b3009eac8cb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -228,8 +228,7 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -265,8 +264,7 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -279,8 +277,8 @@ const docTemplate = `{ } } }, - "/api/experimental/tasks/{user}/{task}/logs": { - "get": { + "/api/experimental/tasks/{user}/{task}/input": { + "patch": { "security": [ { "CoderSessionToken": [] @@ -289,8 +287,8 @@ const docTemplate = `{ "tags": [ "Experimental" ], - "summary": "Get AI task logs", - "operationId": "get-task-logs", + "summary": "Update AI task input", + "operationId": "update-task-input", "parameters": [ { "type": "string", @@ -301,25 +299,30 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true + }, + { + "description": "Update task input request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTaskInputRequest" + } } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.TaskLogsResponse" - } + "204": { + "description": "No Content" } } } }, - "/api/experimental/tasks/{user}/{task}/prompt": { - "patch": { + "/api/experimental/tasks/{user}/{task}/logs": { + "get": { "security": [ { "CoderSessionToken": [] @@ -328,8 +331,8 @@ const docTemplate = `{ "tags": [ "Experimental" ], - "summary": "Update AI task prompt", - "operationId": "update-task-prompt", + "summary": "Get AI task logs", + "operationId": "get-task-logs", "parameters": [ { "type": "string", @@ -340,25 +343,18 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true - }, - { - "description": "Update task prompt request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.UpdateTaskPromptRequest" - } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.TaskLogsResponse" + } } } } @@ -385,8 +381,7 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -19007,7 +19002,7 @@ const docTemplate = `{ } } }, - "codersdk.UpdateTaskPromptRequest": { + "codersdk.UpdateTaskInputRequest": { "type": "object", "properties": { "prompt": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b19899eb8af5c..a02f55ac8b43a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -198,8 +198,7 @@ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -233,8 +232,7 @@ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -247,16 +245,16 @@ } } }, - "/api/experimental/tasks/{user}/{task}/logs": { - "get": { + "/api/experimental/tasks/{user}/{task}/input": { + "patch": { "security": [ { "CoderSessionToken": [] } ], "tags": ["Experimental"], - "summary": "Get AI task logs", - "operationId": "get-task-logs", + "summary": "Update AI task input", + "operationId": "update-task-input", "parameters": [ { "type": "string", @@ -267,33 +265,38 @@ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true + }, + { + "description": "Update task input request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTaskInputRequest" + } } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.TaskLogsResponse" - } + "204": { + "description": "No Content" } } } }, - "/api/experimental/tasks/{user}/{task}/prompt": { - "patch": { + "/api/experimental/tasks/{user}/{task}/logs": { + "get": { "security": [ { "CoderSessionToken": [] } ], "tags": ["Experimental"], - "summary": "Update AI task prompt", - "operationId": "update-task-prompt", + "summary": "Get AI task logs", + "operationId": "get-task-logs", "parameters": [ { "type": "string", @@ -304,25 +307,18 @@ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true - }, - { - "description": "Update task prompt request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.UpdateTaskPromptRequest" - } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.TaskLogsResponse" + } } } } @@ -347,8 +343,7 @@ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -17436,7 +17431,7 @@ } } }, - "codersdk.UpdateTaskPromptRequest": { + "codersdk.UpdateTaskInputRequest": { "type": "object", "properties": { "prompt": { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1a2d6fda2802f..2f65019760902 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13284,7 +13284,8 @@ func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task } const updateTaskPrompt = `-- name: UpdateTaskPrompt :one -UPDATE tasks +UPDATE + tasks SET prompt = $1::text WHERE diff --git a/docs/reference/api/experimental.md b/docs/reference/api/experimental.md index e044a6cc6d165..fb8c901d6d1f6 100644 --- a/docs/reference/api/experimental.md +++ b/docs/reference/api/experimental.md @@ -90,10 +90,10 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task} ### Parameters -| Name | In | Type | Required | Description | -|--------|------|--------------|----------|-------------------------------------------------------| -| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `task` | path | string(uuid) | true | Task ID | +| Name | In | Type | Required | Description | +|--------|------|--------|----------|-------------------------------------------------------| +| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | +| `task` | path | string | true | Task ID, or task name | ### Example responses @@ -121,10 +121,10 @@ curl -X DELETE http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{ta ### Parameters -| Name | In | Type | Required | Description | -|--------|------|--------------|----------|-------------------------------------------------------| -| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `task` | path | string(uuid) | true | Task ID | +| Name | In | Type | Required | Description | +|--------|------|--------|----------|-------------------------------------------------------| +| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | +| `task` | path | string | true | Task ID, or task name | ### Responses @@ -134,72 +134,72 @@ curl -X DELETE http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{ta To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get AI task logs +## Update AI task input ### Code samples ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/logs \ - -H 'Accept: */*' \ +curl -X PATCH http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/input \ + -H 'Content-Type: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/experimental/tasks/{user}/{task}/logs` +`PATCH /api/experimental/tasks/{user}/{task}/input` -### Parameters +> Body parameter -| Name | In | Type | Required | Description | -|--------|------|--------------|----------|-------------------------------------------------------| -| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `task` | path | string(uuid) | true | Task ID | +```json +{ + "prompt": "string" +} +``` -### Example responses +### Parameters -> 200 Response +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------|----------|-------------------------------------------------------| +| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | +| `task` | path | string | true | Task ID, or task name | +| `body` | body | [codersdk.UpdateTaskInputRequest](schemas.md#codersdkupdatetaskinputrequest) | true | Update task input request | ### Responses -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TaskLogsResponse](schemas.md#codersdktasklogsresponse) | +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Update AI task prompt +## Get AI task logs ### Code samples ```shell # Example request using curl -curl -X PATCH http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/prompt \ - -H 'Content-Type: application/json' \ +curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/logs \ + -H 'Accept: */*' \ -H 'Coder-Session-Token: API_KEY' ``` -`PATCH /api/experimental/tasks/{user}/{task}/prompt` +`GET /api/experimental/tasks/{user}/{task}/logs` -> Body parameter +### Parameters -```json -{ - "prompt": "string" -} -``` +| Name | In | Type | Required | Description | +|--------|------|--------|----------|-------------------------------------------------------| +| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | +| `task` | path | string | true | Task ID, or task name | -### Parameters +### Example responses -| Name | In | Type | Required | Description | -|--------|------|--------------------------------------------------------------------------------|----------|-------------------------------------------------------| -| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `task` | path | string(uuid) | true | Task ID | -| `body` | body | [codersdk.UpdateTaskPromptRequest](schemas.md#codersdkupdatetaskpromptrequest) | true | Update task prompt request | +> 200 Response ### Responses -| Status | Meaning | Description | Schema | -|--------|-----------------------------------------------------------------|-------------|--------| -| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TaskLogsResponse](schemas.md#codersdktasklogsresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -229,7 +229,7 @@ curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task | Name | In | Type | Required | Description | |--------|------|----------------------------------------------------------------|----------|-------------------------------------------------------| | `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `task` | path | string(uuid) | true | Task ID | +| `task` | path | string | true | Task ID, or task name | | `body` | body | [codersdk.TaskSendRequest](schemas.md#codersdktasksendrequest) | true | Task input request | ### Responses diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 090cc3ef30387..44f607c1c90ab 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9155,7 +9155,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |---------|-----------------|----------|--------------|-------------| | `roles` | array of string | false | | | -## codersdk.UpdateTaskPromptRequest +## codersdk.UpdateTaskInputRequest ```json { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fddb0854dd132..ef398dfad23dd 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5360,11 +5360,11 @@ export interface UpdateRoles { // From codersdk/aitasks.go /** - * UpdateTaskPromptRequest is used to update a task's prompt. + * UpdateTaskInputRequest is used to update a task's prompt. * * Experimental: This type is experimental and may change in the future. */ -export interface UpdateTaskPromptRequest { +export interface UpdateTaskInputRequest { readonly prompt: string; } From 9c82a1fd4385c09281073f8871b11597b3bd6df5 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 20 Nov 2025 10:29:02 +0000 Subject: [PATCH 04/10] chore: feedback again --- coderd/aitasks.go | 8 ++++++++ coderd/aitasks_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index dc5571446164f..538ee83d7f23f 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "slices" + "strings" "time" "github.com/google/uuid" @@ -622,6 +623,13 @@ func (api *API) taskUpdateInput(rw http.ResponseWriter, r *http.Request) { return } + if strings.TrimSpace(req.Input) == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Input is required, and should not be empty.", + }) + return + } + var updatedTask database.TaskTable if err := api.Database.InTx(func(tx database.Store) error { task, err := tx.GetTaskByID(ctx, task.ID) diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index e636e1e249f84..75c3828508a95 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -820,6 +820,44 @@ func TestTasks(t *testing.T) { require.NoError(t, err) }) + t.Run("EmptyPrompt", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + // Create a task with workspace + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "initial prompt", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + // Wait for workspace to be running + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Stop the workspace so we can test the empty prompt validation + build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + // Attempt to update with empty prompt should fail with 400 Bad Request + err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ + Input: "", + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "required") + }) + t.Run("Running", func(t *testing.T) { t.Parallel() From feb5cc76d973a6190d80f7dfc5613099dfcc781b Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 20 Nov 2025 13:41:58 +0000 Subject: [PATCH 05/10] chore: address feedback --- coderd/aitasks_test.go | 60 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 75c3828508a95..23fbf59551d7c 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -735,8 +735,8 @@ func TestTasks(t *testing.T) { }) }) - t.Run("UpdatePrompt", func(t *testing.T) { - t.Run("Stopped", func(t *testing.T) { + t.Run("UpdateInput", func(t *testing.T) { + t.Run("WhenPaused", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) @@ -768,6 +768,11 @@ func TestTasks(t *testing.T) { require.NoError(t, err) require.Equal(t, codersdk.WorkspaceStatusStopped, build.Status) + // Verify task status is "Paused" + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusPaused, task.Status) + // Now update prompt should succeed err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ Input: "Updated prompt after stop", @@ -775,7 +780,7 @@ func TestTasks(t *testing.T) { require.NoError(t, err) }) - t.Run("Canceled", func(t *testing.T) { + t.Run("WhenError", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) @@ -813,6 +818,11 @@ func TestTasks(t *testing.T) { require.NoError(t, err) require.Equal(t, codersdk.WorkspaceStatusCanceled, startBuild.Status) + // Verify task status is "Error" + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusError, task.Status) + // Now update prompt should succeed err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ Input: "Updated prompt after cancel", @@ -858,7 +868,7 @@ func TestTasks(t *testing.T) { require.Contains(t, apiErr.Message, "required") }) - t.Run("Running", func(t *testing.T) { + t.Run("WhenInitializing", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) @@ -887,6 +897,11 @@ func TestTasks(t *testing.T) { require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status) require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) + // Verify task status is "Active" + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusInitializing, task.Status) + // Attempt to update prompt should fail with 409 Conflict err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ Input: "Should fail", @@ -917,6 +932,43 @@ func TestTasks(t *testing.T) { require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) }) + t.Run("DeletedTask", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitShort) + + template := createAITemplate(t, client, user) + + // Create a task with workspace + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "initial prompt", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + // Wait for workspace to be running + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Delete the task + err = exp.DeleteTask(ctx, codersdk.Me, task.ID) + require.NoError(t, err) + + // Attempt to update prompt for deleted task + err = exp.UpdateTaskInput(ctx, user.UserID.String(), uuid.New(), codersdk.UpdateTaskInputRequest{ + Input: "Should fail", + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + t.Run("UnauthorizedUser", func(t *testing.T) { t.Parallel() From d370e3c471c5fa57c2392e6dba1ce0c73abdfe82 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 24 Nov 2025 13:59:24 +0000 Subject: [PATCH 06/10] chore: remove jank comment, workspace -> task --- coderd/database/modelqueries_internal_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/coderd/database/modelqueries_internal_test.go b/coderd/database/modelqueries_internal_test.go index 1ee4ab3fd5f03..9e84324b72ee8 100644 --- a/coderd/database/modelqueries_internal_test.go +++ b/coderd/database/modelqueries_internal_test.go @@ -76,11 +76,12 @@ func TestTaskTableConvert(t *testing.T) { }, } - // This feels a bit janky, but it works. - // If you use 'PopulateStruct' to create 2 workspaces, using the same + // Copies the approach taken by TestWorkspaceTableConvert. + // + // If you use 'PopulateStruct' to create 2 tasks, using the same // "random" values for each type. Then they should be identical. // - // So if 'workspace.WorkspaceTable()' was missing any fields in its + // So if 'task.TaskTable()' was missing any fields in its // conversion, the comparison would fail. var task Task From f9749f60db2608db658b378f2f398b9f02054f9e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 24 Nov 2025 13:59:55 +0000 Subject: [PATCH 07/10] chore: prompt -> input --- codersdk/aitasks.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index d2251a3a60d7c..ea2297c663ca0 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -352,11 +352,11 @@ func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid. return nil } -// UpdateTaskInputRequest is used to update a task's prompt. +// UpdateTaskInputRequest is used to update a task's input. // // Experimental: This type is experimental and may change in the future. type UpdateTaskInputRequest struct { - Input string `json:"prompt"` + Input string `json:"input"` } // UpdateTaskInput updates the task's input. From fbeb95c0735b72d0b12cc3cda0267cc9785f93ac Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 24 Nov 2025 16:43:15 +0000 Subject: [PATCH 08/10] chore: move tests into table --- coderd/aitasks.go | 2 +- coderd/aitasks_test.go | 328 ++++++++++++++++------------------------- 2 files changed, 128 insertions(+), 202 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index ae3fd4b6984d5..af87325931b79 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -686,7 +686,7 @@ func (api *API) taskUpdateInput(rw http.ResponseWriter, r *http.Request) { if strings.TrimSpace(req.Input) == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Input is required, and should not be empty.", + Message: "Task input is required.", }) return } diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 63df2472bf7f9..abe2028f8b323 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -23,6 +23,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/util/slice" @@ -740,182 +741,144 @@ func TestTasks(t *testing.T) { }) t.Run("UpdateInput", func(t *testing.T) { - t.Run("WhenPaused", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - template := createAITemplate(t, client, user) - - // Create a task with workspace - exp := codersdk.NewExperimentalClient(client) - task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ - TemplateVersionID: template.ActiveVersionID, - Input: "initial prompt", - }) - require.NoError(t, err) - require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") - - // Wait for the initial workspace build to complete (start transition) - workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) - require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - - // Stop the workspace - build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) - - // Verify build succeeded (stop transition completed) - build, err = client.WorkspaceBuild(ctx, build.ID) - require.NoError(t, err) - require.Equal(t, codersdk.WorkspaceStatusStopped, build.Status) - - // Verify task status is "Paused" - task, err = exp.TaskByID(ctx, task.ID) - require.NoError(t, err) - require.Equal(t, codersdk.TaskStatusPaused, task.Status) - - // Now update prompt should succeed - err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ - Input: "Updated prompt after stop", - }) - require.NoError(t, err) - }) - - t.Run("WhenError", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - template := createAITemplate(t, client, user) - - // Create a task with workspace - exp := codersdk.NewExperimentalClient(client) - task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ - TemplateVersionID: template.ActiveVersionID, - Input: "initial prompt", - }) - require.NoError(t, err) - require.True(t, task.WorkspaceID.Valid) - - // Wait for the initial workspace build to complete - workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) - require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - - // Stop the workspace first - stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, stopBuild.ID) - - // Start a new build and cancel it - startBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) - err = client.CancelWorkspaceBuild(ctx, startBuild.ID, codersdk.CancelWorkspaceBuildParams{}) - require.NoError(t, err) - - // Wait for cancellation to complete - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, startBuild.ID) - startBuild, err = client.WorkspaceBuild(ctx, startBuild.ID) - require.NoError(t, err) - require.Equal(t, codersdk.WorkspaceStatusCanceled, startBuild.Status) - - // Verify task status is "Error" - task, err = exp.TaskByID(ctx, task.ID) - require.NoError(t, err) - require.Equal(t, codersdk.TaskStatusError, task.Status) - - // Now update prompt should succeed - err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ - Input: "Updated prompt after cancel", - }) - require.NoError(t, err) - }) - - t.Run("EmptyPrompt", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - template := createAITemplate(t, client, user) - - // Create a task with workspace - exp := codersdk.NewExperimentalClient(client) - task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ - TemplateVersionID: template.ActiveVersionID, - Input: "initial prompt", - }) - require.NoError(t, err) - require.True(t, task.WorkspaceID.Valid) + tests := []struct { + name string + disableProvisioner bool + transition database.WorkspaceTransition + cancelTransition bool + deleteTask bool + taskInput string + wantStatus codersdk.TaskStatus + wantErr string + wantErrStatusCode int + }{ + { + name: "TaskStatusInitializing", + // We want to disable the provisioner so that the task + // never gets provisioned (ensuring it stays in Initializing). + disableProvisioner: true, + taskInput: "Valid prompt", + wantStatus: codersdk.TaskStatusInitializing, + wantErr: "Cannot update input", + wantErrStatusCode: http.StatusConflict, + }, + { + name: "TaskStatusPaused", + transition: database.WorkspaceTransitionStop, + taskInput: "Valid prompt", + wantStatus: codersdk.TaskStatusPaused, + }, + { + name: "TaskStatusError", + transition: database.WorkspaceTransitionStart, + cancelTransition: true, + taskInput: "Valid prompt", + wantStatus: codersdk.TaskStatusError, + }, + { + name: "EmptyPrompt", + transition: database.WorkspaceTransitionStop, + // We want to ensure an empty prompt is rejected. + taskInput: "", + wantStatus: codersdk.TaskStatusPaused, + wantErr: "Task input is required.", + wantErrStatusCode: http.StatusBadRequest, + }, + { + name: "TaskDeleted", + disableProvisioner: true, + deleteTask: true, + taskInput: "Valid prompt", + wantErr: httpapi.ResourceNotFoundResponse.Message, + wantErrStatusCode: http.StatusNotFound, + }, + } - // Wait for workspace to be running - workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) - require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - // Stop the workspace so we can test the empty prompt validation - build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + client, provisioner := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) - // Attempt to update with empty prompt should fail with 400 Bad Request - err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ - Input: "", - }) - require.Error(t, err) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) - require.Contains(t, apiErr.Message, "required") - }) + template := createAITemplate(t, client, user) - t.Run("WhenInitializing", func(t *testing.T) { - t.Parallel() + if tt.disableProvisioner { + provisioner.Close() + } - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) + // Given: We create a task + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "initial prompt", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") - template := createAITemplate(t, client, user) + if !tt.disableProvisioner { + // Given: The Task is running + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Given: We transition the task's workspace + build := coderdtest.CreateWorkspaceBuild(t, client, workspace, tt.transition) + if tt.cancelTransition { + // Given: We cancel the workspace build + err := client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{}) + require.NoError(t, err) + + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + // Then: We expect it to be canceled + build, err = client.WorkspaceBuild(ctx, build.ID) + require.Equal(t, codersdk.WorkspaceStatusCanceled, build.Status) + } else { + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + } + } - // Create a task with workspace - exp := codersdk.NewExperimentalClient(client) - task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ - TemplateVersionID: template.ActiveVersionID, - Input: "initial prompt", - }) - require.NoError(t, err) - require.True(t, task.WorkspaceID.Valid) + if tt.deleteTask { + err = exp.DeleteTask(ctx, codersdk.Me, task.ID) + require.NoError(t, err) + } else { + // Given: Task has expected status + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.Equal(t, tt.wantStatus, task.Status) + } - // Wait for workspace to be running - workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) - require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + // When: We attempt to update the task input + err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ + Input: tt.taskInput, + }) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) - // Verify workspace is running (start transition succeeded) - workspace, err = client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status) - require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) + if tt.wantErrStatusCode != 0 { + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, tt.wantStatus, apiErr.StatusCode()) + } - // Verify task status is "Active" - task, err = exp.TaskByID(ctx, task.ID) - require.NoError(t, err) - require.Equal(t, codersdk.TaskStatusInitializing, task.Status) + // Then: We expect the input to **not** be updated + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.NotEqual(t, tt.taskInput, task.InitialPrompt) + } else { + require.NoError(t, err) - // Attempt to update prompt should fail with 409 Conflict - err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ - Input: "Should fail", + if !tt.deleteTask { + // Then: We expect the input to be updated + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.Equal(t, tt.taskInput, task.InitialPrompt) + } + } }) - require.Error(t, err) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusConflict, apiErr.StatusCode()) - require.Contains(t, apiErr.Message, "Cannot update input") - }) + } t.Run("NonExistentTask", func(t *testing.T) { t.Parallel() @@ -936,43 +899,6 @@ func TestTasks(t *testing.T) { require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) }) - t.Run("DeletedTask", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitShort) - - template := createAITemplate(t, client, user) - - // Create a task with workspace - exp := codersdk.NewExperimentalClient(client) - task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ - TemplateVersionID: template.ActiveVersionID, - Input: "initial prompt", - }) - require.NoError(t, err) - require.True(t, task.WorkspaceID.Valid) - - // Wait for workspace to be running - workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) - require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - - // Delete the task - err = exp.DeleteTask(ctx, codersdk.Me, task.ID) - require.NoError(t, err) - - // Attempt to update prompt for deleted task - err = exp.UpdateTaskInput(ctx, user.UserID.String(), uuid.New(), codersdk.UpdateTaskInputRequest{ - Input: "Should fail", - }) - require.Error(t, err) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) - }) - t.Run("UnauthorizedUser", func(t *testing.T) { t.Parallel() From a0e0efdb2a1b46ca60a15a942a9d03a0cd1db70f Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 25 Nov 2025 10:43:31 +0000 Subject: [PATCH 09/10] chore: oops --- coderd/aitasks_test.go | 15 ++++++++------- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- docs/reference/api/experimental.md | 2 +- docs/reference/api/schemas.md | 8 ++++---- site/src/api/typesGenerated.ts | 4 ++-- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index abe2028f8b323..8094c5c43400e 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -785,12 +785,12 @@ func TestTasks(t *testing.T) { wantErrStatusCode: http.StatusBadRequest, }, { - name: "TaskDeleted", - disableProvisioner: true, - deleteTask: true, - taskInput: "Valid prompt", - wantErr: httpapi.ResourceNotFoundResponse.Message, - wantErrStatusCode: http.StatusNotFound, + name: "TaskDeleted", + transition: database.WorkspaceTransitionStop, + deleteTask: true, + taskInput: "Valid prompt", + wantErr: httpapi.ResourceNotFoundResponse.Message, + wantErrStatusCode: http.StatusNotFound, }, } @@ -834,6 +834,7 @@ func TestTasks(t *testing.T) { // Then: We expect it to be canceled build, err = client.WorkspaceBuild(ctx, build.ID) + require.NoError(t, err) require.Equal(t, codersdk.WorkspaceStatusCanceled, build.Status) } else { coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) @@ -860,7 +861,7 @@ func TestTasks(t *testing.T) { if tt.wantErrStatusCode != 0 { var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, tt.wantStatus, apiErr.StatusCode()) + require.Equal(t, tt.wantErrStatusCode, apiErr.StatusCode()) } // Then: We expect the input to **not** be updated diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a3b3009eac8cb..449127ee5577b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19005,7 +19005,7 @@ const docTemplate = `{ "codersdk.UpdateTaskInputRequest": { "type": "object", "properties": { - "prompt": { + "input": { "type": "string" } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a02f55ac8b43a..9a7503c6b60fc 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -17434,7 +17434,7 @@ "codersdk.UpdateTaskInputRequest": { "type": "object", "properties": { - "prompt": { + "input": { "type": "string" } } diff --git a/docs/reference/api/experimental.md b/docs/reference/api/experimental.md index fb8c901d6d1f6..206cf273b532a 100644 --- a/docs/reference/api/experimental.md +++ b/docs/reference/api/experimental.md @@ -151,7 +151,7 @@ curl -X PATCH http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{tas ```json { - "prompt": "string" + "input": "string" } ``` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 44f607c1c90ab..d564725577d2b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9159,15 +9159,15 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ```json { - "prompt": "string" + "input": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|----------|--------|----------|--------------|-------------| -| `prompt` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------|--------|----------|--------------|-------------| +| `input` | string | false | | | ## codersdk.UpdateTemplateACL diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ef398dfad23dd..07ffa2d679ead 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5360,12 +5360,12 @@ export interface UpdateRoles { // From codersdk/aitasks.go /** - * UpdateTaskInputRequest is used to update a task's prompt. + * UpdateTaskInputRequest is used to update a task's input. * * Experimental: This type is experimental and may change in the future. */ export interface UpdateTaskInputRequest { - readonly prompt: string; + readonly input: string; } // From codersdk/templates.go From 3c7204c25839804fdf3c16f941c6c743b8e2d8a5 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 25 Nov 2025 10:51:13 +0000 Subject: [PATCH 10/10] chore: gate on task being paused --- coderd/aitasks.go | 4 ++-- coderd/aitasks_test.go | 24 ++++++++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index af87325931b79..dccdf98bf2164 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -701,9 +701,9 @@ func (api *API) taskUpdateInput(rw http.ResponseWriter, r *http.Request) { }) } - if task.Status == database.TaskStatusInitializing || task.Status == database.TaskStatusActive { + if task.Status != database.TaskStatusPaused { return httperror.NewResponseError(http.StatusConflict, codersdk.Response{ - Message: "Cannot update input while task is initializing or active.", + Message: "Unable to update task input, task must be paused.", Detail: "Please stop the task's workspace before updating the input.", }) } diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 8094c5c43400e..b9f82656884f2 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -759,7 +759,7 @@ func TestTasks(t *testing.T) { disableProvisioner: true, taskInput: "Valid prompt", wantStatus: codersdk.TaskStatusInitializing, - wantErr: "Cannot update input", + wantErr: "Unable to update", wantErrStatusCode: http.StatusConflict, }, { @@ -769,11 +769,13 @@ func TestTasks(t *testing.T) { wantStatus: codersdk.TaskStatusPaused, }, { - name: "TaskStatusError", - transition: database.WorkspaceTransitionStart, - cancelTransition: true, - taskInput: "Valid prompt", - wantStatus: codersdk.TaskStatusError, + name: "TaskStatusError", + transition: database.WorkspaceTransitionStart, + cancelTransition: true, + taskInput: "Valid prompt", + wantStatus: codersdk.TaskStatusError, + wantErr: "Unable to update", + wantErrStatusCode: http.StatusConflict, }, { name: "EmptyPrompt", @@ -864,10 +866,12 @@ func TestTasks(t *testing.T) { require.Equal(t, tt.wantErrStatusCode, apiErr.StatusCode()) } - // Then: We expect the input to **not** be updated - task, err = exp.TaskByID(ctx, task.ID) - require.NoError(t, err) - require.NotEqual(t, tt.taskInput, task.InitialPrompt) + if !tt.deleteTask { + // Then: We expect the input to **not** be updated + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.NotEqual(t, tt.taskInput, task.InitialPrompt) + } } else { require.NoError(t, err)