From 2c6cbf15e275bf29aa276f5c9c5873525a3abade Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 23 Oct 2025 20:10:50 +0300 Subject: [PATCH] feat(coderd): use task data model for send/logs (#20381) Updates coder/internal#976 --- cli/exp_task_logs_test.go | 2 +- cli/exp_task_send_test.go | 2 +- coderd/aitasks.go | 106 ++++------ coderd/aitasks_test.go | 397 ++++++++++++++++++-------------------- 4 files changed, 224 insertions(+), 283 deletions(-) diff --git a/cli/exp_task_logs_test.go b/cli/exp_task_logs_test.go index 3bf8fb91e7..5137c8b40f 100644 --- a/cli/exp_task_logs_test.go +++ b/cli/exp_task_logs_test.go @@ -23,7 +23,7 @@ import ( func Test_TaskLogs(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") + t.Skip("TODO(mafredri): Remove, fixed up-stack!") testMessages := []agentapisdk.Message{ { diff --git a/cli/exp_task_send_test.go b/cli/exp_task_send_test.go index 26d62ba5f6..26b69b992d 100644 --- a/cli/exp_task_send_test.go +++ b/cli/exp_task_send_test.go @@ -22,7 +22,7 @@ import ( func Test_TaskSend(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") + t.Skip("TODO(mafredri): Remove, fixed up-stack!") t.Run("ByWorkspaceName_WithArgument", func(t *testing.T) { t.Parallel() diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 2f3dd74ad2..e5f5260a24 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/go-chi/chi/v5" "github.com/google/uuid" "cdr.dev/slog" @@ -755,15 +754,7 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { // workspace and validate the sidebar app health. func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - - idStr := chi.URLParam(r, "task") - taskID, err := uuid.Parse(idStr) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr), - }) - return - } + task := httpmw.TaskParam(r) var req codersdk.TaskSendRequest if !httpapi.Read(ctx, rw, r, &req) { @@ -776,7 +767,7 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { return } - if err = api.authAndDoWithTaskSidebarAppClient(r, taskID, func(ctx context.Context, client *http.Client, appURL *url.URL) error { + if err := api.authAndDoWithTaskAppClient(r, task, func(ctx context.Context, client *http.Client, appURL *url.URL) error { agentAPIClient, err := aiagentapi.NewClient(appURL.String(), aiagentapi.WithHTTPClient(client)) if err != nil { return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ @@ -835,18 +826,10 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { // We enforce ApplicationConnect RBAC on the workspace and validate the sidebar app health. func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - - idStr := chi.URLParam(r, "task") - taskID, err := uuid.Parse(idStr) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr), - }) - return - } + task := httpmw.TaskParam(r) var out codersdk.TaskLogsResponse - if err := api.authAndDoWithTaskSidebarAppClient(r, taskID, func(ctx context.Context, client *http.Client, appURL *url.URL) error { + if err := api.authAndDoWithTaskAppClient(r, task, func(ctx context.Context, client *http.Client, appURL *url.URL) error { agentAPIClient, err := aiagentapi.NewClient(appURL.String(), aiagentapi.WithHTTPClient(client)) if err != nil { return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ @@ -894,7 +877,7 @@ func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, out) } -// authAndDoWithTaskSidebarAppClient centralizes the shared logic to: +// authAndDoWithTaskAppClient centralizes the shared logic to: // // - Fetch the task workspace // - Authorize ApplicationConnect on the workspace @@ -903,15 +886,31 @@ func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) { // // The provided callback receives the context, an HTTP client that dials via the // agent, and the base app URL (as a value URL) to perform any request. -func (api *API) authAndDoWithTaskSidebarAppClient( +func (api *API) authAndDoWithTaskAppClient( r *http.Request, - taskID uuid.UUID, + task database.Task, do func(ctx context.Context, client *http.Client, appURL *url.URL) error, ) error { ctx := r.Context() - workspaceID := taskID - workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID) + if task.Status != database.TaskStatusActive { + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Task status must be active.", + Detail: fmt.Sprintf("Task status is %q, it must be %q to interact with the task.", task.Status, codersdk.TaskStatusActive), + }) + } + if !task.WorkspaceID.Valid { + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Task does not have a workspace.", + }) + } + if !task.WorkspaceAppID.Valid { + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Task does not have a workspace app.", + }) + } + + workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) if err != nil { if httpapi.Is404Error(err) { return httperror.ErrResourceNotFound @@ -927,65 +926,30 @@ func (api *API) authAndDoWithTaskSidebarAppClient( return httperror.ErrResourceNotFound } - data, err := api.workspaceData(ctx, []database.Workspace{workspace}) + apps, err := api.Database.GetWorkspaceAppsByAgentID(ctx, task.WorkspaceAgentID.UUID) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace resources.", Detail: err.Error(), }) } - if len(data.builds) == 0 || len(data.templates) == 0 { - return httperror.ErrResourceNotFound - } - build := data.builds[0] - if build.HasAITask == nil || !*build.HasAITask || build.AITaskSidebarAppID == nil || *build.AITaskSidebarAppID == uuid.Nil { - return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ - Message: "Task is not configured with a sidebar app.", - }) - } - // Find the sidebar app details to get the URL and validate app health. - sidebarAppID := *build.AITaskSidebarAppID - agentID, sidebarApp, ok := func() (uuid.UUID, codersdk.WorkspaceApp, bool) { - for _, res := range build.Resources { - for _, agent := range res.Agents { - for _, app := range agent.Apps { - if app.ID == sidebarAppID { - return agent.ID, app, true - } - } - } + var app *database.WorkspaceApp + for _, a := range apps { + if a.ID == task.WorkspaceAppID.UUID { + app = &a + break } - return uuid.Nil, codersdk.WorkspaceApp{}, false - }() - if !ok { - return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ - Message: "Task sidebar app not found in latest build.", - }) - } - - // Return an informative error if the app isn't healthy rather than trying - // and failing. - switch sidebarApp.Health { - case codersdk.WorkspaceAppHealthDisabled: - // No health check, pass through. - case codersdk.WorkspaceAppHealthInitializing: - return httperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{ - Message: "Task sidebar app is initializing. Try again shortly.", - }) - case codersdk.WorkspaceAppHealthUnhealthy: - return httperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{ - Message: "Task sidebar app is unhealthy.", - }) } // Build the direct app URL and dial the agent. - if sidebarApp.URL == "" { + appURL := app.Url.String + if appURL == "" { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Task sidebar app URL is not configured.", }) } - parsedURL, err := url.Parse(sidebarApp.URL) + parsedURL, err := url.Parse(appURL) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error parsing task app URL.", @@ -1000,7 +964,7 @@ func (api *API) authAndDoWithTaskSidebarAppClient( dialCtx, dialCancel := context.WithTimeout(ctx, time.Second*30) defer dialCancel() - agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agentID) + agentConn, release, err := api.agentProvider.AgentConn(dialCtx, task.WorkspaceAgentID.UUID) if err != nil { return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ Message: "Failed to reach task app endpoint.", diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index c7db4f58c6..c88785c858 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -2,7 +2,7 @@ package coderd_test import ( "database/sql" - "fmt" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + 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/coderd/coderdtest" @@ -210,8 +211,8 @@ func TestTasks(t *testing.T) { Apps: []*proto.App{ { Id: taskAppID.String(), - Slug: "task-sidebar", - DisplayName: "Task Sidebar", + Slug: "task-app", + DisplayName: "Task App", Url: opt.appURL, }, }, @@ -221,9 +222,7 @@ func TestTasks(t *testing.T) { }, AiTasks: []*proto.AITask{ { - SidebarApp: &proto.AITaskSidebarApp{ - Id: taskAppID.String(), - }, + AppId: taskAppID.String(), }, }, }, @@ -469,41 +468,40 @@ func TestTasks(t *testing.T) { t.Run("Send", func(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") - t.Run("IntegrationOK", func(t *testing.T) { t.Parallel() - client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) - userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - createStatusResponse := func(status string) string { - return ` - { - "$schema": "http://localhost:3284/schemas/StatusResponseBody.json", - "status": "` + status + `" - } - ` - } - statusResponse := createStatusResponse("stable") + statusResponse := agentapisdk.StatusStable // Start a fake AgentAPI that accepts GET /status and POST /message. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && r.URL.Path == "/status" { w.Header().Set("Content-Type", "application/json") + resp := agentapisdk.GetStatusResponse{ + Status: statusResponse, + } + respBytes, err := json.Marshal(resp) + assert.NoError(t, err) w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, statusResponse) + w.Write(respBytes) return } if r.Method == http.MethodPost && r.URL.Path == "/message" { w.Header().Set("Content-Type", "application/json") b, _ := io.ReadAll(r.Body) - assert.Equal(t, `{"content":"Hello, Agent!","type":"user"}`, string(b), "expected message content") + expectedReq := agentapisdk.PostMessageParams{ + Content: "Hello, Agent!", + Type: agentapisdk.MessageTypeUser, + } + expectedBytes, _ := json.Marshal(expectedReq) + assert.Equal(t, string(expectedBytes), string(b), "expected message content") + resp := agentapisdk.PostMessageResponse{Ok: true} + respBytes, err := json.Marshal(resp) + assert.NoError(t, err) w.WriteHeader(http.StatusOK) - io.WriteString(w, `{"ok": true}`) + w.Write(respBytes) return } w.WriteHeader(http.StatusInternalServerError) @@ -511,103 +509,105 @@ func TestTasks(t *testing.T) { defer srv.Close() // Create an AI-capable template whose sidebar app points to our fake AgentAPI. - authToken := uuid.NewString() - template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken)) + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + ctx = testutil.Context(t, testutil.WaitLong) + owner = coderdtest.CreateFirstUser(t, client) + userClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + agentAuthToken = uuid.NewString() + template = createAITemplate(t, client, owner, withAgentToken(agentAuthToken), withSidebarURL(srv.URL)) + exp = codersdk.NewExperimentalClient(userClient) + ) - // Create a workspace (task) from the AI-capable template. - ws := coderdtest.CreateWorkspace(t, userClient, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: "send a message"}, - } + task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "send me food", }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + // Get the workspace and wait for it to be ready. + ws, err := userClient.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, ws.LatestBuild.ID) + + // Fetch the task by ID via experimental API and verify fields. + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.NotZero(t, task.WorkspaceBuildNumber) + require.True(t, task.WorkspaceAgentID.Valid) + require.True(t, task.WorkspaceAppID.Valid) + + // Insert an app status for the workspace + _, err = db.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ + ID: uuid.New(), + WorkspaceID: task.WorkspaceID.UUID, + CreatedAt: dbtime.Now(), + AgentID: task.WorkspaceAgentID.UUID, + AppID: task.WorkspaceAppID.UUID, + State: database.WorkspaceAppStatusStateComplete, + Message: "all done", + }) + require.NoError(t, err) // Start a fake agent so the workspace agent is connected before sending the message. - agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) - _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { + agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(agentAuthToken)) + _ = agenttest.New(t, userClient.URL, agentAuthToken, func(o *agent.Options) { o.Client = agentClient }) + coderdtest.NewWorkspaceAgentWaiter(t, userClient, ws.ID).WaitFor(coderdtest.AgentsReady) - ctx := testutil.Context(t, testutil.WaitMedium) - coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WithContext(ctx).WaitFor(coderdtest.AgentsReady) - - // Lookup the sidebar app ID. - w, err := client.Workspace(ctx, ws.ID) + // Fetch the task by ID via experimental API and verify fields. + task, err = exp.TaskByID(ctx, task.ID) require.NoError(t, err) - var sidebarAppID uuid.UUID - for _, res := range w.LatestBuild.Resources { - for _, ag := range res.Agents { - for _, app := range ag.Apps { - if app.Slug == "task-sidebar" { - sidebarAppID = app.ID - } - } - } - } - require.NotEqual(t, uuid.Nil, sidebarAppID) // Make the sidebar app unhealthy initially. - err = api.Database.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{ - ID: sidebarAppID, + err = db.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{ + ID: task.WorkspaceAppID.UUID, Health: database.WorkspaceAppHealthUnhealthy, }) require.NoError(t, err) - exp := codersdk.NewExperimentalClient(userClient) - err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ + err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ Input: "Hello, Agent!", }) require.Error(t, err, "wanted error due to unhealthy sidebar app") // Make the sidebar app healthy. - err = api.Database.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{ - ID: sidebarAppID, + err = db.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{ + ID: task.WorkspaceAppID.UUID, Health: database.WorkspaceAppHealthHealthy, }) require.NoError(t, err) - statusResponse = createStatusResponse("bad") + statusResponse = agentapisdk.AgentStatus("bad") - err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ + err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ Input: "Hello, Agent!", }) require.Error(t, err, "wanted error due to bad status") - statusResponse = createStatusResponse("stable") + statusResponse = agentapisdk.StatusStable - // Send task input to the tasks sidebar app and expect 204.e - err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ - Input: "Hello, Agent!", - }) - require.NoError(t, err, "wanted no error due to healthy sidebar app and stable status") - }) - - t.Run("MissingContent", 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 workspace (task). - ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: "do work"}, - } - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - - exp := codersdk.NewExperimentalClient(client) - err := exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ - Input: "", + //nolint:tparallel // Not intended to run in parallel. + t.Run("SendOK", func(t *testing.T) { + err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ + Input: "Hello, Agent!", + }) + require.NoError(t, err, "wanted no error due to healthy sidebar app and stable status") }) - var sdkErr *codersdk.Error - require.Error(t, err) - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + //nolint:tparallel // Not intended to run in parallel. + t.Run("MissingContent", func(t *testing.T) { + err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ + Input: "", + }) + require.Error(t, err, "wanted error due to missing content") + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) }) t.Run("TaskNotFound", func(t *testing.T) { @@ -627,108 +627,112 @@ func TestTasks(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) - - t.Run("NotATask", 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) - - // Create a template without AI tasks. - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - - ws := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - - exp := codersdk.NewExperimentalClient(client) - err := exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ - Input: "hello", - }) - - var sdkErr *codersdk.Error - require.Error(t, err) - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) - }) }) t.Run("Logs", func(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") - - t.Run("OK", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - messageResponse := ` + messageResponseData := agentapisdk.GetMessagesResponse{ + Messages: []agentapisdk.Message{ { - "$schema": "http://localhost:3284/schemas/MessagesResponseBody.json", - "messages": [ - { - "id": 0, - "content": "Welcome, user!", - "role": "agent", - "time": "2025-09-25T10:42:48.751774125Z" - }, - { - "id": 1, - "content": "Hello, agent!", - "role": "user", - "time": "2025-09-25T10:46:42.880996296Z" - }, - { - "id": 2, - "content": "What would you like to work on today?", - "role": "agent", - "time": "2025-09-25T10:46:50.747761102Z" - } - ] - } - ` + Id: 0, + Content: "Welcome, user!", + Role: agentapisdk.RoleAgent, + Time: time.Date(2025, 9, 25, 10, 42, 48, 0, time.UTC), + }, + { + Id: 1, + Content: "Hello, agent!", + Role: agentapisdk.RoleUser, + Time: time.Date(2025, 9, 25, 10, 46, 42, 0, time.UTC), + }, + { + Id: 2, + Content: "What would you like to work on today?", + Role: agentapisdk.RoleAgent, + Time: time.Date(2025, 9, 25, 10, 46, 50, 0, time.UTC), + }, + }, + } + messageResponseBytes, err := json.Marshal(messageResponseData) + require.NoError(t, err) + messageResponse := string(messageResponseBytes) - // Fake AgentAPI that returns a couple of messages. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/messages" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - io.WriteString(w, messageResponse) - return - } - w.WriteHeader(http.StatusNotFound) - })) - t.Cleanup(srv.Close) + var shouldReturnError bool - // Template pointing sidebar app to our fake AgentAPI. - authToken := uuid.NewString() - template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken)) + // Fake AgentAPI that returns a couple of messages or an error. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if shouldReturnError { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, "boom") + return + } + if r.Method == http.MethodGet && r.URL.Path == "/messages" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + io.WriteString(w, messageResponse) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() - // Create task workspace. - ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: "show logs"}, - } - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + // Create an AI-capable template whose sidebar app points to our fake AgentAPI. + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + ctx = testutil.Context(t, testutil.WaitLong) + owner = coderdtest.CreateFirstUser(t, client) + agentAuthToken = uuid.NewString() + template = createAITemplate(t, client, owner, withAgentToken(agentAuthToken), withSidebarURL(srv.URL)) + exp = codersdk.NewExperimentalClient(client) + ) - // Start a fake agent. - agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) - _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { - o.Client = agentClient - }) - coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WithContext(ctx).WaitFor(coderdtest.AgentsReady) + task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "show logs", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) - // Omit sidebar app health as undefined is OK. + // Get the workspace and wait for it to be ready. + ws, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + // Fetch the task by ID via experimental API and verify fields. + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.NotZero(t, task.WorkspaceBuildNumber) + require.True(t, task.WorkspaceAgentID.Valid) + require.True(t, task.WorkspaceAppID.Valid) + + // Insert an app status for the workspace + _, err = db.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ + ID: uuid.New(), + WorkspaceID: task.WorkspaceID.UUID, + CreatedAt: dbtime.Now(), + AgentID: task.WorkspaceAgentID.UUID, + AppID: task.WorkspaceAppID.UUID, + State: database.WorkspaceAppStatusStateComplete, + Message: "all done", + }) + require.NoError(t, err) + + // Start a fake agent so the workspace agent is connected before fetching logs. + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(agentAuthToken)) + _ = agenttest.New(t, client.URL, agentAuthToken, func(o *agent.Options) { + o.Client = agentClient + }) + coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WaitFor(coderdtest.AgentsReady) + + // Fetch the task by ID via experimental API and verify fields. + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + + //nolint:tparallel // Not intended to run in parallel. + t.Run("OK", func(t *testing.T) { // Fetch logs. - exp := codersdk.NewExperimentalClient(client) - resp, err := exp.TaskLogs(ctx, "me", ws.ID) + resp, err := exp.TaskLogs(ctx, "me", task.ID) require.NoError(t, err) require.Len(t, resp.Logs, 3) assert.Equal(t, 0, resp.Logs[0].ID) @@ -744,38 +748,11 @@ func TestTasks(t *testing.T) { assert.Equal(t, "What would you like to work on today?", resp.Logs[2].Content) }) + //nolint:tparallel // Not intended to run in parallel. t.Run("UpstreamError", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitShort) - - // Fake AgentAPI that returns 500 for messages. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = io.WriteString(w, "boom") - })) - t.Cleanup(srv.Close) - - authToken := uuid.NewString() - template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken)) - ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: "show logs"}, - } - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - - // Start fake agent. - agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) - _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { - o.Client = agentClient - }) - coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WithContext(ctx).WaitFor(coderdtest.AgentsReady) - - exp := codersdk.NewExperimentalClient(client) - _, err := exp.TaskLogs(ctx, "me", ws.ID) + shouldReturnError = true + t.Cleanup(func() { shouldReturnError = false }) + _, err := exp.TaskLogs(ctx, "me", task.ID) var sdkErr *codersdk.Error require.Error(t, err)