package cli_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "slices" "strings" "sync" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" agentapisdk "github.com/coder/agentapi-sdk-go" "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" ) // This test performs an integration-style test for tasks functionality. // //nolint:tparallel // The sub-tests of this test must be run sequentially. func Test_Tasks(t *testing.T) { t.Parallel() // Given: a template configured for tasks var ( ctx = testutil.Context(t, testutil.WaitLong) client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner = coderdtest.CreateFirstUser(t, client) userClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) initMsg = agentapisdk.Message{ Content: "test task input for " + t.Name(), Id: 0, Role: "user", Time: time.Now().UTC(), } authToken = uuid.NewString() echoAgentAPI = startFakeAgentAPI(t, fakeAgentAPIEcho(ctx, t, initMsg, "hello")) taskTpl = createAITaskTemplate(t, client, owner.OrganizationID, withAgentToken(authToken), withSidebarURL(echoAgentAPI.URL())) taskName = strings.ReplaceAll(testutil.GetRandomName(t), "_", "-") ) for _, tc := range []struct { name string cmdArgs []string assertFn func(stdout string, userClient *codersdk.Client) }{ { name: "create task", cmdArgs: []string{"task", "create", "test task input for " + t.Name(), "--name", taskName, "--template", taskTpl.Name}, assertFn: func(stdout string, userClient *codersdk.Client) { require.Contains(t, stdout, taskName, "task name should be in output") }, }, { name: "list tasks after create", cmdArgs: []string{"task", "list", "--output", "json"}, assertFn: func(stdout string, userClient *codersdk.Client) { var tasks []codersdk.Task err := json.NewDecoder(strings.NewReader(stdout)).Decode(&tasks) require.NoError(t, err, "list output should unmarshal properly") require.Len(t, tasks, 1, "expected one task") require.Equal(t, taskName, tasks[0].Name, "task name should match") require.Equal(t, initMsg.Content, tasks[0].InitialPrompt, "initial prompt should match") require.True(t, tasks[0].WorkspaceID.Valid, "workspace should be created") // For the next test, we need to wait for the workspace to be healthy ws := coderdtest.MustWorkspace(t, userClient, tasks[0].WorkspaceID.UUID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { o.Client = agentClient }) coderdtest.NewWorkspaceAgentWaiter(t, userClient, tasks[0].WorkspaceID.UUID).WithContext(ctx).WaitFor(coderdtest.AgentsReady) // Report the task app as idle so that waitForTaskIdle // can proceed during the "send task message" step. require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ AppSlug: "task-sidebar", State: codersdk.WorkspaceAppStatusStateIdle, Message: "ready", })) }, }, { name: "get task status after create", cmdArgs: []string{"task", "status", taskName, "--output", "json"}, assertFn: func(stdout string, userClient *codersdk.Client) { var task codersdk.Task require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status") require.Equal(t, task.Name, taskName, "task name should match") require.Equal(t, codersdk.TaskStatusActive, task.Status, "task should be active") }, }, { name: "send task message", cmdArgs: []string{"task", "send", taskName, "hello"}, // Assertions for this happen in the fake agent API handler. }, { name: "read task logs", cmdArgs: []string{"task", "logs", taskName, "--output", "json"}, assertFn: func(stdout string, userClient *codersdk.Client) { var logs []codersdk.TaskLogEntry require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&logs), "should unmarshal task logs") require.Len(t, logs, 3, "should have 3 logs") require.Equal(t, logs[0].Content, initMsg.Content, "first message should be the init message") require.Equal(t, logs[0].Type, codersdk.TaskLogTypeInput, "first message should be an input") require.Equal(t, logs[1].Content, "hello", "second message should be the sent message") require.Equal(t, logs[1].Type, codersdk.TaskLogTypeInput, "second message should be an input") require.Equal(t, logs[2].Content, "hello", "third message should be the echoed message") require.Equal(t, logs[2].Type, codersdk.TaskLogTypeOutput, "third message should be an output") }, }, { name: "pause task", cmdArgs: []string{"task", "pause", taskName, "--yes"}, assertFn: func(stdout string, userClient *codersdk.Client) { require.Contains(t, stdout, "has been paused", "pause output should confirm task was paused") }, }, { name: "get task status after pause", cmdArgs: []string{"task", "status", taskName, "--output", "json"}, assertFn: func(stdout string, userClient *codersdk.Client) { var task codersdk.Task require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status") require.Equal(t, taskName, task.Name, "task name should match") require.Equal(t, codersdk.TaskStatusPaused, task.Status, "task should be paused") }, }, { name: "resume task", cmdArgs: []string{"task", "resume", taskName, "--yes"}, assertFn: func(stdout string, userClient *codersdk.Client) { require.Contains(t, stdout, "has been resumed", "resume output should confirm task was resumed") }, }, { name: "get task status after resume", cmdArgs: []string{"task", "status", taskName, "--output", "json"}, assertFn: func(stdout string, userClient *codersdk.Client) { var task codersdk.Task require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status") require.Equal(t, taskName, task.Name, "task name should match") require.Equal(t, codersdk.TaskStatusInitializing, task.Status, "task should be initializing after resume") }, }, { name: "delete task", cmdArgs: []string{"task", "delete", taskName, "--yes"}, assertFn: func(stdout string, userClient *codersdk.Client) { // The task should eventually no longer show up in the list of tasks testutil.Eventually(ctx, t, func(ctx context.Context) bool { tasks, err := userClient.Tasks(ctx, &codersdk.TasksFilter{}) if !assert.NoError(t, err) { return false } return slices.IndexFunc(tasks, func(task codersdk.Task) bool { return task.Name == taskName }) == -1 }, testutil.IntervalMedium) }, }, } { t.Logf("test case: %q", tc.name) var stdout strings.Builder inv, root := clitest.New(t, tc.cmdArgs...) inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) require.NoError(t, inv.WithContext(ctx).Run(), tc.name) if tc.assertFn != nil { tc.assertFn(stdout.String(), userClient) } } } func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Message, want ...string) map[string]http.HandlerFunc { t.Helper() var mmu sync.RWMutex msgs := []agentapisdk.Message{initMsg} wantCpy := make([]string, len(want)) copy(wantCpy, want) t.Cleanup(func() { mmu.Lock() defer mmu.Unlock() if !t.Failed() { assert.Empty(t, wantCpy, "not all expected messages received: missing %v", wantCpy) } }) writeAgentAPIError := func(w http.ResponseWriter, err error, status int) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(agentapisdk.ErrorModel{ Errors: ptr.Ref([]agentapisdk.ErrorDetail{ { Message: ptr.Ref(err.Error()), }, }), }) } return map[string]http.HandlerFunc{ "/status": func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(agentapisdk.GetStatusResponse{ Status: "stable", }) }, "/messages": func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") mmu.RLock() defer mmu.RUnlock() bs, err := json.Marshal(agentapisdk.GetMessagesResponse{ Messages: msgs, }) if err != nil { writeAgentAPIError(w, err, http.StatusBadRequest) return } _, _ = w.Write(bs) }, "/message": func(w http.ResponseWriter, r *http.Request) { mmu.Lock() defer mmu.Unlock() var params agentapisdk.PostMessageParams w.Header().Set("Content-Type", "application/json") err := json.NewDecoder(r.Body).Decode(¶ms) if !assert.NoError(t, err, "decode message") { writeAgentAPIError(w, err, http.StatusBadRequest) return } if len(wantCpy) == 0 { assert.Fail(t, "unexpected message", "received message %v, but no more expected messages", params) writeAgentAPIError(w, xerrors.New("no more expected messages"), http.StatusBadRequest) return } exp := wantCpy[0] wantCpy = wantCpy[1:] if !assert.Equal(t, exp, params.Content, "message content mismatch") { writeAgentAPIError(w, xerrors.New("unexpected message content: expected "+exp+", got "+params.Content), http.StatusBadRequest) return } msgs = append(msgs, agentapisdk.Message{ Id: int64(len(msgs) + 1), Content: params.Content, Role: agentapisdk.RoleUser, Time: time.Now().UTC(), }) msgs = append(msgs, agentapisdk.Message{ Id: int64(len(msgs) + 1), Content: params.Content, Role: agentapisdk.RoleAgent, Time: time.Now().UTC(), }) assert.NoError(t, json.NewEncoder(w).Encode(agentapisdk.PostMessageResponse{ Ok: true, })) }, } } // setupCLITaskTest creates a test workspace with an AI task template and agent, // with a fake agent API configured with the provided set of handlers. // Returns the user client and workspace. // setupCLITaskTestResult holds the return values from setupCLITaskTest. type setupCLITaskTestResult struct { ownerClient *codersdk.Client userClient *codersdk.Client task codersdk.Task agentToken string agent agent.Agent } func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) setupCLITaskTestResult { t.Helper() ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, ownerClient) userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) fakeAPI := startFakeAgentAPI(t, agentAPIHandlers) authToken := uuid.NewString() template := createAITaskTemplate(t, ownerClient, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken)) wantPrompt := "test prompt" task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ TemplateVersionID: template.ActiveVersionID, Input: wantPrompt, Name: "test-task", }) require.NoError(t, err) // Wait for the task's underlying workspace to be built. require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(authToken)) agt := agenttest.New(t, userClient.URL, authToken, func(o *agent.Options) { o.Client = agentClient }) coderdtest.NewWorkspaceAgentWaiter(t, userClient, workspace.ID). WaitFor(coderdtest.AgentsReady) // Report the task app as idle so that waitForTaskIdle can proceed. err = agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ AppSlug: "task-sidebar", State: codersdk.WorkspaceAppStatusStateIdle, Message: "ready", }) require.NoError(t, err) return setupCLITaskTestResult{ ownerClient: ownerClient, userClient: userClient, task: task, agentToken: authToken, agent: agt, } } // pauseTask pauses the task and waits for the stop build to complete. func pauseTask(ctx context.Context, t *testing.T, client *codersdk.Client, task codersdk.Task) { t.Helper() pauseResp, err := client.PauseTask(ctx, task.OwnerName, task.ID) require.NoError(t, err) require.NotNil(t, pauseResp.WorkspaceBuild) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID) } // resumeTask resumes the task waits for the start build to complete. The task // will be in "initializing" state after this returns because no agent is connected. func resumeTask(ctx context.Context, t *testing.T, client *codersdk.Client, task codersdk.Task) { t.Helper() resumeResp, err := client.ResumeTask(ctx, task.OwnerName, task.ID) require.NoError(t, err) require.NotNil(t, resumeResp.WorkspaceBuild) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, resumeResp.WorkspaceBuild.ID) } // setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot. // Note: We do not use IncludeProvisionerDaemon because these tests use dbfake to directly // set up database state and don't need actual provisioning. This also avoids potential // interference from the provisioner daemon polling for jobs. func setupCLITaskTestWithSnapshot(ctx context.Context, t *testing.T, status codersdk.TaskStatus, messages []agentapisdk.Message) (*codersdk.Client, codersdk.Task) { t.Helper() ownerClient, db := coderdtest.NewWithDatabase(t, nil) owner := coderdtest.CreateFirstUser(t, ownerClient) userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) ownerUser, err := ownerClient.User(ctx, owner.UserID.String()) require.NoError(t, err) ownerSubject := coderdtest.AuthzUserSubject(ownerUser) task := createTaskInStatus(t, db, owner.OrganizationID, user.ID, status) // Create snapshot envelope with agentapi format. envelope := coderd.TaskLogSnapshotEnvelope{ Format: "agentapi", Data: agentapisdk.GetMessagesResponse{ Messages: messages, }, } snapshotJSON, err := json.Marshal(envelope) require.NoError(t, err) // Insert snapshot into database. snapshotTime := time.Now() err = db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{ TaskID: task.ID, LogSnapshot: json.RawMessage(snapshotJSON), LogSnapshotCreatedAt: snapshotTime, }) require.NoError(t, err) return userClient, task } // setupCLITaskTestWithoutSnapshot creates a task in the specified status without a log snapshot. // Note: We do not use IncludeProvisionerDaemon because these tests use dbfake to directly // set up database state and don't need actual provisioning. This also avoids potential // interference from the provisioner daemon polling for jobs. func setupCLITaskTestWithoutSnapshot(t *testing.T, status codersdk.TaskStatus) (*codersdk.Client, codersdk.Task) { t.Helper() ownerClient, db := coderdtest.NewWithDatabase(t, nil) owner := coderdtest.CreateFirstUser(t, ownerClient) userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) task := createTaskInStatus(t, db, owner.OrganizationID, user.ID, status) return userClient, task } // createTaskInStatus creates a task in the specified status using dbfake. func createTaskInStatus(t *testing.T, db database.Store, orgID, ownerID uuid.UUID, status codersdk.TaskStatus) codersdk.Task { t.Helper() builder := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: orgID, OwnerID: ownerID, }). WithTask(database.TaskTable{ OrganizationID: orgID, OwnerID: ownerID, }, nil) switch status { case codersdk.TaskStatusPending: builder = builder.Pending() case codersdk.TaskStatusInitializing: builder = builder.Starting() case codersdk.TaskStatusPaused: builder = builder.Seed(database.WorkspaceBuild{ Transition: database.WorkspaceTransitionStop, }) default: require.Fail(t, "unsupported task status in test helper", "status: %s", status) } resp := builder.Do() return codersdk.Task{ ID: resp.Task.ID, Name: resp.Task.Name, OrganizationID: resp.Task.OrganizationID, OwnerID: resp.Task.OwnerID, WorkspaceID: resp.Task.WorkspaceID, Status: status, } } // createAITaskTemplate creates a template configured for AI tasks with a sidebar app. func createAITaskTemplate(t *testing.T, client *codersdk.Client, orgID uuid.UUID, opts ...aiTemplateOpt) codersdk.Template { t.Helper() opt := aiTemplateOpts{ authToken: uuid.NewString(), } for _, o := range opts { o(&opt) } taskAppID := uuid.New() version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ Parse: echo.ParseComplete, ProvisionGraph: []*proto.Response{ { Type: &proto.Response_Graph{ Graph: &proto.GraphComplete{ Resources: []*proto.Resource{ { Name: "example", Type: "aws_instance", Agents: []*proto.Agent{ { Id: uuid.NewString(), Name: "example", Auth: &proto.Agent_Token{ Token: opt.authToken, }, Apps: []*proto.App{ { Id: taskAppID.String(), Slug: "task-sidebar", DisplayName: "Task Sidebar", Url: opt.appURL, }, }, }, }, }, }, HasAiTasks: true, AiTasks: []*proto.AITask{ { AppId: taskAppID.String(), }, }, }, }, }, }, }) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, orgID, version.ID) return template } // fakeAgentAPI implements a fake AgentAPI HTTP server for testing. type fakeAgentAPI struct { t *testing.T server *httptest.Server handlers map[string]http.HandlerFunc called map[string]bool mu sync.Mutex } // startFakeAgentAPI starts an HTTP server that implements the AgentAPI endpoints. // handlers is a map of path -> handler function. func startFakeAgentAPI(t *testing.T, handlers map[string]http.HandlerFunc) *fakeAgentAPI { t.Helper() fake := &fakeAgentAPI{ t: t, handlers: handlers, called: make(map[string]bool), } mux := http.NewServeMux() // Register all provided handlers with call tracking for path, handler := range handlers { mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { fake.mu.Lock() fake.called[path] = true fake.mu.Unlock() handler(w, r) }) } knownEndpoints := []string{"/status", "/messages", "/message"} for _, endpoint := range knownEndpoints { if handlers[endpoint] == nil { endpoint := endpoint // capture loop variable mux.HandleFunc(endpoint, func(w http.ResponseWriter, r *http.Request) { t.Fatalf("unexpected call to %s %s - no handler defined", r.Method, endpoint) }) } } // Default handler for unknown endpoints should cause the test to fail. mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { t.Fatalf("unexpected call to %s %s - no handler defined", r.Method, r.URL.Path) }) fake.server = httptest.NewServer(mux) // Register cleanup to check that all defined handlers were called t.Cleanup(func() { fake.server.Close() fake.mu.Lock() for path := range handlers { if !fake.called[path] { t.Errorf("handler for %s was defined but never called", path) } } }) return fake } func (f *fakeAgentAPI) URL() string { return f.server.URL } type aiTemplateOpts struct { appURL string authToken string } type aiTemplateOpt func(*aiTemplateOpts) func withSidebarURL(url string) aiTemplateOpt { return func(o *aiTemplateOpts) { o.appURL = url } } func withAgentToken(token string) aiTemplateOpt { return func(o *aiTemplateOpts) { o.authToken = token } }