diff --git a/cli/exp.go b/cli/exp.go index dafd85402663e..1a568303731c3 100644 --- a/cli/exp.go +++ b/cli/exp.go @@ -16,6 +16,7 @@ func (r *RootCmd) expCmd() *serpent.Command { r.mcpCommand(), r.promptExample(), r.rptyCommand(), + r.taskCommand(), }, } return cmd diff --git a/cli/exp_task.go b/cli/exp_task.go new file mode 100644 index 0000000000000..5e8cb6d51240b --- /dev/null +++ b/cli/exp_task.go @@ -0,0 +1,118 @@ +package cli + +import ( + "context" + "errors" + "net/url" + "time" + + agentapi "github.com/coder/agentapi-sdk-go" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) taskCommand() *serpent.Command { + cmd := &serpent.Command{ + Use: "task", + Short: "Interact with AI tasks.", + Handler: func(i *serpent.Invocation) error { + return i.Command.HelpHandler(i) + }, + Children: []*serpent.Command{ + r.taskReportStatus(), + }, + } + return cmd +} + +func (r *RootCmd) taskReportStatus() *serpent.Command { + var ( + slug string + interval time.Duration + llmURL url.URL + ) + cmd := &serpent.Command{ + Use: "report-status", + Short: "Report status of the currently running task to Coder.", + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + + // This is meant to run in a workspace, so instead of a regular client we + // need a workspace agent client to update the status in coderd. + agentClient, err := r.createAgentClient() + if err != nil { + return err + } + + // We also need an agentapi client to get the LLM agent's current status. + llmClient, err := agentapi.NewClient(llmURL.String()) + if err != nil { + return err + } + + notifyCtx, notifyCancel := inv.SignalNotifyContext(ctx, StopSignals...) + defer notifyCancel() + + outerLoop: + for { + res, err := llmClient.GetStatus(notifyCtx) + if err != nil && !errors.Is(err, context.Canceled) { + cliui.Warnf(inv.Stderr, "failed to fetch status: %s", err) + } else { + // Currently we only update the status, which leaves the last summary + // (if any) untouched. If we do want to update the summary here, we + // will need to fetch the messages and generate one. + status := codersdk.WorkspaceAppStatusStateWorking + switch res.Status { + case agentapi.StatusStable: // Stable == idle == done + status = codersdk.WorkspaceAppStatusStateComplete + case agentapi.StatusRunning: // Running == working + } + err = agentClient.PatchAppStatus(notifyCtx, agentsdk.PatchAppStatus{ + AppSlug: slug, + State: status, + }) + if err != nil && !errors.Is(err, context.Canceled) { + cliui.Warnf(inv.Stderr, "failed to update status: %s", err) + } + } + + timer := time.NewTimer(interval) + select { + case <-notifyCtx.Done(): + timer.Stop() + break outerLoop + case <-timer.C: + } + } + + return nil + }, + Options: []serpent.Option{ + { + Flag: "app-slug", + Description: "The app slug to use when reporting the status.", + Env: "CODER_MCP_APP_STATUS_SLUG", + Required: true, + Value: serpent.StringOf(&slug), + }, + { + Flag: "agentapi-url", + Description: "The URL of the LLM agent API.", + Env: "CODER_AGENTAPI_URL", + Required: true, + Value: serpent.URLOf(&llmURL), + }, + { + Flag: "interval", + Description: "The interval on which to poll for the status.", + Env: "CODER_APP_STATUS_INTERVAL", + Default: "30s", + Value: serpent.DurationOf(&interval), + }, + }, + } + return cmd +} diff --git a/cli/exp_task_test.go b/cli/exp_task_test.go new file mode 100644 index 0000000000000..e5036a99e62a9 --- /dev/null +++ b/cli/exp_task_test.go @@ -0,0 +1,134 @@ +package cli_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agentapi "github.com/coder/agentapi-sdk-go" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestExpTask(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resp *codersdk.Response + status *agentapi.GetStatusResponse + expected codersdk.WorkspaceAppStatusState + }{ + { + name: "ReportWorking", + resp: nil, + status: &agentapi.GetStatusResponse{ + Status: agentapi.StatusRunning, + }, + expected: codersdk.WorkspaceAppStatusStateWorking, + }, + { + name: "ReportComplete", + resp: nil, + status: &agentapi.GetStatusResponse{ + Status: agentapi.StatusStable, + }, + expected: codersdk.WorkspaceAppStatusStateComplete, + }, + { + name: "ReportUpdateError", + resp: &codersdk.Response{ + Message: "Failed to get workspace app.", + Detail: "This is a test failure.", + }, + status: &agentapi.GetStatusResponse{ + Status: agentapi.StatusStable, + }, + expected: codersdk.WorkspaceAppStatusStateComplete, + }, + { + name: "ReportStatusError", + resp: nil, + status: nil, + expected: codersdk.WorkspaceAppStatusStateComplete, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + done := make(chan codersdk.WorkspaceAppStatusState) + + // A mock server for coderd. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + _ = r.Body.Close() + + var req agentsdk.PatchAppStatus + err = json.Unmarshal(body, &req) + require.NoError(t, err) + + if test.resp != nil { + httpapi.Write(context.Background(), w, http.StatusBadRequest, test.resp) + } else { + httpapi.Write(context.Background(), w, http.StatusOK, nil) + } + done <- req.State + })) + t.Cleanup(srv.Close) + agentURL := srv.URL + + // Another mock server for the LLM agent API. + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if test.status != nil { + httpapi.Write(context.Background(), w, http.StatusOK, test.status) + } else { + httpapi.Write(context.Background(), w, http.StatusBadRequest, nil) + } + })) + t.Cleanup(srv.Close) + agentapiURL := srv.URL + + inv, _ := clitest.New(t, "--agent-url", agentURL, "exp", "task", "report-status", + "--app-slug", "claude-code", + "--agentapi-url", agentapiURL) + stdout := ptytest.New(t) + inv.Stdout = stdout.Output() + stderr := ptytest.New(t) + inv.Stderr = stderr.Output() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + + go func() { + err := inv.WithContext(ctx).Run() + assert.NoError(t, err) + }() + + // Should only try to update the status if we got one. + if test.status == nil { + stderr.ExpectMatch("failed to fetch status") + } else { + got := <-done + require.Equal(t, got, test.expected) + } + + // Non-nil for the update means there was an error. + if test.resp != nil { + stderr.ExpectMatch("failed to update status") + } + }) + } +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5bfa015af3d78..ee776e6800d90 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1991,6 +1991,13 @@ func (q *querier) GetLatestCryptoKeyByFeature(ctx context.Context, feature datab return q.db.GetLatestCryptoKeyByFeature(ctx, feature) } +func (q *querier) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (database.WorkspaceAppStatus, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return database.WorkspaceAppStatus{}, err + } + return q.db.GetLatestWorkspaceAppStatusByAppID(ctx, appID) +} + func (q *querier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return nil, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 50373fbeb72e6..3683c267d9b74 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -3801,6 +3801,11 @@ func (s *MethodTestSuite) TestSystemFunctions() { LoginType: database.LoginTypeGithub, }).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(l) })) + s.Run("GetLatestWorkspaceAppStatusByAppID", s.Subtest(func(db database.Store, check *expects) { + dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) + status := dbgen.WorkspaceAppStatus(s.T(), db, database.WorkspaceAppStatus{}) + check.Args(status.AppID).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(status) + })) s.Run("GetLatestWorkspaceAppStatusesByWorkspaceIDs", s.Subtest(func(db database.Store, check *expects) { check.Args([]uuid.UUID{}).Asserts(rbac.ResourceSystem, policy.ActionRead) })) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index c85db83a2adc9..36439c9827bf0 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -799,6 +799,24 @@ func WorkspaceAppStat(t testing.TB, db database.Store, orig database.WorkspaceAp return scheme } +func WorkspaceAppStatus(t testing.TB, db database.Store, orig database.WorkspaceAppStatus) database.WorkspaceAppStatus { + status, err := db.InsertWorkspaceAppStatus(genCtx, database.InsertWorkspaceAppStatusParams{ + ID: takeFirst(orig.ID, uuid.New()), + CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), + WorkspaceID: takeFirst(orig.WorkspaceID, uuid.New()), + AgentID: takeFirst(orig.AgentID, uuid.New()), + AppID: takeFirst(orig.AppID, uuid.New()), + Uri: sql.NullString{ + String: takeFirst(orig.Uri.String, ""), + Valid: takeFirst(orig.Uri.Valid, false), + }, + Message: takeFirst(orig.Message, ""), + State: takeFirst(orig.State, database.WorkspaceAppStatusStateWorking), + }) + require.NoError(t, err, "insert workspace agent status") + return status +} + func WorkspaceResource(t testing.TB, db database.Store, orig database.WorkspaceResource) database.WorkspaceResource { resource, err := db.InsertWorkspaceResource(genCtx, database.InsertWorkspaceResourceParams{ ID: takeFirst(orig.ID, uuid.New()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index f838a93d24c78..eb9c1d281daae 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -3777,6 +3777,22 @@ func (q *FakeQuerier) GetLatestCryptoKeyByFeature(_ context.Context, feature dat return latestKey, nil } +func (q *FakeQuerier) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (database.WorkspaceAppStatus, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var current *database.WorkspaceAppStatus = nil + for _, appStatus := range q.workspaceAppStatuses { + if appStatus.AppID == appID && (current == nil || appStatus.CreatedAt.After(current.CreatedAt)) { + current = &appStatus + } + } + if current == nil { + return database.WorkspaceAppStatus{}, sql.ErrNoRows + } + return *current, nil +} + func (q *FakeQuerier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index e208f9898cb1e..d33c7876de96e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -900,6 +900,13 @@ func (m queryMetricsStore) GetLatestCryptoKeyByFeature(ctx context.Context, feat return r0, r1 } +func (m queryMetricsStore) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (database.WorkspaceAppStatus, error) { + start := time.Now() + r0, r1 := m.s.GetLatestWorkspaceAppStatusByAppID(ctx, appID) + m.queryLatencies.WithLabelValues("GetLatestWorkspaceAppStatusByAppID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { start := time.Now() r0, r1 := m.s.GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b6a04754f17b0..087c9d5e4eaf9 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1817,6 +1817,21 @@ func (mr *MockStoreMockRecorder) GetLatestCryptoKeyByFeature(ctx, feature any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCryptoKeyByFeature", reflect.TypeOf((*MockStore)(nil).GetLatestCryptoKeyByFeature), ctx, feature) } +// GetLatestWorkspaceAppStatusByAppID mocks base method. +func (m *MockStore) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (database.WorkspaceAppStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestWorkspaceAppStatusByAppID", ctx, appID) + ret0, _ := ret[0].(database.WorkspaceAppStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestWorkspaceAppStatusByAppID indicates an expected call of GetLatestWorkspaceAppStatusByAppID. +func (mr *MockStoreMockRecorder) GetLatestWorkspaceAppStatusByAppID(ctx, appID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestWorkspaceAppStatusByAppID", reflect.TypeOf((*MockStore)(nil).GetLatestWorkspaceAppStatusByAppID), ctx, appID) +} + // GetLatestWorkspaceAppStatusesByWorkspaceIDs mocks base method. func (m *MockStore) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b612143b63776..43a325cd80e71 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -206,6 +206,7 @@ type sqlcQuerier interface { GetInboxNotificationsByUserID(ctx context.Context, arg GetInboxNotificationsByUserIDParams) ([]InboxNotification, error) GetLastUpdateCheck(ctx context.Context) (string, error) GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error) + GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (WorkspaceAppStatus, error) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index eec91c7586d61..b97a7c02b4368 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -16262,6 +16262,27 @@ func (q *sqlQuerier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg Ups return new_or_stale, err } +const getLatestWorkspaceAppStatusByAppID = `-- name: GetLatestWorkspaceAppStatusByAppID :one +SELECT id, created_at, agent_id, app_id, workspace_id, state, message, uri FROM workspace_app_statuses WHERE app_id = $1 +ORDER BY created_at DESC LIMIT 1 +` + +func (q *sqlQuerier) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (WorkspaceAppStatus, error) { + row := q.db.QueryRowContext(ctx, getLatestWorkspaceAppStatusByAppID, appID) + var i WorkspaceAppStatus + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.AppID, + &i.WorkspaceID, + &i.State, + &i.Message, + &i.Uri, + ) + return i, err +} + const getLatestWorkspaceAppStatusesByWorkspaceIDs = `-- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many SELECT DISTINCT ON (workspace_id) id, created_at, agent_id, app_id, workspace_id, state, message, uri diff --git a/coderd/database/queries/workspaceapps.sql b/coderd/database/queries/workspaceapps.sql index f3f8e4066970b..8aca68f6c9b48 100644 --- a/coderd/database/queries/workspaceapps.sql +++ b/coderd/database/queries/workspaceapps.sql @@ -58,3 +58,7 @@ SELECT DISTINCT ON (workspace_id) FROM workspace_app_statuses WHERE workspace_id = ANY(@ids :: uuid[]) ORDER BY workspace_id, created_at DESC; + +-- name: GetLatestWorkspaceAppStatusByAppID :one +SELECT * FROM workspace_app_statuses WHERE app_id = $1 +ORDER BY created_at DESC LIMIT 1; diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 6b25fcbcfeaf6..db110650d5f1d 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -380,6 +380,35 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req return } + // nolint:gocritic // This is a system restricted operation. + status, err := api.Database.GetLatestWorkspaceAppStatusByAppID(dbauthz.AsSystemRestricted(ctx), app.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace app statuses.", + Detail: err.Error(), + }) + return + } + // Preserve the existing message, which allows partial updates where we change + // the status but keep the message. + // TODO: What if another status is added while we are doing these checks? + // This would essentially revert the message to the previous one. We could + // maybe do this in the query instead? + if req.Message == "" { + req.Message = status.Message + // Only preserve the URI if there was no message. If there is a message and + // a blank URI, we assume the intent is to remove the URI, not preserve it. + if req.URI == "" { + req.URI = status.Uri.String + } + } + // Skip duplicate status updates. + newState := database.WorkspaceAppStatusState(req.State) + if status.State == newState && status.Message == req.Message && status.Uri.String == req.URI { + httpapi.Write(ctx, rw, http.StatusOK, nil) + return + } + // nolint:gocritic // This is a system restricted operation. _, err = api.Database.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ ID: uuid.New(), @@ -387,7 +416,7 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req WorkspaceID: workspace.ID, AgentID: workspaceAgent.ID, AppID: app.ID, - State: database.WorkspaceAppStatusState(req.State), + State: newState, Message: req.Message, Uri: sql.NullString{ String: req.URI, diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index a9b981f820be2..ce6515ecca076 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -387,6 +387,81 @@ func TestWorkspaceAgentAppStatus(t *testing.T) { require.False(t, agent.Apps[0].Statuses[0].NeedsUserAttention) }) + t.Run("Patch", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + ws := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user2.ID, + }).WithAgent(func(a []*proto.Agent) []*proto.Agent { + a[0].Apps = []*proto.App{ + { + Slug: "vscode", + }, + } + return a + }).Do() + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(ws.AgentToken) + + err := agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "vscode", + Message: "testing", + URI: "https://example.com", + State: codersdk.WorkspaceAppStatusStateComplete, + }) + require.NoError(t, err) + + // Duplicate should be a no-op. + err = agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "vscode", + Message: "testing", + URI: "https://example.com", + State: codersdk.WorkspaceAppStatusStateComplete, + }) + require.NoError(t, err) + + // If no message, both the old message and URI are preserved, and this ends + // up being a duplicate as well. + err = agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "vscode", + State: codersdk.WorkspaceAppStatusStateComplete, + }) + require.NoError(t, err) + + // Same, but the status updated so it will not be a duplicate. + err = agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "vscode", + State: codersdk.WorkspaceAppStatusStateWorking, + }) + require.NoError(t, err) + + // Message update, so nothing is preserved. + err = agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "vscode", + Message: "updated", + State: codersdk.WorkspaceAppStatusStateWorking, + }) + require.NoError(t, err) + + workspace, err := client.Workspace(ctx, ws.Workspace.ID) + require.NoError(t, err) + agent, err := client.WorkspaceAgent(ctx, workspace.LatestBuild.Resources[0].Agents[0].ID) + require.NoError(t, err) + require.Len(t, agent.Apps[0].Statuses, 3) + require.Equal(t, agent.Apps[0].Statuses[0].State, codersdk.WorkspaceAppStatusStateComplete) + require.Equal(t, agent.Apps[0].Statuses[0].Message, "testing") + require.Equal(t, agent.Apps[0].Statuses[0].URI, "https://example.com") + require.Equal(t, agent.Apps[0].Statuses[1].State, codersdk.WorkspaceAppStatusStateWorking) + require.Equal(t, agent.Apps[0].Statuses[1].Message, "testing") + require.Equal(t, agent.Apps[0].Statuses[1].URI, "https://example.com") + require.Equal(t, agent.Apps[0].Statuses[2].State, codersdk.WorkspaceAppStatusStateWorking) + require.Equal(t, agent.Apps[0].Statuses[2].Message, "updated") + require.Equal(t, agent.Apps[0].Statuses[2].URI, "") + }) + t.Run("FailUnknownApp", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/go.mod b/go.mod index 584b7f08cc373..122adba5314e1 100644 --- a/go.mod +++ b/go.mod @@ -485,6 +485,7 @@ require ( require ( github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 + github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 github.com/coder/preview v0.0.2-0.20250604144457-c9862a17f652 github.com/fsnotify/fsnotify v1.9.0 github.com/kylecarbs/aisdk-go v0.0.8 @@ -523,6 +524,7 @@ require ( github.com/samber/lo v1.50.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/tmaxmax/go-sse v0.10.0 // indirect github.com/ulikunitz/xz v0.5.12 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect diff --git a/go.sum b/go.sum index c48ca26edd6ce..77e9365d99e81 100644 --- a/go.sum +++ b/go.sum @@ -894,6 +894,8 @@ github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8= +github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4= github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwuWwPHPYoCZ/KLAjHv5g4h2MS4f2/MTI= github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4= github.com/coder/clistat v1.0.0 h1:MjiS7qQ1IobuSSgDnxcCSyBPESs44hExnh2TEqMcGnA= @@ -1827,6 +1829,8 @@ github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZ github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= +github.com/tmaxmax/go-sse v0.10.0 h1:j9F93WB4Hxt8wUf6oGffMm4dutALvUPoDDxfuDQOSqA= +github.com/tmaxmax/go-sse v0.10.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8= github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a h1:eg5FkNoQp76ZsswyGZ+TjYqA/rhKefxK8BW7XOlQsxo= github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a/go.mod h1:e/8TmrdreH0sZOw2DFKBaUV7bvDWRq6SeM9PzkuVM68= github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= diff --git a/site/src/pages/WorkspacePage/AppStatuses.tsx b/site/src/pages/WorkspacePage/AppStatuses.tsx index 148484a4992ea..35d4db46c3ac9 100644 --- a/site/src/pages/WorkspacePage/AppStatuses.tsx +++ b/site/src/pages/WorkspacePage/AppStatuses.tsx @@ -156,7 +156,7 @@ export const AppStatuses: FC = ({