From e8e379c2a946c554f0d7a4c1a71eb3bd655d30bb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 22 May 2026 09:48:07 +0000 Subject: [PATCH] feat: add chat goal persistence foundation --- coderd/apidoc/docs.go | 129 ++++++ coderd/apidoc/swagger.json | 111 +++++ coderd/database/check_constraint.go | 7 + coderd/database/db2sdk/db2sdk.go | 38 ++ coderd/database/db2sdk/db2sdk_test.go | 47 +- coderd/database/dbauthz/dbauthz.go | 57 +++ coderd/database/dbauthz/dbauthz_test.go | 58 +++ coderd/database/dbmetrics/querymetrics.go | 56 +++ coderd/database/dbmock/dbmock.go | 105 +++++ coderd/database/dump.sql | 51 +++ coderd/database/foreign_key_constraint.go | 4 + .../000503_chat_goal_persistence.down.sql | 2 + .../000503_chat_goal_persistence.up.sql | 38 ++ .../000503_chat_goal_persistence.up.sql | 22 + coderd/database/models.go | 84 ++++ coderd/database/querier.go | 7 + coderd/database/querier_test.go | 184 ++++++++ coderd/database/queries.sql.go | 309 +++++++++++++ coderd/database/queries/chats.sql | 91 ++++ coderd/database/unique_constraint.go | 2 + codersdk/chats.go | 59 ++- docs/reference/api/chats.md | 415 +++++++++++++----- docs/reference/api/schemas.md | 157 +++++++ site/src/api/typesGenerated.ts | 67 +++ 24 files changed, 1979 insertions(+), 121 deletions(-) create mode 100644 coderd/database/migrations/000503_chat_goal_persistence.down.sql create mode 100644 coderd/database/migrations/000503_chat_goal_persistence.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000503_chat_goal_persistence.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index aab6699a95..10293cd690 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16456,6 +16456,9 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ChatFileMetadata" } }, + "goal": { + "$ref": "#/definitions/codersdk.ChatGoal" + }, "has_unread": { "description": "HasUnread is true when assistant messages exist beyond\nthe owner's read cursor, which updates on stream\nconnect and disconnect.", "type": "boolean" @@ -16770,6 +16773,126 @@ const docTemplate = `{ } } }, + "codersdk.ChatGoal": { + "type": "object", + "properties": { + "cleared_at": { + "type": "string", + "format": "date-time" + }, + "completed_at": { + "type": "string", + "format": "date-time" + }, + "completed_by_agent": { + "type": "boolean" + }, + "completed_by_user_id": { + "type": "string", + "format": "uuid" + }, + "completion_summary": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by_user_id": { + "type": "string", + "format": "uuid" + }, + "created_from_chat_id": { + "type": "string", + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "objective": { + "type": "string" + }, + "replaced_at": { + "type": "string", + "format": "date-time" + }, + "root_chat_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "$ref": "#/definitions/codersdk.ChatGoalStatus" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.ChatGoalMutation": { + "type": "object", + "properties": { + "action": { + "enum": [ + "set", + "clear", + "pause", + "resume", + "complete" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ChatGoalMutationAction" + } + ] + }, + "completion_summary": { + "type": "string" + }, + "goal_id": { + "type": "string", + "format": "uuid" + }, + "objective": { + "type": "string" + } + } + }, + "codersdk.ChatGoalMutationAction": { + "type": "string", + "enum": [ + "set", + "clear", + "pause", + "resume", + "complete" + ], + "x-enum-varnames": [ + "ChatGoalMutationActionSet", + "ChatGoalMutationActionClear", + "ChatGoalMutationActionPause", + "ChatGoalMutationActionResume", + "ChatGoalMutationActionComplete" + ] + }, + "codersdk.ChatGoalStatus": { + "type": "string", + "enum": [ + "active", + "paused", + "complete", + "cleared", + "replaced" + ], + "x-enum-varnames": [ + "ChatGoalStatusActive", + "ChatGoalStatusPaused", + "ChatGoalStatusComplete", + "ChatGoalStatusCleared", + "ChatGoalStatusReplaced" + ] + }, "codersdk.ChatGroup": { "type": "object", "properties": { @@ -17781,6 +17904,9 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ChatInputPart" } }, + "goal_mutation": { + "$ref": "#/definitions/codersdk.ChatGoalMutation" + }, "mcp_server_ids": { "type": "array", "items": { @@ -17834,6 +17960,9 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ChatInputPart" } }, + "goal_mutation": { + "$ref": "#/definitions/codersdk.ChatGoalMutation" + }, "labels": { "type": "object", "additionalProperties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 865e9ac96d..bfdedf08f3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14794,6 +14794,9 @@ "$ref": "#/definitions/codersdk.ChatFileMetadata" } }, + "goal": { + "$ref": "#/definitions/codersdk.ChatGoal" + }, "has_unread": { "description": "HasUnread is true when assistant messages exist beyond\nthe owner's read cursor, which updates on stream\nconnect and disconnect.", "type": "boolean" @@ -15096,6 +15099,108 @@ } } }, + "codersdk.ChatGoal": { + "type": "object", + "properties": { + "cleared_at": { + "type": "string", + "format": "date-time" + }, + "completed_at": { + "type": "string", + "format": "date-time" + }, + "completed_by_agent": { + "type": "boolean" + }, + "completed_by_user_id": { + "type": "string", + "format": "uuid" + }, + "completion_summary": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by_user_id": { + "type": "string", + "format": "uuid" + }, + "created_from_chat_id": { + "type": "string", + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "objective": { + "type": "string" + }, + "replaced_at": { + "type": "string", + "format": "date-time" + }, + "root_chat_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "$ref": "#/definitions/codersdk.ChatGoalStatus" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.ChatGoalMutation": { + "type": "object", + "properties": { + "action": { + "enum": ["set", "clear", "pause", "resume", "complete"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ChatGoalMutationAction" + } + ] + }, + "completion_summary": { + "type": "string" + }, + "goal_id": { + "type": "string", + "format": "uuid" + }, + "objective": { + "type": "string" + } + } + }, + "codersdk.ChatGoalMutationAction": { + "type": "string", + "enum": ["set", "clear", "pause", "resume", "complete"], + "x-enum-varnames": [ + "ChatGoalMutationActionSet", + "ChatGoalMutationActionClear", + "ChatGoalMutationActionPause", + "ChatGoalMutationActionResume", + "ChatGoalMutationActionComplete" + ] + }, + "codersdk.ChatGoalStatus": { + "type": "string", + "enum": ["active", "paused", "complete", "cleared", "replaced"], + "x-enum-varnames": [ + "ChatGoalStatusActive", + "ChatGoalStatusPaused", + "ChatGoalStatusComplete", + "ChatGoalStatusCleared", + "ChatGoalStatusReplaced" + ] + }, "codersdk.ChatGroup": { "type": "object", "properties": { @@ -16069,6 +16174,9 @@ "$ref": "#/definitions/codersdk.ChatInputPart" } }, + "goal_mutation": { + "$ref": "#/definitions/codersdk.ChatGoalMutation" + }, "mcp_server_ids": { "type": "array", "items": { @@ -16122,6 +16230,9 @@ "$ref": "#/definitions/codersdk.ChatInputPart" } }, + "goal_mutation": { + "$ref": "#/definitions/codersdk.ChatGoalMutation" + }, "labels": { "type": "object", "additionalProperties": { diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index c1fa991032..ac6d7c1ab5 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -16,6 +16,13 @@ const ( CheckAiProvidersNameCheck CheckConstraint = "ai_providers_name_check" // ai_providers CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys CheckBoundaryLogsSequenceNumberCheck CheckConstraint = "boundary_logs_sequence_number_check" // boundary_logs + CheckChatGoalsClearedAtStatusCheck CheckConstraint = "chat_goals_cleared_at_status_check" // chat_goals + CheckChatGoalsCompletedAtStatusCheck CheckConstraint = "chat_goals_completed_at_status_check" // chat_goals + CheckChatGoalsCompletedByAgentStatusCheck CheckConstraint = "chat_goals_completed_by_agent_status_check" // chat_goals + CheckChatGoalsCompletedByUserStatusCheck CheckConstraint = "chat_goals_completed_by_user_status_check" // chat_goals + CheckChatGoalsCompletionSummaryStatusCheck CheckConstraint = "chat_goals_completion_summary_status_check" // chat_goals + CheckChatGoalsObjectiveNotEmpty CheckConstraint = "chat_goals_objective_not_empty" // chat_goals + CheckChatGoalsReplacedAtStatusCheck CheckConstraint = "chat_goals_replaced_at_status_check" // chat_goals CheckChatModelConfigsAiProviderRequiredWhenActive CheckConstraint = "chat_model_configs_ai_provider_required_when_active" // chat_model_configs CheckChatModelConfigsCompressionThresholdCheck CheckConstraint = "chat_model_configs_compression_threshold_check" // chat_model_configs CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index bc93df7cd3..c6cdfcbca3 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1736,6 +1736,44 @@ func decodeChatLastError(raw pqtype.NullRawMessage) *codersdk.ChatError { return &payload } +// ChatGoal converts a database.ChatGoal to a codersdk.ChatGoal. +func ChatGoal(goal database.ChatGoal) codersdk.ChatGoal { + converted := codersdk.ChatGoal{ + ID: goal.ID, + RootChatID: goal.RootChatID, + Objective: goal.Objective, + Status: codersdk.ChatGoalStatus(goal.Status), + CreatedByUserID: goal.CreatedByUserID, + CompletedByAgent: goal.CompletedByAgent, + CreatedAt: goal.CreatedAt, + UpdatedAt: goal.UpdatedAt, + } + if goal.CreatedFromChatID.Valid { + createdFromChatID := goal.CreatedFromChatID.UUID + converted.CreatedFromChatID = &createdFromChatID + } + if goal.CompletionSummary.Valid { + converted.CompletionSummary = &goal.CompletionSummary.String + } + if goal.CompletedByUserID.Valid { + completedByUserID := goal.CompletedByUserID.UUID + converted.CompletedByUserID = &completedByUserID + } + if goal.CompletedAt.Valid { + completedAt := goal.CompletedAt.Time + converted.CompletedAt = &completedAt + } + if goal.ClearedAt.Valid { + clearedAt := goal.ClearedAt.Time + converted.ClearedAt = &clearedAt + } + if goal.ReplacedAt.Valid { + replacedAt := goal.ReplacedAt.Time + converted.ReplacedAt = &replacedAt + } + return converted +} + // Chat converts a database.Chat to a codersdk.Chat. It coalesces // nil slices and maps to empty values for JSON serialization and // derives RootChatID from the parent chain when not explicitly set. diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 7dce695afc..fbddc5393e 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -907,6 +907,47 @@ func TestChatQueuedMessage_ParsesUserContentParts(t *testing.T) { require.Equal(t, "queued text", queued.Content[0].Text) } +func TestChatGoal(t *testing.T) { + t.Parallel() + + now := dbtime.Now() + createdFromChatID := uuid.New() + completedByUserID := uuid.New() + goal := database.ChatGoal{ + ID: uuid.New(), + RootChatID: uuid.New(), + CreatedFromChatID: uuid.NullUUID{UUID: createdFromChatID, Valid: true}, + Objective: "ship goals", + Status: database.ChatGoalStatusComplete, + CompletionSummary: sql.NullString{String: "done", Valid: true}, + CreatedByUserID: uuid.New(), + CompletedByUserID: uuid.NullUUID{UUID: completedByUserID, Valid: true}, + CompletedByAgent: true, + CreatedAt: now, + UpdatedAt: now, + CompletedAt: sql.NullTime{Time: now, Valid: true}, + ClearedAt: sql.NullTime{Time: now, Valid: true}, + ReplacedAt: sql.NullTime{Time: now, Valid: true}, + } + + converted := db2sdk.ChatGoal(goal) + + require.Equal(t, goal.ID, converted.ID) + require.Equal(t, goal.RootChatID, converted.RootChatID) + require.Equal(t, createdFromChatID, *converted.CreatedFromChatID) + require.Equal(t, goal.Objective, converted.Objective) + require.Equal(t, codersdk.ChatGoalStatusComplete, converted.Status) + require.Equal(t, "done", *converted.CompletionSummary) + require.Equal(t, goal.CreatedByUserID, converted.CreatedByUserID) + require.Equal(t, completedByUserID, *converted.CompletedByUserID) + require.True(t, converted.CompletedByAgent) + require.Equal(t, now, converted.CreatedAt) + require.Equal(t, now, converted.UpdatedAt) + require.Equal(t, now, *converted.CompletedAt) + require.Equal(t, now, *converted.ClearedAt) + require.Equal(t, now, *converted.ReplacedAt) +} + func TestChat_AllFieldsPopulated(t *testing.T) { t.Parallel() @@ -990,9 +1031,9 @@ func TestChat_AllFieldsPopulated(t *testing.T) { typ := v.Type() // HasUnread is populated by ChatRowsWithChildren (which joins the // read-cursor query), not by Chat. Warnings is a transient - // field populated by handlers, not the converter. Both are - // expected to remain zero here. - skip := map[string]bool{"HasUnread": true, "Warnings": true} + // field populated by handlers, not the converter. Goal is attached + // by handlers after loading the current root chat goal. + skip := map[string]bool{"Goal": true, "HasUnread": true, "Warnings": true} for i := range typ.NumField() { field := typ.Field(i) if skip[field.Name] { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d084514dd8..96f1998e8a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1804,6 +1804,21 @@ func (q *querier) CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error return q.db.CleanupDeletedMCPServerIDsFromChats(ctx) } +func (q *querier) authorizeChatGoalRoot(ctx context.Context, action policy.Action, rootChatID uuid.UUID) error { + chat, err := q.db.GetChatByID(ctx, rootChatID) + if err != nil { + return err + } + return q.authorizeContext(ctx, action, chat) +} + +func (q *querier) ClearChatGoalByID(ctx context.Context, arg database.ClearChatGoalByIDParams) (database.ChatGoal, error) { + if err := q.authorizeChatGoalRoot(ctx, policy.ActionUpdate, arg.RootChatID); err != nil { + return database.ChatGoal{}, err + } + return q.db.ClearChatGoalByID(ctx, arg) +} + func (q *querier) ClearChatMessageProviderResponseIDsByChatID(ctx context.Context, chatID uuid.UUID) error { chat, err := q.db.GetChatByID(ctx, chatID) if err != nil { @@ -1815,6 +1830,13 @@ func (q *querier) ClearChatMessageProviderResponseIDsByChatID(ctx context.Contex return q.db.ClearChatMessageProviderResponseIDsByChatID(ctx, chatID) } +func (q *querier) CompleteChatGoalByID(ctx context.Context, arg database.CompleteChatGoalByIDParams) (database.ChatGoal, error) { + if err := q.authorizeChatGoalRoot(ctx, policy.ActionUpdate, arg.RootChatID); err != nil { + return database.ChatGoal{}, err + } + return q.db.CompleteChatGoalByID(ctx, arg) +} + func (q *querier) CountAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams) (int64, error) { prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type) if err != nil { @@ -3377,6 +3399,13 @@ func (q *querier) GetCryptoKeysByFeature(ctx context.Context, feature database.C return q.db.GetCryptoKeysByFeature(ctx, feature) } +func (q *querier) GetCurrentChatGoalByRootChatID(ctx context.Context, rootChatID uuid.UUID) (database.ChatGoal, error) { + if err := q.authorizeChatGoalRoot(ctx, policy.ActionRead, rootChatID); err != nil { + return database.ChatGoal{}, err + } + return q.db.GetCurrentChatGoalByRootChatID(ctx, rootChatID) +} + func (q *querier) GetDBCryptKeys(ctx context.Context) ([]database.DBCryptKey, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return nil, err @@ -5505,6 +5534,13 @@ func (q *querier) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyPar q.db.InsertAPIKey)(ctx, arg) } +func (q *querier) InsertActiveChatGoal(ctx context.Context, arg database.InsertActiveChatGoalParams) (database.ChatGoal, error) { + if err := q.authorizeChatGoalRoot(ctx, policy.ActionUpdate, arg.RootChatID); err != nil { + return database.ChatGoal{}, err + } + return q.db.InsertActiveChatGoal(ctx, arg) +} + func (q *querier) InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (database.Group, error) { // This method creates a new group. return insert(q.log, q.auth, rbac.ResourceGroup.InOrg(organizationID), q.db.InsertAllUsersGroup)(ctx, organizationID) @@ -6365,6 +6401,13 @@ func (q *querier) MarkAllInboxNotificationsAsRead(ctx context.Context, arg datab return q.db.MarkAllInboxNotificationsAsRead(ctx, arg) } +func (q *querier) MarkCurrentChatGoalReplacedByRootChatID(ctx context.Context, rootChatID uuid.UUID) ([]database.ChatGoal, error) { + if err := q.authorizeChatGoalRoot(ctx, policy.ActionUpdate, rootChatID); err != nil { + return nil, err + } + return q.db.MarkCurrentChatGoalReplacedByRootChatID(ctx, rootChatID) +} + func (q *querier) OIDCClaimFieldValues(ctx context.Context, args database.OIDCClaimFieldValuesParams) ([]string, error) { resource := rbac.ResourceIdpsyncSettings if args.OrganizationID != uuid.Nil { @@ -6400,6 +6443,13 @@ func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database return q.db.PaginatedOrganizationMembers(ctx, arg) } +func (q *querier) PauseChatGoalByID(ctx context.Context, arg database.PauseChatGoalByIDParams) (database.ChatGoal, error) { + if err := q.authorizeChatGoalRoot(ctx, policy.ActionUpdate, arg.RootChatID); err != nil { + return database.ChatGoal{}, err + } + return q.db.PauseChatGoalByID(ctx, arg) +} + func (q *querier) PinChatByID(ctx context.Context, id uuid.UUID) error { chat, err := q.db.GetChatByID(ctx, id) if err != nil { @@ -6468,6 +6518,13 @@ func (q *querier) ResolveUserChatSpendLimit(ctx context.Context, arg database.Re return q.db.ResolveUserChatSpendLimit(ctx, arg) } +func (q *querier) ResumeChatGoalByID(ctx context.Context, arg database.ResumeChatGoalByIDParams) (database.ChatGoal, error) { + if err := q.authorizeChatGoalRoot(ctx, policy.ActionUpdate, arg.RootChatID); err != nil { + return database.ChatGoal{}, err + } + return q.db.ResumeChatGoalByID(ctx, arg) +} + func (q *querier) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 1d7f7a62c3..64818d968e 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -555,6 +555,64 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UnarchiveChatByID(gomock.Any(), chat.ID).Return([]database.Chat{chat}, nil).AnyTimes() check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns([]database.Chat{chat}) })) + s.Run("GetCurrentChatGoalByRootChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + goal := testutil.Fake(s.T(), faker, database.ChatGoal{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().GetCurrentChatGoalByRootChatID(gomock.Any(), chat.ID).Return(goal, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(goal) + })) + s.Run("InsertActiveChatGoal", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + goal := testutil.Fake(s.T(), faker, database.ChatGoal{}) + arg := database.InsertActiveChatGoalParams{ + RootChatID: chat.ID, + Objective: "test goal", + CreatedByUserID: uuid.New(), + } + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().InsertActiveChatGoal(gomock.Any(), arg).Return(goal, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(goal) + })) + s.Run("MarkCurrentChatGoalReplacedByRootChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + goal := testutil.Fake(s.T(), faker, database.ChatGoal{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().MarkCurrentChatGoalReplacedByRootChatID(gomock.Any(), chat.ID).Return([]database.ChatGoal{goal}, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns([]database.ChatGoal{goal}) + })) + s.Run("PauseChatGoalByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + goal := testutil.Fake(s.T(), faker, database.ChatGoal{}) + arg := database.PauseChatGoalByIDParams{RootChatID: chat.ID, ID: goal.ID} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().PauseChatGoalByID(gomock.Any(), arg).Return(goal, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(goal) + })) + s.Run("ResumeChatGoalByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + goal := testutil.Fake(s.T(), faker, database.ChatGoal{}) + arg := database.ResumeChatGoalByIDParams{RootChatID: chat.ID, ID: goal.ID} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().ResumeChatGoalByID(gomock.Any(), arg).Return(goal, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(goal) + })) + s.Run("ClearChatGoalByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + goal := testutil.Fake(s.T(), faker, database.ChatGoal{}) + arg := database.ClearChatGoalByIDParams{RootChatID: chat.ID, ID: goal.ID} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().ClearChatGoalByID(gomock.Any(), arg).Return(goal, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(goal) + })) + s.Run("CompleteChatGoalByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + goal := testutil.Fake(s.T(), faker, database.ChatGoal{}) + arg := database.CompleteChatGoalByIDParams{RootChatID: chat.ID, ID: goal.ID} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().CompleteChatGoalByID(gomock.Any(), arg).Return(goal, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(goal) + })) s.Run("LinkChatFiles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := database.LinkChatFilesParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 7f68852baf..ccb4e72866 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -289,6 +289,14 @@ func (m queryMetricsStore) CleanupDeletedMCPServerIDsFromChats(ctx context.Conte return r0 } +func (m queryMetricsStore) ClearChatGoalByID(ctx context.Context, arg database.ClearChatGoalByIDParams) (database.ChatGoal, error) { + start := time.Now() + r0, r1 := m.s.ClearChatGoalByID(ctx, arg) + m.queryLatencies.WithLabelValues("ClearChatGoalByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ClearChatGoalByID").Inc() + return r0, r1 +} + func (m queryMetricsStore) ClearChatMessageProviderResponseIDsByChatID(ctx context.Context, chatID uuid.UUID) error { start := time.Now() r0 := m.s.ClearChatMessageProviderResponseIDsByChatID(ctx, chatID) @@ -297,6 +305,14 @@ func (m queryMetricsStore) ClearChatMessageProviderResponseIDsByChatID(ctx conte return r0 } +func (m queryMetricsStore) CompleteChatGoalByID(ctx context.Context, arg database.CompleteChatGoalByIDParams) (database.ChatGoal, error) { + start := time.Now() + r0, r1 := m.s.CompleteChatGoalByID(ctx, arg) + m.queryLatencies.WithLabelValues("CompleteChatGoalByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CompleteChatGoalByID").Inc() + return r0, r1 +} + func (m queryMetricsStore) CountAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams) (int64, error) { start := time.Now() r0, r1 := m.s.CountAIBridgeInterceptions(ctx, arg) @@ -1737,6 +1753,14 @@ func (m queryMetricsStore) GetCryptoKeysByFeature(ctx context.Context, feature d return r0, r1 } +func (m queryMetricsStore) GetCurrentChatGoalByRootChatID(ctx context.Context, rootChatID uuid.UUID) (database.ChatGoal, error) { + start := time.Now() + r0, r1 := m.s.GetCurrentChatGoalByRootChatID(ctx, rootChatID) + m.queryLatencies.WithLabelValues("GetCurrentChatGoalByRootChatID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetCurrentChatGoalByRootChatID").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetDBCryptKeys(ctx context.Context) ([]database.DBCryptKey, error) { start := time.Now() r0, r1 := m.s.GetDBCryptKeys(ctx) @@ -3761,6 +3785,14 @@ func (m queryMetricsStore) InsertAPIKey(ctx context.Context, arg database.Insert return r0, r1 } +func (m queryMetricsStore) InsertActiveChatGoal(ctx context.Context, arg database.InsertActiveChatGoalParams) (database.ChatGoal, error) { + start := time.Now() + r0, r1 := m.s.InsertActiveChatGoal(ctx, arg) + m.queryLatencies.WithLabelValues("InsertActiveChatGoal").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertActiveChatGoal").Inc() + return r0, r1 +} + func (m queryMetricsStore) InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (database.Group, error) { start := time.Now() r0, r1 := m.s.InsertAllUsersGroup(ctx, organizationID) @@ -4545,6 +4577,14 @@ func (m queryMetricsStore) MarkAllInboxNotificationsAsRead(ctx context.Context, return r0 } +func (m queryMetricsStore) MarkCurrentChatGoalReplacedByRootChatID(ctx context.Context, rootChatID uuid.UUID) ([]database.ChatGoal, error) { + start := time.Now() + r0, r1 := m.s.MarkCurrentChatGoalReplacedByRootChatID(ctx, rootChatID) + m.queryLatencies.WithLabelValues("MarkCurrentChatGoalReplacedByRootChatID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "MarkCurrentChatGoalReplacedByRootChatID").Inc() + return r0, r1 +} + func (m queryMetricsStore) OIDCClaimFieldValues(ctx context.Context, arg database.OIDCClaimFieldValuesParams) ([]string, error) { start := time.Now() r0, r1 := m.s.OIDCClaimFieldValues(ctx, arg) @@ -4577,6 +4617,14 @@ func (m queryMetricsStore) PaginatedOrganizationMembers(ctx context.Context, arg return r0, r1 } +func (m queryMetricsStore) PauseChatGoalByID(ctx context.Context, arg database.PauseChatGoalByIDParams) (database.ChatGoal, error) { + start := time.Now() + r0, r1 := m.s.PauseChatGoalByID(ctx, arg) + m.queryLatencies.WithLabelValues("PauseChatGoalByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "PauseChatGoalByID").Inc() + return r0, r1 +} + func (m queryMetricsStore) PinChatByID(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.PinChatByID(ctx, id) @@ -4633,6 +4681,14 @@ func (m queryMetricsStore) ResolveUserChatSpendLimit(ctx context.Context, userID return r0, r1 } +func (m queryMetricsStore) ResumeChatGoalByID(ctx context.Context, arg database.ResumeChatGoalByIDParams) (database.ChatGoal, error) { + start := time.Now() + r0, r1 := m.s.ResumeChatGoalByID(ctx, arg) + m.queryLatencies.WithLabelValues("ResumeChatGoalByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ResumeChatGoalByID").Inc() + return r0, r1 +} + func (m queryMetricsStore) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error { start := time.Now() r0 := m.s.RevokeDBCryptKey(ctx, activeKeyDigest) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 8321983028..7309d07edf 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -379,6 +379,21 @@ func (mr *MockStoreMockRecorder) CleanupDeletedMCPServerIDsFromChats(ctx any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupDeletedMCPServerIDsFromChats", reflect.TypeOf((*MockStore)(nil).CleanupDeletedMCPServerIDsFromChats), ctx) } +// ClearChatGoalByID mocks base method. +func (m *MockStore) ClearChatGoalByID(ctx context.Context, arg database.ClearChatGoalByIDParams) (database.ChatGoal, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClearChatGoalByID", ctx, arg) + ret0, _ := ret[0].(database.ChatGoal) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ClearChatGoalByID indicates an expected call of ClearChatGoalByID. +func (mr *MockStoreMockRecorder) ClearChatGoalByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearChatGoalByID", reflect.TypeOf((*MockStore)(nil).ClearChatGoalByID), ctx, arg) +} + // ClearChatMessageProviderResponseIDsByChatID mocks base method. func (m *MockStore) ClearChatMessageProviderResponseIDsByChatID(ctx context.Context, chatID uuid.UUID) error { m.ctrl.T.Helper() @@ -393,6 +408,21 @@ func (mr *MockStoreMockRecorder) ClearChatMessageProviderResponseIDsByChatID(ctx return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearChatMessageProviderResponseIDsByChatID", reflect.TypeOf((*MockStore)(nil).ClearChatMessageProviderResponseIDsByChatID), ctx, chatID) } +// CompleteChatGoalByID mocks base method. +func (m *MockStore) CompleteChatGoalByID(ctx context.Context, arg database.CompleteChatGoalByIDParams) (database.ChatGoal, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CompleteChatGoalByID", ctx, arg) + ret0, _ := ret[0].(database.ChatGoal) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CompleteChatGoalByID indicates an expected call of CompleteChatGoalByID. +func (mr *MockStoreMockRecorder) CompleteChatGoalByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CompleteChatGoalByID", reflect.TypeOf((*MockStore)(nil).CompleteChatGoalByID), ctx, arg) +} + // CountAIBridgeInterceptions mocks base method. func (m *MockStore) CountAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams) (int64, error) { m.ctrl.T.Helper() @@ -3225,6 +3255,21 @@ func (mr *MockStoreMockRecorder) GetCryptoKeysByFeature(ctx, feature any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCryptoKeysByFeature", reflect.TypeOf((*MockStore)(nil).GetCryptoKeysByFeature), ctx, feature) } +// GetCurrentChatGoalByRootChatID mocks base method. +func (m *MockStore) GetCurrentChatGoalByRootChatID(ctx context.Context, rootChatID uuid.UUID) (database.ChatGoal, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCurrentChatGoalByRootChatID", ctx, rootChatID) + ret0, _ := ret[0].(database.ChatGoal) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCurrentChatGoalByRootChatID indicates an expected call of GetCurrentChatGoalByRootChatID. +func (mr *MockStoreMockRecorder) GetCurrentChatGoalByRootChatID(ctx, rootChatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentChatGoalByRootChatID", reflect.TypeOf((*MockStore)(nil).GetCurrentChatGoalByRootChatID), ctx, rootChatID) +} + // GetDBCryptKeys mocks base method. func (m *MockStore) GetDBCryptKeys(ctx context.Context) ([]database.DBCryptKey, error) { m.ctrl.T.Helper() @@ -7064,6 +7109,21 @@ func (mr *MockStoreMockRecorder) InsertAPIKey(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAPIKey", reflect.TypeOf((*MockStore)(nil).InsertAPIKey), ctx, arg) } +// InsertActiveChatGoal mocks base method. +func (m *MockStore) InsertActiveChatGoal(ctx context.Context, arg database.InsertActiveChatGoalParams) (database.ChatGoal, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertActiveChatGoal", ctx, arg) + ret0, _ := ret[0].(database.ChatGoal) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertActiveChatGoal indicates an expected call of InsertActiveChatGoal. +func (mr *MockStoreMockRecorder) InsertActiveChatGoal(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertActiveChatGoal", reflect.TypeOf((*MockStore)(nil).InsertActiveChatGoal), ctx, arg) +} + // InsertAllUsersGroup mocks base method. func (m *MockStore) InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (database.Group, error) { m.ctrl.T.Helper() @@ -8593,6 +8653,21 @@ func (mr *MockStoreMockRecorder) MarkAllInboxNotificationsAsRead(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkAllInboxNotificationsAsRead", reflect.TypeOf((*MockStore)(nil).MarkAllInboxNotificationsAsRead), ctx, arg) } +// MarkCurrentChatGoalReplacedByRootChatID mocks base method. +func (m *MockStore) MarkCurrentChatGoalReplacedByRootChatID(ctx context.Context, rootChatID uuid.UUID) ([]database.ChatGoal, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkCurrentChatGoalReplacedByRootChatID", ctx, rootChatID) + ret0, _ := ret[0].([]database.ChatGoal) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MarkCurrentChatGoalReplacedByRootChatID indicates an expected call of MarkCurrentChatGoalReplacedByRootChatID. +func (mr *MockStoreMockRecorder) MarkCurrentChatGoalReplacedByRootChatID(ctx, rootChatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkCurrentChatGoalReplacedByRootChatID", reflect.TypeOf((*MockStore)(nil).MarkCurrentChatGoalReplacedByRootChatID), ctx, rootChatID) +} + // OIDCClaimFieldValues mocks base method. func (m *MockStore) OIDCClaimFieldValues(ctx context.Context, arg database.OIDCClaimFieldValuesParams) ([]string, error) { m.ctrl.T.Helper() @@ -8668,6 +8743,21 @@ func (mr *MockStoreMockRecorder) PaginatedOrganizationMembers(ctx, arg any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaginatedOrganizationMembers", reflect.TypeOf((*MockStore)(nil).PaginatedOrganizationMembers), ctx, arg) } +// PauseChatGoalByID mocks base method. +func (m *MockStore) PauseChatGoalByID(ctx context.Context, arg database.PauseChatGoalByIDParams) (database.ChatGoal, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PauseChatGoalByID", ctx, arg) + ret0, _ := ret[0].(database.ChatGoal) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PauseChatGoalByID indicates an expected call of PauseChatGoalByID. +func (mr *MockStoreMockRecorder) PauseChatGoalByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PauseChatGoalByID", reflect.TypeOf((*MockStore)(nil).PauseChatGoalByID), ctx, arg) +} + // PinChatByID mocks base method. func (m *MockStore) PinChatByID(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -8786,6 +8876,21 @@ func (mr *MockStoreMockRecorder) ResolveUserChatSpendLimit(ctx, arg any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveUserChatSpendLimit", reflect.TypeOf((*MockStore)(nil).ResolveUserChatSpendLimit), ctx, arg) } +// ResumeChatGoalByID mocks base method. +func (m *MockStore) ResumeChatGoalByID(ctx context.Context, arg database.ResumeChatGoalByIDParams) (database.ChatGoal, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResumeChatGoalByID", ctx, arg) + ret0, _ := ret[0].(database.ChatGoal) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResumeChatGoalByID indicates an expected call of ResumeChatGoalByID. +func (mr *MockStoreMockRecorder) ResumeChatGoalByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResumeChatGoalByID", reflect.TypeOf((*MockStore)(nil).ResumeChatGoalByID), ctx, arg) +} + // RevokeDBCryptKey mocks base method. func (m *MockStore) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ea09f4300f..eece198809 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -312,6 +312,14 @@ CREATE TYPE chat_client_type AS ENUM ( 'api' ); +CREATE TYPE chat_goal_status AS ENUM ( + 'active', + 'paused', + 'complete', + 'cleared', + 'replaced' +); + CREATE TYPE chat_message_role AS ENUM ( 'system', 'user', @@ -1669,6 +1677,30 @@ CREATE TABLE chat_files ( data bytea NOT NULL ); +CREATE TABLE chat_goals ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + root_chat_id uuid NOT NULL, + created_from_chat_id uuid, + objective text NOT NULL, + status chat_goal_status NOT NULL, + completion_summary text, + created_by_user_id uuid NOT NULL, + completed_by_user_id uuid, + completed_by_agent boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + completed_at timestamp with time zone, + cleared_at timestamp with time zone, + replaced_at timestamp with time zone, + CONSTRAINT chat_goals_cleared_at_status_check CHECK (((status = 'cleared'::chat_goal_status) = (cleared_at IS NOT NULL))), + CONSTRAINT chat_goals_completed_at_status_check CHECK (((status = 'complete'::chat_goal_status) = (completed_at IS NOT NULL))), + CONSTRAINT chat_goals_completed_by_agent_status_check CHECK (((completed_by_agent = false) OR (status = 'complete'::chat_goal_status))), + CONSTRAINT chat_goals_completed_by_user_status_check CHECK (((completed_by_user_id IS NULL) OR (status = 'complete'::chat_goal_status))), + CONSTRAINT chat_goals_completion_summary_status_check CHECK (((completion_summary IS NULL) OR (status = 'complete'::chat_goal_status))), + CONSTRAINT chat_goals_objective_not_empty CHECK ((length(btrim(objective)) > 0)), + CONSTRAINT chat_goals_replaced_at_status_check CHECK (((status = 'replaced'::chat_goal_status) = (replaced_at IS NOT NULL))) +); + CREATE TABLE chat_messages ( id bigint NOT NULL, chat_id uuid NOT NULL, @@ -3844,6 +3876,9 @@ ALTER TABLE ONLY chat_file_links ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_pkey PRIMARY KEY (id); +ALTER TABLE ONLY chat_goals + ADD CONSTRAINT chat_goals_pkey PRIMARY KEY (id); + ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); @@ -4274,6 +4309,10 @@ CREATE INDEX idx_chat_files_org ON chat_files USING btree (organization_id); CREATE INDEX idx_chat_files_owner ON chat_files USING btree (owner_id); +CREATE UNIQUE INDEX idx_chat_goals_current ON chat_goals USING btree (root_chat_id) WHERE (status = ANY (ARRAY['active'::chat_goal_status, 'paused'::chat_goal_status])); + +CREATE INDEX idx_chat_goals_root_created ON chat_goals USING btree (root_chat_id, created_at DESC, id DESC); + CREATE INDEX idx_chat_messages_chat ON chat_messages USING btree (chat_id); CREATE INDEX idx_chat_messages_chat_created ON chat_messages USING btree (chat_id, created_at); @@ -4632,6 +4671,18 @@ ALTER TABLE ONLY chat_files ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY chat_goals + ADD CONSTRAINT chat_goals_completed_by_user_id_fkey FOREIGN KEY (completed_by_user_id) REFERENCES users(id); + +ALTER TABLE ONLY chat_goals + ADD CONSTRAINT chat_goals_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES users(id); + +ALTER TABLE ONLY chat_goals + ADD CONSTRAINT chat_goals_created_from_chat_id_fkey FOREIGN KEY (created_from_chat_id) REFERENCES chats(id) ON DELETE SET NULL; + +ALTER TABLE ONLY chat_goals + ADD CONSTRAINT chat_goals_root_chat_id_fkey FOREIGN KEY (root_chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 8109f2564f..287b44402d 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -22,6 +22,10 @@ const ( ForeignKeyChatFileLinksFileID ForeignKeyConstraint = "chat_file_links_file_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_file_id_fkey FOREIGN KEY (file_id) REFERENCES chat_files(id) ON DELETE CASCADE; ForeignKeyChatFilesOrganizationID ForeignKeyConstraint = "chat_files_organization_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyChatFilesOwnerID ForeignKeyConstraint = "chat_files_owner_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyChatGoalsCompletedByUserID ForeignKeyConstraint = "chat_goals_completed_by_user_id_fkey" // ALTER TABLE ONLY chat_goals ADD CONSTRAINT chat_goals_completed_by_user_id_fkey FOREIGN KEY (completed_by_user_id) REFERENCES users(id); + ForeignKeyChatGoalsCreatedByUserID ForeignKeyConstraint = "chat_goals_created_by_user_id_fkey" // ALTER TABLE ONLY chat_goals ADD CONSTRAINT chat_goals_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES users(id); + ForeignKeyChatGoalsCreatedFromChatID ForeignKeyConstraint = "chat_goals_created_from_chat_id_fkey" // ALTER TABLE ONLY chat_goals ADD CONSTRAINT chat_goals_created_from_chat_id_fkey FOREIGN KEY (created_from_chat_id) REFERENCES chats(id) ON DELETE SET NULL; + ForeignKeyChatGoalsRootChatID ForeignKeyConstraint = "chat_goals_root_chat_id_fkey" // ALTER TABLE ONLY chat_goals ADD CONSTRAINT chat_goals_root_chat_id_fkey FOREIGN KEY (root_chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatMessagesAPIKeyID ForeignKeyConstraint = "chat_messages_api_key_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL; ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatMessagesModelConfigID ForeignKeyConstraint = "chat_messages_model_config_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_model_config_id_fkey FOREIGN KEY (model_config_id) REFERENCES chat_model_configs(id); diff --git a/coderd/database/migrations/000503_chat_goal_persistence.down.sql b/coderd/database/migrations/000503_chat_goal_persistence.down.sql new file mode 100644 index 0000000000..dfb0d21b0b --- /dev/null +++ b/coderd/database/migrations/000503_chat_goal_persistence.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS chat_goals; +DROP TYPE IF EXISTS chat_goal_status; diff --git a/coderd/database/migrations/000503_chat_goal_persistence.up.sql b/coderd/database/migrations/000503_chat_goal_persistence.up.sql new file mode 100644 index 0000000000..ceda0a21f6 --- /dev/null +++ b/coderd/database/migrations/000503_chat_goal_persistence.up.sql @@ -0,0 +1,38 @@ +CREATE TYPE chat_goal_status AS ENUM ( + 'active', + 'paused', + 'complete', + 'cleared', + 'replaced' +); + +CREATE TABLE chat_goals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + root_chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + created_from_chat_id UUID REFERENCES chats(id) ON DELETE SET NULL, + objective TEXT NOT NULL, + status chat_goal_status NOT NULL, + completion_summary TEXT, + created_by_user_id UUID NOT NULL REFERENCES users(id), + completed_by_user_id UUID REFERENCES users(id), + completed_by_agent BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + cleared_at TIMESTAMPTZ, + replaced_at TIMESTAMPTZ, + CONSTRAINT chat_goals_objective_not_empty CHECK (length(btrim(objective)) > 0), + CONSTRAINT chat_goals_completed_at_status_check CHECK ((status = 'complete') = (completed_at IS NOT NULL)), + CONSTRAINT chat_goals_cleared_at_status_check CHECK ((status = 'cleared') = (cleared_at IS NOT NULL)), + CONSTRAINT chat_goals_replaced_at_status_check CHECK ((status = 'replaced') = (replaced_at IS NOT NULL)), + CONSTRAINT chat_goals_completion_summary_status_check CHECK (completion_summary IS NULL OR status = 'complete'), + CONSTRAINT chat_goals_completed_by_user_status_check CHECK (completed_by_user_id IS NULL OR status = 'complete'), + CONSTRAINT chat_goals_completed_by_agent_status_check CHECK (completed_by_agent = FALSE OR status = 'complete') +); + +CREATE UNIQUE INDEX idx_chat_goals_current + ON chat_goals(root_chat_id) + WHERE status IN ('active', 'paused'); + +CREATE INDEX idx_chat_goals_root_created + ON chat_goals(root_chat_id, created_at DESC, id DESC); diff --git a/coderd/database/migrations/testdata/fixtures/000503_chat_goal_persistence.up.sql b/coderd/database/migrations/testdata/fixtures/000503_chat_goal_persistence.up.sql new file mode 100644 index 0000000000..40a11312e7 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000503_chat_goal_persistence.up.sql @@ -0,0 +1,22 @@ +INSERT INTO chat_goals ( + id, + root_chat_id, + created_from_chat_id, + objective, + status, + created_by_user_id, + created_at, + updated_at +) +SELECT + 'c8dcb6e1-85f6-48a3-8f70-2bc4e9b98025', + '72c0438a-18eb-4688-ab80-e4c6a126ef96', + '72c0438a-18eb-4688-ab80-e4c6a126ef96', + 'Fixture goal', + 'active', + id, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' +FROM users +ORDER BY created_at, id +LIMIT 1; diff --git a/coderd/database/models.go b/coderd/database/models.go index 8a6aa5fd4d..13c3c35db9 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1319,6 +1319,73 @@ func AllChatClientTypeValues() []ChatClientType { } } +type ChatGoalStatus string + +const ( + ChatGoalStatusActive ChatGoalStatus = "active" + ChatGoalStatusPaused ChatGoalStatus = "paused" + ChatGoalStatusComplete ChatGoalStatus = "complete" + ChatGoalStatusCleared ChatGoalStatus = "cleared" + ChatGoalStatusReplaced ChatGoalStatus = "replaced" +) + +func (e *ChatGoalStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ChatGoalStatus(s) + case string: + *e = ChatGoalStatus(s) + default: + return fmt.Errorf("unsupported scan type for ChatGoalStatus: %T", src) + } + return nil +} + +type NullChatGoalStatus struct { + ChatGoalStatus ChatGoalStatus `json:"chat_goal_status"` + Valid bool `json:"valid"` // Valid is true if ChatGoalStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullChatGoalStatus) Scan(value interface{}) error { + if value == nil { + ns.ChatGoalStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ChatGoalStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullChatGoalStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ChatGoalStatus), nil +} + +func (e ChatGoalStatus) Valid() bool { + switch e { + case ChatGoalStatusActive, + ChatGoalStatusPaused, + ChatGoalStatusComplete, + ChatGoalStatusCleared, + ChatGoalStatusReplaced: + return true + } + return false +} + +func AllChatGoalStatusValues() []ChatGoalStatus { + return []ChatGoalStatus{ + ChatGoalStatusActive, + ChatGoalStatusPaused, + ChatGoalStatusComplete, + ChatGoalStatusCleared, + ChatGoalStatusReplaced, + } +} + type ChatMessageRole string const ( @@ -4717,6 +4784,23 @@ type ChatFileLink struct { FileID uuid.UUID `db:"file_id" json:"file_id"` } +type ChatGoal struct { + ID uuid.UUID `db:"id" json:"id"` + RootChatID uuid.UUID `db:"root_chat_id" json:"root_chat_id"` + CreatedFromChatID uuid.NullUUID `db:"created_from_chat_id" json:"created_from_chat_id"` + Objective string `db:"objective" json:"objective"` + Status ChatGoalStatus `db:"status" json:"status"` + CompletionSummary sql.NullString `db:"completion_summary" json:"completion_summary"` + CreatedByUserID uuid.UUID `db:"created_by_user_id" json:"created_by_user_id"` + CompletedByUserID uuid.NullUUID `db:"completed_by_user_id" json:"completed_by_user_id"` + CompletedByAgent bool `db:"completed_by_agent" json:"completed_by_agent"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"` + ClearedAt sql.NullTime `db:"cleared_at" json:"cleared_at"` + ReplacedAt sql.NullTime `db:"replaced_at" json:"replaced_at"` +} + type ChatMessage struct { ID int64 `db:"id" json:"id"` ChatID uuid.UUID `db:"chat_id" json:"chat_id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4b9fa58e01..a1177f6aab 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -85,7 +85,9 @@ type sqlcQuerier interface { CleanTailnetLostPeers(ctx context.Context) error CleanTailnetTunnels(ctx context.Context) error CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error + ClearChatGoalByID(ctx context.Context, arg ClearChatGoalByIDParams) (ChatGoal, error) ClearChatMessageProviderResponseIDsByChatID(ctx context.Context, chatID uuid.UUID) error + CompleteChatGoalByID(ctx context.Context, arg CompleteChatGoalByIDParams) (ChatGoal, error) CountAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams) (int64, error) CountAIBridgeSessions(ctx context.Context, arg CountAIBridgeSessionsParams) (int64, error) CountAuditLogs(ctx context.Context, arg CountAuditLogsParams) (int64, error) @@ -444,6 +446,7 @@ type sqlcQuerier interface { GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) GetCryptoKeys(ctx context.Context) ([]CryptoKey, error) GetCryptoKeysByFeature(ctx context.Context, feature CryptoKeyFeature) ([]CryptoKey, error) + GetCurrentChatGoalByRootChatID(ctx context.Context, rootChatID uuid.UUID) (ChatGoal, error) GetDBCryptKeys(ctx context.Context) ([]DBCryptKey, error) GetDERPMeshKey(ctx context.Context) (string, error) GetDefaultChatModelConfig(ctx context.Context) (ChatModelConfig, error) @@ -919,6 +922,7 @@ type sqlcQuerier interface { InsertAIProvider(ctx context.Context, arg InsertAIProviderParams) (AIProvider, error) InsertAIProviderKey(ctx context.Context, arg InsertAIProviderKeyParams) (AIProviderKey, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) + InsertActiveChatGoal(ctx context.Context, arg InsertActiveChatGoalParams) (ChatGoal, error) // We use the organization_id as the id // for simplicity since all users is // every member of the org. @@ -1072,6 +1076,7 @@ type sqlcQuerier interface { ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]ListUserSkillMetadataByUserIDRow, error) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error + MarkCurrentChatGoalReplacedByRootChatID(ctx context.Context, rootChatID uuid.UUID) ([]ChatGoal, error) OIDCClaimFieldValues(ctx context.Context, arg OIDCClaimFieldValuesParams) ([]string, error) // OIDCClaimFields returns a list of distinct keys in the the merged_claims fields. // This query is used to generate the list of available sync fields for idp sync settings. @@ -1082,6 +1087,7 @@ type sqlcQuerier interface { // - Use both to get a specific org member row OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error) PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) + PauseChatGoalByID(ctx context.Context, arg PauseChatGoalByIDParams) (ChatGoal, error) // Under READ COMMITTED, concurrent pin operations for the same // owner may momentarily produce duplicate pin_order values because // each CTE snapshot does not see the other's writes. The next @@ -1107,6 +1113,7 @@ type sqlcQuerier interface { // limit_source indicates which tier won: 'user', 'group', 'default', // or 'disabled'. ResolveUserChatSpendLimit(ctx context.Context, arg ResolveUserChatSpendLimitParams) (ResolveUserChatSpendLimitRow, error) + ResumeChatGoalByID(ctx context.Context, arg ResumeChatGoalByIDParams) (ChatGoal, error) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error // Note that this selects from the CTE, not the original table. The CTE is named // the same as the original table to trick sqlc into reusing the existing struct diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 3a6e2ac639..c650702fe8 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12130,6 +12130,190 @@ func TestChatPinOrderConstraints(t *testing.T) { }) } +func TestChatGoalPersistence(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + + setup := func(t *testing.T) (database.Store, context.Context, database.User, database.Chat) { + t.Helper() + + store, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + owner := dbgen.User(t, store, database.User{}) + org := dbgen.Organization(t, store, database.Organization{}) + dbgen.OrganizationMember(t, store, database.OrganizationMember{UserID: owner.ID, OrganizationID: org.ID}) + + _, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + require.NoError(t, err) + + modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: "openai", + Model: "test-model-" + uuid.NewString(), + DisplayName: "Test Model", + CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: owner.ID, + LastModelConfigID: modelCfg.ID, + Title: "goal-test-" + uuid.NewString(), + }) + require.NoError(t, err) + + return store, ctx, owner, chat + } + + insertGoal := func(t *testing.T, store database.Store, ctx context.Context, chat database.Chat, owner database.User, objective string) database.ChatGoal { + t.Helper() + + goal, err := store.InsertActiveChatGoal(ctx, database.InsertActiveChatGoalParams{ + RootChatID: chat.ID, + CreatedFromChatID: uuid.NullUUID{UUID: chat.ID, Valid: true}, + Objective: objective, + CreatedByUserID: owner.ID, + }) + require.NoError(t, err) + return goal + } + + t.Run("CurrentGoalInvariant", func(t *testing.T) { + t.Parallel() + + store, ctx, owner, chat := setup(t) + first := insertGoal(t, store, ctx, chat, owner, "ship goal persistence") + require.Equal(t, database.ChatGoalStatusActive, first.Status) + + _, err := store.InsertActiveChatGoal(ctx, database.InsertActiveChatGoalParams{ + RootChatID: chat.ID, + Objective: "conflicting active goal", + CreatedByUserID: owner.ID, + }) + require.Error(t, err) + require.True(t, database.IsUniqueViolation(err, database.UniqueIndexChatGoalsCurrent)) + + paused, err := store.PauseChatGoalByID(ctx, database.PauseChatGoalByIDParams{ + RootChatID: chat.ID, + ID: first.ID, + }) + require.NoError(t, err) + require.Equal(t, database.ChatGoalStatusPaused, paused.Status) + + _, err = store.InsertActiveChatGoal(ctx, database.InsertActiveChatGoalParams{ + RootChatID: chat.ID, + Objective: "conflicting paused goal", + CreatedByUserID: owner.ID, + }) + require.Error(t, err) + require.True(t, database.IsUniqueViolation(err, database.UniqueIndexChatGoalsCurrent)) + + cleared, err := store.ClearChatGoalByID(ctx, database.ClearChatGoalByIDParams{ + RootChatID: chat.ID, + ID: first.ID, + }) + require.NoError(t, err) + require.Equal(t, database.ChatGoalStatusCleared, cleared.Status) + require.True(t, cleared.ClearedAt.Valid) + + second := insertGoal(t, store, ctx, chat, owner, "new current goal") + require.Equal(t, database.ChatGoalStatusActive, second.Status) + }) + + t.Run("LifecycleUsesOptimisticGoalID", func(t *testing.T) { + t.Parallel() + + store, ctx, owner, chat := setup(t) + goal := insertGoal(t, store, ctx, chat, owner, "complete the task") + + current, err := store.GetCurrentChatGoalByRootChatID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, goal.ID, current.ID) + + paused, err := store.PauseChatGoalByID(ctx, database.PauseChatGoalByIDParams{ + RootChatID: chat.ID, + ID: goal.ID, + }) + require.NoError(t, err) + require.Equal(t, database.ChatGoalStatusPaused, paused.Status) + + _, err = store.PauseChatGoalByID(ctx, database.PauseChatGoalByIDParams{ + RootChatID: chat.ID, + ID: goal.ID, + }) + require.ErrorIs(t, err, sql.ErrNoRows) + + resumed, err := store.ResumeChatGoalByID(ctx, database.ResumeChatGoalByIDParams{ + RootChatID: chat.ID, + ID: goal.ID, + }) + require.NoError(t, err) + require.Equal(t, database.ChatGoalStatusActive, resumed.Status) + + _, err = store.CompleteChatGoalByID(ctx, database.CompleteChatGoalByIDParams{ + RootChatID: chat.ID, + ID: uuid.New(), + CompletedByAgent: true, + }) + require.ErrorIs(t, err, sql.ErrNoRows) + + const summary = "done" + completed, err := store.CompleteChatGoalByID(ctx, database.CompleteChatGoalByIDParams{ + RootChatID: chat.ID, + ID: goal.ID, + CompletionSummary: sql.NullString{ + String: summary, + Valid: true, + }, + CompletedByUserID: uuid.NullUUID{UUID: owner.ID, Valid: true}, + }) + require.NoError(t, err) + require.Equal(t, database.ChatGoalStatusComplete, completed.Status) + require.Equal(t, summary, completed.CompletionSummary.String) + require.True(t, completed.CompletedAt.Valid) + require.Equal(t, owner.ID, completed.CompletedByUserID.UUID) + + _, err = store.GetCurrentChatGoalByRootChatID(ctx, chat.ID) + require.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("ReplaceCurrentGoal", func(t *testing.T) { + t.Parallel() + + store, ctx, owner, chat := setup(t) + first := insertGoal(t, store, ctx, chat, owner, "old goal") + + replaced, err := store.MarkCurrentChatGoalReplacedByRootChatID(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, replaced, 1) + require.Equal(t, first.ID, replaced[0].ID) + require.Equal(t, database.ChatGoalStatusReplaced, replaced[0].Status) + require.True(t, replaced[0].ReplacedAt.Valid) + + second := insertGoal(t, store, ctx, chat, owner, "replacement goal") + current, err := store.GetCurrentChatGoalByRootChatID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, second.ID, current.ID) + require.Equal(t, database.ChatGoalStatusActive, current.Status) + }) +} + func TestChatLabels(t *testing.T) { t.Parallel() if testing.Short() { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6b7b39c021..86a9eb13f2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6492,6 +6492,47 @@ func (q *sqlQuerier) BackoffChatDiffStatus(ctx context.Context, arg BackoffChatD return err } +const clearChatGoalByID = `-- name: ClearChatGoalByID :one +UPDATE + chat_goals +SET + status = 'cleared', + updated_at = NOW(), + cleared_at = NOW() +WHERE + root_chat_id = $1::uuid + AND id = $2::uuid + AND status IN ('active', 'paused') +RETURNING id, root_chat_id, created_from_chat_id, objective, status, completion_summary, created_by_user_id, completed_by_user_id, completed_by_agent, created_at, updated_at, completed_at, cleared_at, replaced_at +` + +type ClearChatGoalByIDParams struct { + RootChatID uuid.UUID `db:"root_chat_id" json:"root_chat_id"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) ClearChatGoalByID(ctx context.Context, arg ClearChatGoalByIDParams) (ChatGoal, error) { + row := q.db.QueryRowContext(ctx, clearChatGoalByID, arg.RootChatID, arg.ID) + var i ChatGoal + err := row.Scan( + &i.ID, + &i.RootChatID, + &i.CreatedFromChatID, + &i.Objective, + &i.Status, + &i.CompletionSummary, + &i.CreatedByUserID, + &i.CompletedByUserID, + &i.CompletedByAgent, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompletedAt, + &i.ClearedAt, + &i.ReplacedAt, + ) + return i, err +} + const clearChatMessageProviderResponseIDsByChatID = `-- name: ClearChatMessageProviderResponseIDsByChatID :exec UPDATE chat_messages SET provider_response_id = NULL @@ -6505,6 +6546,59 @@ func (q *sqlQuerier) ClearChatMessageProviderResponseIDsByChatID(ctx context.Con return err } +const completeChatGoalByID = `-- name: CompleteChatGoalByID :one +UPDATE + chat_goals +SET + status = 'complete', + completion_summary = $1::text, + completed_by_user_id = $2::uuid, + completed_by_agent = $3::bool, + updated_at = NOW(), + completed_at = NOW() +WHERE + root_chat_id = $4::uuid + AND id = $5::uuid + AND status = 'active' +RETURNING id, root_chat_id, created_from_chat_id, objective, status, completion_summary, created_by_user_id, completed_by_user_id, completed_by_agent, created_at, updated_at, completed_at, cleared_at, replaced_at +` + +type CompleteChatGoalByIDParams struct { + CompletionSummary sql.NullString `db:"completion_summary" json:"completion_summary"` + CompletedByUserID uuid.NullUUID `db:"completed_by_user_id" json:"completed_by_user_id"` + CompletedByAgent bool `db:"completed_by_agent" json:"completed_by_agent"` + RootChatID uuid.UUID `db:"root_chat_id" json:"root_chat_id"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) CompleteChatGoalByID(ctx context.Context, arg CompleteChatGoalByIDParams) (ChatGoal, error) { + row := q.db.QueryRowContext(ctx, completeChatGoalByID, + arg.CompletionSummary, + arg.CompletedByUserID, + arg.CompletedByAgent, + arg.RootChatID, + arg.ID, + ) + var i ChatGoal + err := row.Scan( + &i.ID, + &i.RootChatID, + &i.CreatedFromChatID, + &i.Objective, + &i.Status, + &i.CompletionSummary, + &i.CreatedByUserID, + &i.CompletedByUserID, + &i.CompletedByAgent, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompletedAt, + &i.ClearedAt, + &i.ReplacedAt, + ) + return i, err +} + const countEnabledModelsWithoutPricing = `-- name: CountEnabledModelsWithoutPricing :one SELECT COUNT(*)::bigint AS count FROM chat_model_configs @@ -8560,6 +8654,39 @@ func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildC return items, nil } +const getCurrentChatGoalByRootChatID = `-- name: GetCurrentChatGoalByRootChatID :one +SELECT + id, root_chat_id, created_from_chat_id, objective, status, completion_summary, created_by_user_id, completed_by_user_id, completed_by_agent, created_at, updated_at, completed_at, cleared_at, replaced_at +FROM + chat_goals +WHERE + root_chat_id = $1::uuid + AND status IN ('active', 'paused') +LIMIT 1 +` + +func (q *sqlQuerier) GetCurrentChatGoalByRootChatID(ctx context.Context, rootChatID uuid.UUID) (ChatGoal, error) { + row := q.db.QueryRowContext(ctx, getCurrentChatGoalByRootChatID, rootChatID) + var i ChatGoal + err := row.Scan( + &i.ID, + &i.RootChatID, + &i.CreatedFromChatID, + &i.Objective, + &i.Status, + &i.CompletionSummary, + &i.CreatedByUserID, + &i.CompletedByUserID, + &i.CompletedByAgent, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompletedAt, + &i.ClearedAt, + &i.ReplacedAt, + ) + return i, err +} + const getLastChatMessageByRole = `-- name: GetLastChatMessageByRole :one SELECT id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id @@ -8753,6 +8880,57 @@ func (q *sqlQuerier) GetUserGroupSpendLimit(ctx context.Context, arg GetUserGrou return limit_micros, err } +const insertActiveChatGoal = `-- name: InsertActiveChatGoal :one +INSERT INTO chat_goals ( + root_chat_id, + created_from_chat_id, + objective, + status, + created_by_user_id +) VALUES ( + $1::uuid, + $2::uuid, + $3::text, + 'active', + $4::uuid +) +RETURNING id, root_chat_id, created_from_chat_id, objective, status, completion_summary, created_by_user_id, completed_by_user_id, completed_by_agent, created_at, updated_at, completed_at, cleared_at, replaced_at +` + +type InsertActiveChatGoalParams struct { + RootChatID uuid.UUID `db:"root_chat_id" json:"root_chat_id"` + CreatedFromChatID uuid.NullUUID `db:"created_from_chat_id" json:"created_from_chat_id"` + Objective string `db:"objective" json:"objective"` + CreatedByUserID uuid.UUID `db:"created_by_user_id" json:"created_by_user_id"` +} + +func (q *sqlQuerier) InsertActiveChatGoal(ctx context.Context, arg InsertActiveChatGoalParams) (ChatGoal, error) { + row := q.db.QueryRowContext(ctx, insertActiveChatGoal, + arg.RootChatID, + arg.CreatedFromChatID, + arg.Objective, + arg.CreatedByUserID, + ) + var i ChatGoal + err := row.Scan( + &i.ID, + &i.RootChatID, + &i.CreatedFromChatID, + &i.Objective, + &i.Status, + &i.CompletionSummary, + &i.CreatedByUserID, + &i.CompletedByUserID, + &i.CompletedByAgent, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompletedAt, + &i.ClearedAt, + &i.ReplacedAt, + ) + return i, err +} + const insertChat = `-- name: InsertChat :one WITH inserted_chat AS ( INSERT INTO chats ( @@ -9261,6 +9439,97 @@ func (q *sqlQuerier) ListChatUsageLimitOverrides(ctx context.Context) ([]ListCha return items, nil } +const markCurrentChatGoalReplacedByRootChatID = `-- name: MarkCurrentChatGoalReplacedByRootChatID :many +UPDATE + chat_goals +SET + status = 'replaced', + updated_at = NOW(), + replaced_at = NOW() +WHERE + root_chat_id = $1::uuid + AND status IN ('active', 'paused') +RETURNING id, root_chat_id, created_from_chat_id, objective, status, completion_summary, created_by_user_id, completed_by_user_id, completed_by_agent, created_at, updated_at, completed_at, cleared_at, replaced_at +` + +func (q *sqlQuerier) MarkCurrentChatGoalReplacedByRootChatID(ctx context.Context, rootChatID uuid.UUID) ([]ChatGoal, error) { + rows, err := q.db.QueryContext(ctx, markCurrentChatGoalReplacedByRootChatID, rootChatID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatGoal + for rows.Next() { + var i ChatGoal + if err := rows.Scan( + &i.ID, + &i.RootChatID, + &i.CreatedFromChatID, + &i.Objective, + &i.Status, + &i.CompletionSummary, + &i.CreatedByUserID, + &i.CompletedByUserID, + &i.CompletedByAgent, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompletedAt, + &i.ClearedAt, + &i.ReplacedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const pauseChatGoalByID = `-- name: PauseChatGoalByID :one +UPDATE + chat_goals +SET + status = 'paused', + updated_at = NOW() +WHERE + root_chat_id = $1::uuid + AND id = $2::uuid + AND status = 'active' +RETURNING id, root_chat_id, created_from_chat_id, objective, status, completion_summary, created_by_user_id, completed_by_user_id, completed_by_agent, created_at, updated_at, completed_at, cleared_at, replaced_at +` + +type PauseChatGoalByIDParams struct { + RootChatID uuid.UUID `db:"root_chat_id" json:"root_chat_id"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) PauseChatGoalByID(ctx context.Context, arg PauseChatGoalByIDParams) (ChatGoal, error) { + row := q.db.QueryRowContext(ctx, pauseChatGoalByID, arg.RootChatID, arg.ID) + var i ChatGoal + err := row.Scan( + &i.ID, + &i.RootChatID, + &i.CreatedFromChatID, + &i.Objective, + &i.Status, + &i.CompletionSummary, + &i.CreatedByUserID, + &i.CompletedByUserID, + &i.CompletedByAgent, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompletedAt, + &i.ClearedAt, + &i.ReplacedAt, + ) + return i, err +} + const pinChatByID = `-- name: PinChatByID :exec WITH target_chat AS ( SELECT @@ -9429,6 +9698,46 @@ func (q *sqlQuerier) ResolveUserChatSpendLimit(ctx context.Context, arg ResolveU return i, err } +const resumeChatGoalByID = `-- name: ResumeChatGoalByID :one +UPDATE + chat_goals +SET + status = 'active', + updated_at = NOW() +WHERE + root_chat_id = $1::uuid + AND id = $2::uuid + AND status = 'paused' +RETURNING id, root_chat_id, created_from_chat_id, objective, status, completion_summary, created_by_user_id, completed_by_user_id, completed_by_agent, created_at, updated_at, completed_at, cleared_at, replaced_at +` + +type ResumeChatGoalByIDParams struct { + RootChatID uuid.UUID `db:"root_chat_id" json:"root_chat_id"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) ResumeChatGoalByID(ctx context.Context, arg ResumeChatGoalByIDParams) (ChatGoal, error) { + row := q.db.QueryRowContext(ctx, resumeChatGoalByID, arg.RootChatID, arg.ID) + var i ChatGoal + err := row.Scan( + &i.ID, + &i.RootChatID, + &i.CreatedFromChatID, + &i.Objective, + &i.Status, + &i.CompletionSummary, + &i.CreatedByUserID, + &i.CompletedByUserID, + &i.CompletedByAgent, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompletedAt, + &i.ClearedAt, + &i.ReplacedAt, + ) + return i, err +} + const softDeleteChatMessageByID = `-- name: SoftDeleteChatMessageByID :exec UPDATE chat_messages diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index c8b6502cf5..b5a3a61242 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1794,6 +1794,97 @@ chats_expanded AS ( SELECT * FROM chats_expanded; +-- name: GetCurrentChatGoalByRootChatID :one +SELECT + * +FROM + chat_goals +WHERE + root_chat_id = @root_chat_id::uuid + AND status IN ('active', 'paused') +LIMIT 1; + +-- name: MarkCurrentChatGoalReplacedByRootChatID :many +UPDATE + chat_goals +SET + status = 'replaced', + updated_at = NOW(), + replaced_at = NOW() +WHERE + root_chat_id = @root_chat_id::uuid + AND status IN ('active', 'paused') +RETURNING *; + +-- name: InsertActiveChatGoal :one +INSERT INTO chat_goals ( + root_chat_id, + created_from_chat_id, + objective, + status, + created_by_user_id +) VALUES ( + @root_chat_id::uuid, + sqlc.narg('created_from_chat_id')::uuid, + @objective::text, + 'active', + @created_by_user_id::uuid +) +RETURNING *; + +-- name: PauseChatGoalByID :one +UPDATE + chat_goals +SET + status = 'paused', + updated_at = NOW() +WHERE + root_chat_id = @root_chat_id::uuid + AND id = @id::uuid + AND status = 'active' +RETURNING *; + +-- name: ResumeChatGoalByID :one +UPDATE + chat_goals +SET + status = 'active', + updated_at = NOW() +WHERE + root_chat_id = @root_chat_id::uuid + AND id = @id::uuid + AND status = 'paused' +RETURNING *; + +-- name: ClearChatGoalByID :one +UPDATE + chat_goals +SET + status = 'cleared', + updated_at = NOW(), + cleared_at = NOW() +WHERE + root_chat_id = @root_chat_id::uuid + AND id = @id::uuid + AND status IN ('active', 'paused') +RETURNING *; + +-- name: CompleteChatGoalByID :one +UPDATE + chat_goals +SET + status = 'complete', + completion_summary = sqlc.narg('completion_summary')::text, + completed_by_user_id = sqlc.narg('completed_by_user_id')::uuid, + completed_by_agent = @completed_by_agent::bool, + updated_at = NOW(), + completed_at = NOW() +WHERE + root_chat_id = @root_chat_id::uuid + AND id = @id::uuid + AND status = 'active' +RETURNING *; + -- name: GetChatsByChatFileID :many SELECT * diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index fd11ab2e06..842ece8c0b 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -26,6 +26,7 @@ const ( UniqueChatDiffStatusesPkey UniqueConstraint = "chat_diff_statuses_pkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id); UniqueChatFileLinksChatIDFileIDKey UniqueConstraint = "chat_file_links_chat_id_file_id_key" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_chat_id_file_id_key UNIQUE (chat_id, file_id); UniqueChatFilesPkey UniqueConstraint = "chat_files_pkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_pkey PRIMARY KEY (id); + UniqueChatGoalsPkey UniqueConstraint = "chat_goals_pkey" // ALTER TABLE ONLY chat_goals ADD CONSTRAINT chat_goals_pkey PRIMARY KEY (id); UniqueChatMessagesPkey UniqueConstraint = "chat_messages_pkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); UniqueChatModelConfigsPkey UniqueConstraint = "chat_model_configs_pkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_pkey PRIMARY KEY (id); UniqueChatQueuedMessagesPkey UniqueConstraint = "chat_queued_messages_pkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_pkey PRIMARY KEY (id); @@ -143,6 +144,7 @@ const ( UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); UniqueIndexChatDebugRunsIDChat UniqueConstraint = "idx_chat_debug_runs_id_chat" // CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs USING btree (id, chat_id); UniqueIndexChatDebugStepsRunStep UniqueConstraint = "idx_chat_debug_steps_run_step" // CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps USING btree (run_id, step_number); + UniqueIndexChatGoalsCurrent UniqueConstraint = "idx_chat_goals_current" // CREATE UNIQUE INDEX idx_chat_goals_current ON chat_goals USING btree (root_chat_id) WHERE (status = ANY (ARRAY['active'::chat_goal_status, 'paused'::chat_goal_status])); UniqueIndexChatModelConfigsSingleDefault UniqueConstraint = "idx_chat_model_configs_single_default" // CREATE UNIQUE INDEX idx_chat_model_configs_single_default ON chat_model_configs USING btree ((1)) WHERE ((is_default = true) AND (deleted = false)); UniqueIndexConnectionLogsConnectionIDWorkspaceIDAgentName UniqueConstraint = "idx_connection_logs_connection_id_workspace_id_agent_name" // CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name); UniqueIndexCustomRolesNameLowerOrganizationID UniqueConstraint = "idx_custom_roles_name_lower_organization_id" // CREATE UNIQUE INDEX idx_custom_roles_name_lower_organization_id ON custom_roles USING btree (lower(name), COALESCE(organization_id, '00000000-0000-0000-0000-000000000000'::uuid)); diff --git a/codersdk/chats.go b/codersdk/chats.go index bcf235f590..dfb0253687 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -104,6 +104,54 @@ const ( ChatClientTypeAPI ChatClientType = "api" ) +// ChatGoalStatus represents the lifecycle state of a chat goal. +type ChatGoalStatus string + +const ( + ChatGoalStatusActive ChatGoalStatus = "active" + ChatGoalStatusPaused ChatGoalStatus = "paused" + ChatGoalStatusComplete ChatGoalStatus = "complete" + ChatGoalStatusCleared ChatGoalStatus = "cleared" + ChatGoalStatusReplaced ChatGoalStatus = "replaced" +) + +// ChatGoal is a durable objective associated with a root chat. +type ChatGoal struct { + ID uuid.UUID `json:"id" format:"uuid"` + RootChatID uuid.UUID `json:"root_chat_id" format:"uuid"` + CreatedFromChatID *uuid.UUID `json:"created_from_chat_id,omitempty" format:"uuid"` + Objective string `json:"objective"` + Status ChatGoalStatus `json:"status"` + CompletionSummary *string `json:"completion_summary,omitempty"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" format:"uuid"` + CompletedByUserID *uuid.UUID `json:"completed_by_user_id,omitempty" format:"uuid"` + CompletedByAgent bool `json:"completed_by_agent"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + CompletedAt *time.Time `json:"completed_at,omitempty" format:"date-time"` + ClearedAt *time.Time `json:"cleared_at,omitempty" format:"date-time"` + ReplacedAt *time.Time `json:"replaced_at,omitempty" format:"date-time"` +} + +// ChatGoalMutationAction identifies a goal lifecycle mutation. +type ChatGoalMutationAction string + +const ( + ChatGoalMutationActionSet ChatGoalMutationAction = "set" + ChatGoalMutationActionClear ChatGoalMutationAction = "clear" + ChatGoalMutationActionPause ChatGoalMutationAction = "pause" + ChatGoalMutationActionResume ChatGoalMutationAction = "resume" + ChatGoalMutationActionComplete ChatGoalMutationAction = "complete" +) + +// ChatGoalMutation requests a goal lifecycle change. +type ChatGoalMutation struct { + Action ChatGoalMutationAction `json:"action" enums:"set,clear,pause,resume,complete"` + GoalID *uuid.UUID `json:"goal_id,omitempty" format:"uuid"` + Objective string `json:"objective,omitempty"` + CompletionSummary *string `json:"completion_summary,omitempty"` +} + // Chat represents a chat session with an AI agent. type Chat struct { ID uuid.UUID `json:"id" format:"uuid"` @@ -123,6 +171,7 @@ type Chat struct { LastError *ChatError `json:"last_error,omitempty"` LastTurnSummary *string `json:"last_turn_summary"` DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"` + Goal *ChatGoal `json:"goal,omitempty"` CreatedAt time.Time `json:"created_at" format:"date-time"` UpdatedAt time.Time `json:"updated_at" format:"date-time"` Archived bool `json:"archived"` @@ -474,6 +523,7 @@ type CreateChatRequest struct { ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` MCPServerIDs []uuid.UUID `json:"mcp_server_ids,omitempty" format:"uuid"` Labels map[string]string `json:"labels,omitempty"` + GoalMutation *ChatGoalMutation `json:"goal_mutation,omitempty"` // UnsafeDynamicTools declares client-executed tools that the // LLM can invoke. This API is highly experimental and highly // subject to change. @@ -528,10 +578,11 @@ const ( // CreateChatMessageRequest is the request to add a message to a chat. type CreateChatMessageRequest struct { - Content []ChatInputPart `json:"content"` - ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` - MCPServerIDs *[]uuid.UUID `json:"mcp_server_ids,omitempty" format:"uuid"` - BusyBehavior ChatBusyBehavior `json:"busy_behavior,omitempty" enums:"queue,interrupt"` + Content []ChatInputPart `json:"content"` + ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` + MCPServerIDs *[]uuid.UUID `json:"mcp_server_ids,omitempty" format:"uuid"` + GoalMutation *ChatGoalMutation `json:"goal_mutation,omitempty"` + BusyBehavior ChatBusyBehavior `json:"busy_behavior,omitempty" enums:"queue,interrupt"` // PlanMode switches the chat's persistent plan mode. // nil: no change, ptr to "plan": enable, ptr to "": clear. PlanMode *ChatPlanMode `json:"plan_mode,omitempty"` diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index f475d8482d..8358b0e255 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -17,10 +17,10 @@ Experimental: this endpoint is subject to change. ### Parameters -| Name | In | Type | Required | Description | -|---------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `q` | query | string | false | Search query. Supports title: (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status: as repeated or comma-separated values, diff_url: (quote values containing colons), pr: (exact PR number match), repo: (case-insensitive substring match against git remote origin or URL), pr_title: (case-insensitive PR title substring). Bare terms are not supported; use title: for title filtering. | -| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). | +| Name | In | Type | Required | Description | +|---------|-------|--------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `q` | query | string | false | Search query. Supports title: (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status: as repeated or comma-separated values, and diff_url: (quote URLs). Bare terms are not supported; use title: for title filtering. | +| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). | ### Example responses @@ -68,6 +68,22 @@ Experimental: this endpoint is subject to change. "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -180,115 +196,130 @@ Experimental: this endpoint is subject to change. Status Code **200** -| Name | Type | Required | Restrictions | Description | -|-----------------------------------|------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `[array item]` | array | false | | | -| `» agent_id` | string(uuid) | false | | | -| `» archived` | boolean | false | | | -| `» build_id` | string(uuid) | false | | | -| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | -| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | -| `» created_at` | string(date-time) | false | | | -| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | -| `»» additions` | integer | false | | | -| `»» approved` | boolean | false | | | -| `»» author_avatar_url` | string | false | | | -| `»» author_login` | string | false | | | -| `»» base_branch` | string | false | | | -| `»» changed_files` | integer | false | | | -| `»» changes_requested` | boolean | false | | | -| `»» chat_id` | string(uuid) | false | | | -| `»» commits` | integer | false | | | -| `»» deletions` | integer | false | | | -| `»» head_branch` | string | false | | | -| `»» pr_number` | integer | false | | | -| `»» pull_request_draft` | boolean | false | | | -| `»» pull_request_state` | string | false | | | -| `»» pull_request_title` | string | false | | | -| `»» refreshed_at` | string(date-time) | false | | | -| `»» reviewer_count` | integer | false | | | -| `»» stale_at` | string(date-time) | false | | | -| `»» url` | string | false | | | -| `» files` | array | false | | | -| `»» created_at` | string(date-time) | false | | | -| `»» id` | string(uuid) | false | | | -| `»» mime_type` | string | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string(uuid) | false | | | -| `»» owner_id` | string(uuid) | false | | | -| `» has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | -| `» id` | string(uuid) | false | | | -| `» labels` | object | false | | | -| `»» [any property]` | string | false | | | -| `» last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | -| `»» detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | -| `»» kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | -| `»» message` | string | false | | Message is the normalized, user-facing error message. | -| `»» provider` | string | false | | Provider identifies the upstream model provider when known. | -| `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | -| `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | -| `» last_injected_context` | array | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | -| `»» args` | array | false | | | -| `»» args_delta` | string | false | | | -| `»» completed_at` | string(date-time) | false | | Completed at is the time a reasoning part finished streaming, so reasoning duration can be computed as completed_at minus created_at. For interrupted reasoning, this is the interruption time. Absent when reasoning timestamp data was not recorded (e.g. messages persisted before this feature was added). | -| `»» content` | string | false | | The code content from the diff that was commented on. | -| `»» context_file_agent_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | -| `»» context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | -| `»» context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | -| `»» context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | -| `»» context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | -| `»» context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | -| `»» created_at` | string(date-time) | false | | Created at is the timestamp this part carries. The semantics depend on the part type: for tool-call and tool-result parts it is the time the call was emitted or the result was produced (tool duration is the result's created_at minus the call's created_at); for reasoning parts it is the time reasoning started streaming. | -| `»» data` | array | false | | | -| `»» end_line` | integer | false | | | -| `»» file_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» file_name` | string | false | | | -| `»» is_error` | boolean | false | | | -| `»» is_media` | boolean | false | | | -| `»» mcp_server_config_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» media_type` | string | false | | | -| `»» name` | string | false | | | -| `»» parsed_commands` | array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Program names are normalized to their base name (e.g. /usr/bin/go becomes go). Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. | -| `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | -| `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | -| `»» result` | array | false | | | -| `»» result_delta` | string | false | | | -| `»» result_reset` | boolean | false | | | -| `»» signature` | string | false | | | -| `»» skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | -| `»» skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | -| `»» skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | -| `»» source_id` | string | false | | | -| `»» start_line` | integer | false | | | -| `»» text` | string | false | | | -| `»» title` | string | false | | | -| `»» tool_call_id` | string | false | | | -| `»» tool_name` | string | false | | | -| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | | -| `»» url` | string | false | | | -| `» last_model_config_id` | string(uuid) | false | | | -| `» last_turn_summary` | string | false | | | -| `» mcp_server_ids` | array | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» owner_id` | string(uuid) | false | | | -| `» owner_name` | string | false | | | -| `» owner_username` | string | false | | | -| `» parent_chat_id` | string(uuid) | false | | | -| `» pin_order` | integer | false | | | -| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | -| `» root_chat_id` | string(uuid) | false | | | -| `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | -| `» title` | string | false | | | -| `» updated_at` | string(date-time) | false | | | -| `» warnings` | array | false | | | -| `» workspace_id` | string(uuid) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------------------|------------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» agent_id` | string(uuid) | false | | | +| `» archived` | boolean | false | | | +| `» build_id` | string(uuid) | false | | | +| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | +| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | +| `» created_at` | string(date-time) | false | | | +| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | +| `»» additions` | integer | false | | | +| `»» approved` | boolean | false | | | +| `»» author_avatar_url` | string | false | | | +| `»» author_login` | string | false | | | +| `»» base_branch` | string | false | | | +| `»» changed_files` | integer | false | | | +| `»» changes_requested` | boolean | false | | | +| `»» chat_id` | string(uuid) | false | | | +| `»» commits` | integer | false | | | +| `»» deletions` | integer | false | | | +| `»» head_branch` | string | false | | | +| `»» pr_number` | integer | false | | | +| `»» pull_request_draft` | boolean | false | | | +| `»» pull_request_state` | string | false | | | +| `»» pull_request_title` | string | false | | | +| `»» refreshed_at` | string(date-time) | false | | | +| `»» reviewer_count` | integer | false | | | +| `»» stale_at` | string(date-time) | false | | | +| `»» url` | string | false | | | +| `» files` | array | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» id` | string(uuid) | false | | | +| `»» mime_type` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» owner_id` | string(uuid) | false | | | +| `» goal` | [codersdk.ChatGoal](schemas.md#codersdkchatgoal) | false | | | +| `»» cleared_at` | string(date-time) | false | | | +| `»» completed_at` | string(date-time) | false | | | +| `»» completed_by_agent` | boolean | false | | | +| `»» completed_by_user_id` | string(uuid) | false | | | +| `»» completion_summary` | string | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» created_by_user_id` | string(uuid) | false | | | +| `»» created_from_chat_id` | string(uuid) | false | | | +| `»» id` | string(uuid) | false | | | +| `»» objective` | string | false | | | +| `»» replaced_at` | string(date-time) | false | | | +| `»» root_chat_id` | string(uuid) | false | | | +| `»» status` | [codersdk.ChatGoalStatus](schemas.md#codersdkchatgoalstatus) | false | | | +| `»» updated_at` | string(date-time) | false | | | +| `» has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | +| `» id` | string(uuid) | false | | | +| `» labels` | object | false | | | +| `»» [any property]` | string | false | | | +| `» last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | +| `»» detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | +| `»» kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | +| `»» message` | string | false | | Message is the normalized, user-facing error message. | +| `»» provider` | string | false | | Provider identifies the upstream model provider when known. | +| `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | +| `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | +| `» last_injected_context` | array | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | +| `»» args` | array | false | | | +| `»» args_delta` | string | false | | | +| `»» completed_at` | string(date-time) | false | | Completed at is the time a reasoning part finished streaming, so reasoning duration can be computed as completed_at minus created_at. For interrupted reasoning, this is the interruption time. Absent when reasoning timestamp data was not recorded (e.g. messages persisted before this feature was added). | +| `»» content` | string | false | | The code content from the diff that was commented on. | +| `»» context_file_agent_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | +| `»» context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | +| `»» context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | +| `»» context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | +| `»» context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | +| `»» context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | +| `»» created_at` | string(date-time) | false | | Created at is the timestamp this part carries. The semantics depend on the part type: for tool-call and tool-result parts it is the time the call was emitted or the result was produced (tool duration is the result's created_at minus the call's created_at); for reasoning parts it is the time reasoning started streaming. | +| `»» data` | array | false | | | +| `»» end_line` | integer | false | | | +| `»» file_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» file_name` | string | false | | | +| `»» is_error` | boolean | false | | | +| `»» is_media` | boolean | false | | | +| `»» mcp_server_config_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» media_type` | string | false | | | +| `»» name` | string | false | | | +| `»» parsed_commands` | array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. | +| `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | +| `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | +| `»» result` | array | false | | | +| `»» result_delta` | string | false | | | +| `»» result_reset` | boolean | false | | | +| `»» signature` | string | false | | | +| `»» skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | +| `»» skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | +| `»» skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | +| `»» source_id` | string | false | | | +| `»» start_line` | integer | false | | | +| `»» text` | string | false | | | +| `»» title` | string | false | | | +| `»» tool_call_id` | string | false | | | +| `»» tool_name` | string | false | | | +| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | | +| `»» url` | string | false | | | +| `» last_model_config_id` | string(uuid) | false | | | +| `» last_turn_summary` | string | false | | | +| `» mcp_server_ids` | array | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» owner_id` | string(uuid) | false | | | +| `» owner_name` | string | false | | | +| `» owner_username` | string | false | | | +| `» parent_chat_id` | string(uuid) | false | | | +| `» pin_order` | integer | false | | | +| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | +| `» root_chat_id` | string(uuid) | false | | | +| `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | +| `» title` | string | false | | | +| `» updated_at` | string(date-time) | false | | | +| `» warnings` | array | false | | | +| `» workspace_id` | string(uuid) | false | | | #### Enumerated Values @@ -298,7 +329,7 @@ Status Code **200** | `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | | `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | | `plan_mode` | `plan` | -| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` | +| `status` | `active`, `cleared`, `complete`, `completed`, `error`, `paused`, `pending`, `replaced`, `requires_action`, `running`, `waiting` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -334,6 +365,12 @@ Experimental: this endpoint is subject to change. "type": "text" } ], + "goal_mutation": { + "action": "set", + "completion_summary": "string", + "goal_id": "3854d9ad-4bca-4a70-a63e-7c6bc2f12aee", + "objective": "string" + }, "labels": { "property1": "string", "property2": "string" @@ -412,6 +449,22 @@ Experimental: this endpoint is subject to change. "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -545,6 +598,22 @@ Experimental: this endpoint is subject to change. "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -829,6 +898,22 @@ Experimental: this endpoint is subject to change. "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -1016,6 +1101,22 @@ Experimental: this endpoint is subject to change. "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -1149,6 +1250,22 @@ Experimental: this endpoint is subject to change. "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -1417,6 +1534,22 @@ Experimental: this endpoint is subject to change. "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -1550,6 +1683,22 @@ Experimental: this endpoint is subject to change. "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -1888,6 +2037,12 @@ Experimental: this endpoint is subject to change. "type": "text" } ], + "goal_mutation": { + "action": "set", + "completion_summary": "string", + "goal_id": "3854d9ad-4bca-4a70-a63e-7c6bc2f12aee", + "objective": "string" + }, "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -2705,6 +2860,22 @@ Experimental: this endpoint is subject to change. "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -2838,6 +3009,22 @@ Experimental: this endpoint is subject to change. "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 55eac2c4f2..21ac1a4871 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2228,6 +2228,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -2361,6 +2377,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -2474,6 +2506,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `created_at` | string | false | | | | `diff_status` | [codersdk.ChatDiffStatus](#codersdkchatdiffstatus) | false | | | | `files` | array of [codersdk.ChatFileMetadata](#codersdkchatfilemetadata) | false | | | +| `goal` | [codersdk.ChatGoal](#codersdkchatgoal) | false | | | | `has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | | `id` | string | false | | | | `labels` | object | false | | | @@ -2731,6 +2764,100 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `organization_id` | string | false | | | | `owner_id` | string | false | | | +## codersdk.ChatGoal + +```json +{ + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------------------|----------------------------------------------------|----------|--------------|-------------| +| `cleared_at` | string | false | | | +| `completed_at` | string | false | | | +| `completed_by_agent` | boolean | false | | | +| `completed_by_user_id` | string | false | | | +| `completion_summary` | string | false | | | +| `created_at` | string | false | | | +| `created_by_user_id` | string | false | | | +| `created_from_chat_id` | string | false | | | +| `id` | string | false | | | +| `objective` | string | false | | | +| `replaced_at` | string | false | | | +| `root_chat_id` | string | false | | | +| `status` | [codersdk.ChatGoalStatus](#codersdkchatgoalstatus) | false | | | +| `updated_at` | string | false | | | + +## codersdk.ChatGoalMutation + +```json +{ + "action": "set", + "completion_summary": "string", + "goal_id": "3854d9ad-4bca-4a70-a63e-7c6bc2f12aee", + "objective": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------|--------------------------------------------------------------------|----------|--------------|-------------| +| `action` | [codersdk.ChatGoalMutationAction](#codersdkchatgoalmutationaction) | false | | | +| `completion_summary` | string | false | | | +| `goal_id` | string | false | | | +| `objective` | string | false | | | + +#### Enumerated Values + +| Property | Value(s) | +|----------|-----------------------------------------------| +| `action` | `clear`, `complete`, `pause`, `resume`, `set` | + +## codersdk.ChatGoalMutationAction + +```json +"set" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-----------------------------------------------| +| `clear`, `complete`, `pause`, `resume`, `set` | + +## codersdk.ChatGoalStatus + +```json +"active" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-------------------------------------------------------| +| `active`, `cleared`, `complete`, `paused`, `replaced` | + ## codersdk.ChatGroup ```json @@ -4039,6 +4166,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" } ], + "goal": { + "cleared_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "completed_by_agent": true, + "completed_by_user_id": "46d01d21-f637-42aa-839a-6d4daf42c3a4", + "completion_summary": "string", + "created_at": "2019-08-24T14:15:22Z", + "created_by_user_id": "209f54c4-4c33-43bc-9c6a-ef4c65ad7473", + "created_from_chat_id": "2ef814a8-f0ae-48bb-ba3f-d85d007ba956", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "objective": "string", + "replaced_at": "2019-08-24T14:15:22Z", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "status": "active", + "updated_at": "2019-08-24T14:15:22Z" + }, "has_unread": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "labels": { @@ -4508,6 +4651,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "type": "text" } ], + "goal_mutation": { + "action": "set", + "completion_summary": "string", + "goal_id": "3854d9ad-4bca-4a70-a63e-7c6bc2f12aee", + "objective": "string" + }, "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -4522,6 +4671,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in |-------------------|-----------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------| | `busy_behavior` | [codersdk.ChatBusyBehavior](#codersdkchatbusybehavior) | false | | | | `content` | array of [codersdk.ChatInputPart](#codersdkchatinputpart) | false | | | +| `goal_mutation` | [codersdk.ChatGoalMutation](#codersdkchatgoalmutation) | false | | | | `mcp_server_ids` | array of string | false | | | | `model_config_id` | string | false | | | | `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | Plan mode switches the chat's persistent plan mode. nil: no change, ptr to "plan": enable, ptr to "": clear. | @@ -4719,6 +4869,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "type": "text" } ], + "goal_mutation": { + "action": "set", + "completion_summary": "string", + "goal_id": "3854d9ad-4bca-4a70-a63e-7c6bc2f12aee", + "objective": "string" + }, "labels": { "property1": "string", "property2": "string" @@ -4749,6 +4905,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in |------------------------|-----------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------| | `client_type` | [codersdk.ChatClientType](#codersdkchatclienttype) | false | | | | `content` | array of [codersdk.ChatInputPart](#codersdkchatinputpart) | false | | | +| `goal_mutation` | [codersdk.ChatGoalMutation](#codersdkchatgoalmutation) | false | | | | `labels` | object | false | | | | » `[any property]` | string | false | | | | `mcp_server_ids` | array of string | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 807c63f400..8687e12c30 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1543,6 +1543,7 @@ export interface Chat { readonly last_error?: ChatError; readonly last_turn_summary: string | null; readonly diff_status?: ChatDiffStatus; + readonly goal?: ChatGoal; readonly created_at: string; readonly updated_at: string; readonly archived: boolean; @@ -2111,6 +2112,70 @@ export const ChatGitWatchWorkspaceNoAgentsMessage = */ export const ChatGitWatchWorkspaceNotFoundMessage = "Chat workspace not found."; +// From codersdk/chats.go +/** + * ChatGoal is a durable objective associated with a root chat. + */ +export interface ChatGoal { + readonly id: string; + readonly root_chat_id: string; + readonly created_from_chat_id?: string; + readonly objective: string; + readonly status: ChatGoalStatus; + readonly completion_summary?: string; + readonly created_by_user_id: string; + readonly completed_by_user_id?: string; + readonly completed_by_agent: boolean; + readonly created_at: string; + readonly updated_at: string; + readonly completed_at?: string; + readonly cleared_at?: string; + readonly replaced_at?: string; +} + +// From codersdk/chats.go +/** + * ChatGoalMutation requests a goal lifecycle change. + */ +export interface ChatGoalMutation { + readonly action: ChatGoalMutationAction; + readonly goal_id?: string; + readonly objective?: string; + readonly completion_summary?: string; +} + +// From codersdk/chats.go +export type ChatGoalMutationAction = + | "clear" + | "complete" + | "pause" + | "resume" + | "set"; + +export const ChatGoalMutationActions: ChatGoalMutationAction[] = [ + "clear", + "complete", + "pause", + "resume", + "set", +]; + +// From codersdk/chats.go +export type ChatGoalStatus = + | "active" + | "cleared" + | "complete" + | "paused" + | "replaced"; + +export const ChatGoalStatuses: ChatGoalStatus[] = [ + "active", + "cleared", + "complete", + "paused", + "replaced", +]; + // From codersdk/chats.go export interface ChatGroup extends Group { readonly role: ChatRole; @@ -3304,6 +3369,7 @@ export interface CreateChatMessageRequest { readonly content: readonly ChatInputPart[]; readonly model_config_id?: string; readonly mcp_server_ids?: string[]; + readonly goal_mutation?: ChatGoalMutation; readonly busy_behavior?: ChatBusyBehavior; /** * PlanMode switches the chat's persistent plan mode. @@ -3366,6 +3432,7 @@ export interface CreateChatRequest { readonly model_config_id?: string; readonly mcp_server_ids?: readonly string[]; readonly labels?: Record; + readonly goal_mutation?: ChatGoalMutation; /** * UnsafeDynamicTools declares client-executed tools that the * LLM can invoke. This API is highly experimental and highly