mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
fix: avoid 500 when generating chat title from rename dialog and remove unused endpoint (#28306)
This commit is contained in:
Generated
+12
-4
@@ -1226,7 +1226,7 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/{chat}/title/regenerate": {
|
||||
"/api/experimental/chats/{chat}/title/propose": {
|
||||
"post": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
"produces": [
|
||||
@@ -1235,8 +1235,8 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Chats"
|
||||
],
|
||||
"summary": "Regenerate chat title",
|
||||
"operationId": "regenerate-chat-title",
|
||||
"summary": "Propose chat title",
|
||||
"operationId": "propose-chat-title",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -1251,7 +1251,7 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Chat"
|
||||
"$ref": "#/definitions/codersdk.ProposeChatTitleResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -25194,6 +25194,14 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ProposeChatTitleResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ProvisionerConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+12
-4
@@ -1089,13 +1089,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/{chat}/title/regenerate": {
|
||||
"/api/experimental/chats/{chat}/title/propose": {
|
||||
"post": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Chats"],
|
||||
"summary": "Regenerate chat title",
|
||||
"operationId": "regenerate-chat-title",
|
||||
"summary": "Propose chat title",
|
||||
"operationId": "propose-chat-title",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -1110,7 +1110,7 @@
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Chat"
|
||||
"$ref": "#/definitions/codersdk.ProposeChatTitleResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -23119,6 +23119,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ProposeChatTitleResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ProvisionerConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1559,7 +1559,6 @@ func New(options *Options) *API {
|
||||
r.Post("/compact", api.compactChat)
|
||||
r.Post("/reconcile-invalid", api.reconcileInvalidChatState)
|
||||
r.Post("/tool-results", api.postChatToolResults)
|
||||
r.Post("/title/regenerate", api.regenerateChatTitle)
|
||||
r.Post("/title/propose", api.proposeChatTitle)
|
||||
r.Get("/diff", api.getChatDiffContents)
|
||||
r.Put("/context", api.refreshChatContext)
|
||||
|
||||
+44
-51
@@ -146,6 +146,43 @@ func maybeWriteChatUsageLimitError(ctx context.Context, rw http.ResponseWriter,
|
||||
return true
|
||||
}
|
||||
|
||||
// statusClientClosedRequest is nginx's non-standard 499 status code,
|
||||
// used here to distinguish a client-initiated cancel from a server-
|
||||
// side failure when the manual title generation context is canceled.
|
||||
const statusClientClosedRequest = 499
|
||||
|
||||
// maybeWriteManualTitleTimeoutErr translates context-cancel or
|
||||
// title-timeout errors from the manual title pipeline into friendly
|
||||
// 499/504 responses instead of a raw 500 that leaks the wrapped error
|
||||
// chain. The errors bubble up wrapped, so match with errors.Is. Returns
|
||||
// true when a response was written.
|
||||
//
|
||||
// The 499 branch additionally requires the request context itself to be
|
||||
// canceled. A provider error can wrap context.Canceled (for example an
|
||||
// upstream 401) while the caller context is still active; without the
|
||||
// ctx.Err() guard such a provider failure would be misreported as a
|
||||
// client-closed request instead of surfacing through the 500 path.
|
||||
//
|
||||
// The 504 branch keys off chatd.ErrManualTitleTimedOut, which chatd
|
||||
// attaches only when the title-generation deadline actually expired. A
|
||||
// provider failure whose chain merely contains an unrelated transport
|
||||
// deadline is not tagged and keeps its provider-failure surface.
|
||||
func maybeWriteManualTitleTimeoutErr(ctx context.Context, rw http.ResponseWriter, err error) bool {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled) && errors.Is(ctx.Err(), context.Canceled):
|
||||
httpapi.Write(ctx, rw, statusClientClosedRequest, codersdk.Response{
|
||||
Message: "Title generation was canceled.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, chatd.ErrManualTitleTimedOut):
|
||||
httpapi.Write(ctx, rw, http.StatusGatewayTimeout, codersdk.Response{
|
||||
Message: "Title generation timed out. Try again or rename manually.",
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// requireChatDaemon reports whether the chat daemon exists, writing a 503
|
||||
// Service Unavailable with a remediation message when it does not. The
|
||||
// daemon is nil when the in-memory AI Gateway is disabled by deployment
|
||||
@@ -3381,63 +3418,16 @@ func (api *API) reconcileInvalidChatState(rw http.ResponseWriter, r *http.Reques
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
// @Summary Regenerate chat title
|
||||
// @ID regenerate-chat-title
|
||||
// @Summary Propose chat title
|
||||
// @ID propose-chat-title
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Chats
|
||||
// @Produce json
|
||||
// @Param chat path string true "Chat ID" format(uuid)
|
||||
// @Success 200 {object} codersdk.Chat
|
||||
// @Router /api/experimental/chats/{chat}/title/regenerate [post]
|
||||
// @Success 200 {object} codersdk.ProposeChatTitleResponse
|
||||
// @Router /api/experimental/chats/{chat}/title/propose [post]
|
||||
// @Description Experimental: this endpoint is subject to change.
|
||||
//
|
||||
//nolint:revive // HTTP handler writes to ResponseWriter.
|
||||
func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
chat := httpmw.ChatParam(r)
|
||||
|
||||
if !api.requireChatDaemon(ctx, rw) {
|
||||
return
|
||||
}
|
||||
|
||||
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
// Only the chat owner may regenerate titles. See
|
||||
// postChatMessages for the security rationale.
|
||||
if apiKey.UserID != chat.OwnerID {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Only the chat owner may regenerate the title.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatedChat, err := api.chatDaemon.RegenerateChatTitle(ctx, chat)
|
||||
if err != nil {
|
||||
if errors.Is(err, chatd.ErrNoDefaultChatModelConfig) {
|
||||
writeNoLocalChatModelResponse(ctx, rw)
|
||||
return
|
||||
}
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if maybeWriteChatUsageLimitError(ctx, rw, err) {
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to regenerate chat title.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updatedChat, nil, nil))
|
||||
}
|
||||
|
||||
//nolint:revive // HTTP handler writes to ResponseWriter.
|
||||
func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
@@ -3475,6 +3465,9 @@ func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) {
|
||||
if maybeWriteChatUsageLimitError(ctx, rw, err) {
|
||||
return
|
||||
}
|
||||
if maybeWriteManualTitleTimeoutErr(ctx, rw, err) {
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to generate chat title.",
|
||||
Detail: err.Error(),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
@@ -21,6 +23,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -564,3 +567,97 @@ func TestIsZeroChatModelCallConfigCoversEveryField(t *testing.T) {
|
||||
"isZeroChatModelCallConfig ignores field %s", field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeWriteManualTitleTimeoutErr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// canceledCtx returns a context whose Err reports context.Canceled,
|
||||
// mirroring a request whose caller disconnected.
|
||||
canceledCtx := func() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
err error
|
||||
wantWrote bool
|
||||
wantStatus int
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
// A genuine title timeout is tagged with the chatd sentinel
|
||||
// and wrapped several layers deep, so the handler must match
|
||||
// with errors.Is.
|
||||
name: "TitleTimeoutSentinelMapsTo504",
|
||||
ctx: context.Background(),
|
||||
err: xerrors.Errorf(
|
||||
"generate manual title: %w",
|
||||
errors.Join(chatd.ErrManualTitleTimedOut, context.DeadlineExceeded),
|
||||
),
|
||||
wantWrote: true,
|
||||
wantStatus: http.StatusGatewayTimeout,
|
||||
wantMessage: "Title generation timed out. Try again or rename manually.",
|
||||
},
|
||||
{
|
||||
// A provider failure can wrap an unrelated transport deadline
|
||||
// while the title deadline never expired. Without the chatd
|
||||
// sentinel this must keep the 500 path instead of a
|
||||
// misleading 504.
|
||||
name: "BareDeadlineWithoutSentinelFallsThrough",
|
||||
ctx: context.Background(),
|
||||
err: xerrors.Errorf("provider call failed: %w", context.DeadlineExceeded),
|
||||
wantWrote: false,
|
||||
},
|
||||
{
|
||||
// The caller disconnected, so ctx.Err() confirms the cancel
|
||||
// and the handler reports a client-closed request.
|
||||
name: "CanceledWithCanceledCtxMapsTo499",
|
||||
ctx: canceledCtx(),
|
||||
err: xerrors.Errorf("generate manual title: %w", context.Canceled),
|
||||
wantWrote: true,
|
||||
wantStatus: statusClientClosedRequest,
|
||||
wantMessage: "Title generation was canceled.",
|
||||
},
|
||||
{
|
||||
// A provider error can wrap context.Canceled (e.g. an
|
||||
// upstream 401) while the request context is still active.
|
||||
// Without a live cancel this must fall through to the 500
|
||||
// path instead of a misleading 499.
|
||||
name: "CanceledWithLiveCtxFallsThrough",
|
||||
ctx: context.Background(),
|
||||
err: xerrors.Errorf("provider auth failed: %w", context.Canceled),
|
||||
wantWrote: false,
|
||||
},
|
||||
{
|
||||
// Unrelated errors must fall through so the handler keeps
|
||||
// its existing 500 surface for genuine failures.
|
||||
name: "UnrelatedErrorFallsThrough",
|
||||
ctx: context.Background(),
|
||||
err: xerrors.New("something else"),
|
||||
wantWrote: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
wrote := maybeWriteManualTitleTimeoutErr(tt.ctx, rw, tt.err)
|
||||
require.Equal(t, tt.wantWrote, wrote)
|
||||
if !tt.wantWrote {
|
||||
require.Equal(t, http.StatusOK, rw.Code, "must not write a response when err is unrelated")
|
||||
return
|
||||
}
|
||||
require.Equal(t, tt.wantStatus, rw.Code)
|
||||
|
||||
var resp codersdk.Response
|
||||
require.NoError(t, json.NewDecoder(rw.Body).Decode(&resp))
|
||||
require.Equal(t, tt.wantMessage, resp.Message)
|
||||
require.Empty(t, resp.Detail, "translated copy must not leak the raw error detail")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+27
-312
@@ -44,7 +44,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/externalauth"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
@@ -11275,289 +11274,6 @@ func TestCompactChat(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegenerateChatTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ChatNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
_ = coderdtest.CreateFirstUser(t, client.Client)
|
||||
|
||||
_, err := client.RegenerateChatTitle(ctx, uuid.New())
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("UpdateDenied", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
clientRaw, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Authorizer: &coderdtest.FakeAuthorizer{
|
||||
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
|
||||
if action == policy.ActionUpdate && object.Type == rbac.ResourceChat.Type {
|
||||
return xerrors.New("denied")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
DeploymentValues: coderdtest.DeploymentValues(t),
|
||||
})
|
||||
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
|
||||
db := api.Database
|
||||
client := codersdk.NewExperimentalClient(clientRaw)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModel(t, client)
|
||||
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "chat with update denied",
|
||||
})
|
||||
|
||||
_, err := client.RegenerateChatTitle(ctx, chat.ID)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("NotFoundForDifferentUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModel(t, client)
|
||||
|
||||
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "private chat",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
|
||||
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
|
||||
_, err = otherClient.RegenerateChatTitle(ctx, createdChat.ID)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("Unauthenticated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModel(t, client)
|
||||
|
||||
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "chat for unauthenticated regeneration",
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
unauthenticatedClient := codersdk.NewExperimentalClient(codersdk.New(client.URL))
|
||||
_, err = unauthenticatedClient.RegenerateChatTitle(ctx, chat.ID)
|
||||
requireSDKError(t, err, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("PasteOnlyChat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db, api := newChatClientWithoutAIBridge(t)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createTitleGenerationChatModel(t, client)
|
||||
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
|
||||
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "New Chat",
|
||||
Status: database.ChatStatusWaiting,
|
||||
})
|
||||
// The chat's only user message is a synthetic pasted-text
|
||||
// attachment with no text parts.
|
||||
seedPasteOnlyTitleSourceMessage(ctx, t, db, chat, modelConfig.ID, "pasted stack trace for title")
|
||||
|
||||
updated, err := client.RegenerateChatTitle(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Test Chat", updated.Title)
|
||||
})
|
||||
|
||||
t.Run("NoPubsubDelivery", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db, api := newChatClientWithoutAIBridge(t)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createTitleGenerationChatModel(t, client)
|
||||
|
||||
// Wire the daemon's reload subscription to a pubsub coderd never
|
||||
// publishes to: gateway routes can then only come from the
|
||||
// synchronous initial load. This guards the invariant the
|
||||
// create-config-before-daemon pattern above relies on; if the
|
||||
// initial load is removed or made asynchronous, this fails
|
||||
// deterministically instead of reintroducing the startup race.
|
||||
isolated := dbpubsub.NewInMemory()
|
||||
t.Cleanup(func() { _ = isolated.Close() })
|
||||
aibridgedtest.StartTestAIBridgeDaemonWithPubsub(t.Context(), t, api, nil, isolated)
|
||||
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "New Chat",
|
||||
})
|
||||
seedManualTitleSourceMessage(t, db, chat, modelConfig.ID)
|
||||
|
||||
updated, err := client.RegenerateChatTitle(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Test Chat", updated.Title)
|
||||
})
|
||||
|
||||
t.Run("DoesNotBumpHistoryVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db, api := newChatClientWithoutAIBridge(t)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createTitleGenerationChatModel(t, client)
|
||||
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
|
||||
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "history fence chat",
|
||||
})
|
||||
seedManualTitleSourceMessage(t, db, chat, modelConfig.ID)
|
||||
|
||||
// Leave history_version lagging snapshot_version, as when a
|
||||
// generation task is in flight. A chat_messages write here would
|
||||
// sync it and break that task's commit fence.
|
||||
_, err := db.LockChatAndBumpSnapshotVersion(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
before, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, before.SnapshotVersion, before.HistoryVersion,
|
||||
"setup must leave history_version lagging snapshot_version")
|
||||
|
||||
updated, err := client.RegenerateChatTitle(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Test Chat", updated.Title)
|
||||
|
||||
after, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, before.HistoryVersion, after.HistoryVersion,
|
||||
"manual title regeneration must not touch chat_messages")
|
||||
})
|
||||
|
||||
t.Run("NoDefaultModelConfig", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
chat := seedChatWithDeletedModelConfig(ctx, t, db, user)
|
||||
|
||||
_, err := client.RegenerateChatTitle(ctx, chat.ID)
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
|
||||
require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
|
||||
})
|
||||
|
||||
t.Run("RegenerationFailure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db, api := newChatClientWithoutAIBridge(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModelWithTitleFailure(t, client)
|
||||
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
|
||||
|
||||
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "test chat",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
|
||||
|
||||
_, err = db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
WorkerID: uuid.NullUUID{},
|
||||
StartedAt: sql.NullTime{},
|
||||
HeartbeatAt: sql.NullTime{},
|
||||
LastError: pqtype.NullRawMessage{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
before, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.RegenerateChatTitle(ctx, chat.ID)
|
||||
requireSDKError(t, err, http.StatusInternalServerError)
|
||||
|
||||
after, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, after.UpdatedAt.Equal(before.UpdatedAt))
|
||||
})
|
||||
|
||||
t.Run("UsageLimitExhausted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db, api := newChatClientWithAPIAndDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModelWithTitleQuotaExhausted(t, client)
|
||||
|
||||
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "test chat",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
|
||||
|
||||
_, err = db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
WorkerID: uuid.NullUUID{},
|
||||
StartedAt: sql.NullTime{},
|
||||
HeartbeatAt: sql.NullTime{},
|
||||
LastError: pqtype.NullRawMessage{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.RegenerateChatTitle(ctx, chat.ID)
|
||||
sdkErr := requireSDKError(t, err, http.StatusConflict)
|
||||
require.Equal(t,
|
||||
"The AI usage limit has been exceeded. Contact an administrator or check the applicable budget and quota settings.",
|
||||
sdkErr.Message)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProposeChatTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -11627,6 +11343,31 @@ func TestProposeChatTitle(t *testing.T) {
|
||||
requireSDKError(t, err, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("PasteOnlyChat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db, api := newChatClientWithoutAIBridge(t)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createTitleGenerationChatModel(t, client)
|
||||
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
|
||||
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "New Chat",
|
||||
Status: database.ChatStatusWaiting,
|
||||
})
|
||||
// The chat's only user message is a synthetic pasted-text
|
||||
// attachment with no text parts.
|
||||
seedPasteOnlyTitleSourceMessage(ctx, t, db, chat, modelConfig.ID, "pasted stack trace for title")
|
||||
|
||||
proposed, err := client.ProposeChatTitle(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Test Chat", proposed.Title)
|
||||
})
|
||||
|
||||
t.Run("DoesNotBumpHistoryVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -11644,7 +11385,8 @@ func TestProposeChatTitle(t *testing.T) {
|
||||
})
|
||||
seedManualTitleSourceMessage(t, db, chat, modelConfig.ID)
|
||||
|
||||
// See the matching TestRegenerateChatTitle subtest.
|
||||
// Bump the snapshot version up front so the assertion below
|
||||
// detects any further bump caused by the propose call.
|
||||
_, err := db.LockChatAndBumpSnapshotVersion(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -11779,13 +11521,6 @@ func TestManualTitleEndpointsPassOwnerSyntheticAPIKeyToAIGateway(t *testing.T) {
|
||||
name string
|
||||
call func(context.Context, *codersdk.ExperimentalClient, uuid.UUID) error
|
||||
}{
|
||||
{
|
||||
name: "RegenerateChatTitle",
|
||||
call: func(ctx context.Context, client *codersdk.ExperimentalClient, chatID uuid.UUID) error {
|
||||
_, err := client.RegenerateChatTitle(ctx, chatID)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ProposeChatTitle",
|
||||
call: func(ctx context.Context, client *codersdk.ExperimentalClient, chatID uuid.UUID) error {
|
||||
@@ -18728,15 +18463,6 @@ func TestChatReadOnlySharedWriteHandlers(t *testing.T) {
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("RegenerateChatTitle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, _, sharedClient, chat, _ := setup(t)
|
||||
_, err := sharedClient.RegenerateChatTitle(ctx, chat.ID)
|
||||
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("ProposeChatTitle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -18890,17 +18616,6 @@ func TestChatOwnerOnlyWriteHandlers(t *testing.T) {
|
||||
require.Contains(t, sdkErr.Message, "Only the chat owner")
|
||||
})
|
||||
|
||||
t.Run("RegenerateChatTitle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
_, adminClient, chat, _ := setupOrgAdminAndOwnerChat(t)
|
||||
|
||||
_, err := adminClient.RegenerateChatTitle(ctx, chat.ID)
|
||||
sdkErr := requireSDKError(t, err, http.StatusForbidden)
|
||||
require.Contains(t, sdkErr.Message, "Only the chat owner")
|
||||
})
|
||||
|
||||
t.Run("ProposeChatTitle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ const (
|
||||
homeInstructionLookupTimeout = 5 * time.Second
|
||||
workspaceDialValidationDelay = 5 * time.Second
|
||||
turnStatusLabelWriteTimeout = 5 * time.Second
|
||||
manualTitlePersistTimeout = 5 * time.Second
|
||||
// defaultDialTimeout matches the timeout used by ~8 other
|
||||
// server-side AgentConn callers.
|
||||
defaultDialTimeout = 30 * time.Second
|
||||
@@ -2524,23 +2523,6 @@ func (t *generatedChatTitle) Load() (string, bool) {
|
||||
return t.title, true
|
||||
}
|
||||
|
||||
// RegenerateChatTitle regenerates a chat title from the chat's visible
|
||||
// messages, persists it when it changes, and broadcasts the update.
|
||||
func (p *Server) RegenerateChatTitle(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
) (database.Chat, error) {
|
||||
// Reuse chatd's scoped auth context for chat model config reads while
|
||||
// keeping chat ownership authorization at the HTTP layer.
|
||||
//nolint:gocritic // Non-admin users need chatd-scoped config reads here.
|
||||
chatdCtx := dbauthz.AsChatd(ctx)
|
||||
return p.regenerateChatTitleWithStore(
|
||||
chatdCtx,
|
||||
p.db,
|
||||
chat,
|
||||
)
|
||||
}
|
||||
|
||||
// RenameChatTitle persists a user-supplied chat title.
|
||||
func (p *Server) RenameChatTitle(
|
||||
ctx context.Context,
|
||||
@@ -2657,40 +2639,6 @@ func (p *Server) generateManualTitleCandidate(
|
||||
return title, nil
|
||||
}
|
||||
|
||||
func (p *Server) regenerateChatTitleWithStore(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chat database.Chat,
|
||||
) (database.Chat, error) {
|
||||
title, err := p.generateManualTitleCandidate(ctx, store, chat)
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
if title == "" {
|
||||
return chat, nil
|
||||
}
|
||||
|
||||
// Generation already happened; don't let a client disconnect drop the
|
||||
// title write.
|
||||
persistCtx, persistCancel := context.WithTimeout(context.WithoutCancel(ctx), manualTitlePersistTimeout)
|
||||
defer persistCancel()
|
||||
|
||||
updatedChat, wroteTitle, err := persistManualTitle(persistCtx, store, chat, title)
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("update chat title: %w", err)
|
||||
}
|
||||
// Publish only when this regeneration wrote the title. When a
|
||||
// concurrent rename won the race, the rename path already published
|
||||
// the fresher title; re-publishing the re-read row here could
|
||||
// deliver a stale title_change after an even newer rename.
|
||||
if !wroteTitle {
|
||||
return updatedChat, nil
|
||||
}
|
||||
|
||||
p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindTitleChange, nil)
|
||||
return updatedChat, nil
|
||||
}
|
||||
|
||||
func (p *Server) prepareManualTitleDebugRun(
|
||||
ctx context.Context,
|
||||
debugSvc *chatdebug.Service,
|
||||
@@ -2929,46 +2877,6 @@ func mergeManualTitleMessages(
|
||||
return merged
|
||||
}
|
||||
|
||||
// persistManualTitle writes newTitle only if the chat title still
|
||||
// matches the caller's snapshot. The returned bool reports whether the
|
||||
// title was actually written; it is false when a concurrent writer
|
||||
// changed the title first or when newTitle matches the current title.
|
||||
// Token usage for manual title generation is not recorded here; AI
|
||||
// Gateway tracks it independently, and writing to chat_messages outside
|
||||
// the chatstate state machine would break in-flight task fences.
|
||||
func persistManualTitle(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chat database.Chat,
|
||||
newTitle string,
|
||||
) (database.Chat, bool, error) {
|
||||
updatedChat := chat
|
||||
wroteTitle := false
|
||||
err := store.InTx(func(tx database.Store) error {
|
||||
lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("lock chat for manual title persist: %w", err)
|
||||
}
|
||||
updatedChat = lockedChat
|
||||
wroteTitle = false
|
||||
if lockedChat.Title == chat.Title && newTitle != lockedChat.Title {
|
||||
updatedChat, err = tx.UpdateChatByID(ctx, database.UpdateChatByIDParams{
|
||||
ID: chat.ID,
|
||||
Title: newTitle,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update chat title: %w", err)
|
||||
}
|
||||
wroteTitle = true
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return database.Chat{}, false, err
|
||||
}
|
||||
return updatedChat, wroteTitle, nil
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
role database.ChatMessageRole
|
||||
content pqtype.NullRawMessage
|
||||
|
||||
@@ -4,9 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -28,7 +25,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
|
||||
@@ -989,324 +985,6 @@ func TestRenameChatTitle(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// requireOutgoingRequestModel asserts that the outgoing request body
|
||||
// requests wantModel. This is so that mock transports can still
|
||||
// verify the outgoing request asked for the expected model.
|
||||
func requireOutgoingRequestModel(t testing.TB, req *http.Request, wantModel string) {
|
||||
t.Helper()
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
require.NoError(t, err)
|
||||
req.Body = io.NopCloser(strings.NewReader(string(body)))
|
||||
|
||||
var decoded struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &decoded))
|
||||
require.Equal(t, wantModel, decoded.Model)
|
||||
}
|
||||
|
||||
func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
usageTx := dbmock.NewMockStore(ctrl)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
pubsub := dbpubsub.NewInMemory()
|
||||
clock := quartz.NewReal()
|
||||
|
||||
ownerID := uuid.New()
|
||||
chatID := uuid.New()
|
||||
modelConfigID := uuid.New()
|
||||
workerID := uuid.New()
|
||||
userPrompt := "review pull request 23633 and fix review threads"
|
||||
activeAPIKeyID := "key-" + uuid.NewString()
|
||||
wantTitle := "Review PR 23633"
|
||||
|
||||
organizationID := uuid.New()
|
||||
chat := database.Chat{
|
||||
ID: chatID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: organizationID,
|
||||
LastModelConfigID: modelConfigID,
|
||||
Status: database.ChatStatusRunning,
|
||||
WorkerID: uuid.NullUUID{UUID: workerID, Valid: true},
|
||||
Title: chatprompt.FallbackTitle(userPrompt),
|
||||
}
|
||||
providerID := uuid.New()
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: modelConfigID,
|
||||
Model: "gpt-4o-mini",
|
||||
ContextLimit: 8192,
|
||||
Enabled: true,
|
||||
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
|
||||
OrganizationID: organizationID,
|
||||
}
|
||||
updatedChat := chat
|
||||
updatedChat.Title = wantTitle
|
||||
|
||||
messageEvents := make(chan struct {
|
||||
payload codersdk.ChatWatchEvent
|
||||
err error
|
||||
}, 1)
|
||||
cancelSub, err := pubsub.SubscribeWithErr(
|
||||
coderdpubsub.ChatWatchEventChannel(ownerID),
|
||||
coderdpubsub.HandleChatWatchEvent(func(_ context.Context, payload codersdk.ChatWatchEvent, err error) {
|
||||
messageEvents <- struct {
|
||||
payload codersdk.ChatWatchEvent
|
||||
err error
|
||||
}{payload: payload, err: err}
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer cancelSub()
|
||||
|
||||
// Title generation routes through the transport factory, so the model
|
||||
// response is synthesized by the RoundTripper (see aibridgeTestFactory).
|
||||
factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requireOutgoingRequestModel(t, req, modelConfig.Model)
|
||||
text := strconv.Quote(`{"title":"` + wantTitle + `"}`)
|
||||
body := `{"id":"resp_test","object":"response","created_at":0,"status":"completed","model":"gpt-4o-mini","output":[{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"output_text","text":` + text + `}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
server := &Server{
|
||||
db: db,
|
||||
logger: logger,
|
||||
pubsub: pubsub,
|
||||
clock: quartz.NewReal(),
|
||||
configCache: newChatConfigCache(context.Background(), db, clock),
|
||||
aibridgeTransportFactory: aibridgeTestFactoryPointer(factory),
|
||||
}
|
||||
|
||||
db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{
|
||||
ID: providerID,
|
||||
Name: "primary-openai",
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
}, nil).AnyTimes()
|
||||
|
||||
db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{
|
||||
ID: providerID,
|
||||
Name: "primary-openai",
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
}}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
|
||||
db.EXPECT().GetChatGatewayAPIKey(gomock.Any(), database.GetChatGatewayAPIKeyParams{UserID: ownerID, TokenName: GatewayTokenName(ownerID)}).Return(database.APIKey{ID: activeAPIKeyID, UserID: ownerID, ExpiresAt: time.Now().Add(48 * time.Hour)}, nil)
|
||||
db.EXPECT().GetChatMessagesByChatIDAscPaginated(
|
||||
gomock.Any(),
|
||||
database.GetChatMessagesByChatIDAscPaginatedParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
LimitVal: manualTitleMessageWindowLimit,
|
||||
},
|
||||
).Return([]database.ChatMessage{
|
||||
mustChatMessage(
|
||||
t,
|
||||
database.ChatMessageRoleUser,
|
||||
database.ChatMessageVisibilityBoth,
|
||||
codersdk.ChatMessageText(userPrompt),
|
||||
),
|
||||
mustChatMessage(
|
||||
t,
|
||||
database.ChatMessageRoleAssistant,
|
||||
database.ChatMessageVisibilityBoth,
|
||||
codersdk.ChatMessageText("checking the diff now"),
|
||||
),
|
||||
}, nil)
|
||||
db.EXPECT().GetChatMessagesByChatIDDescPaginated(
|
||||
gomock.Any(),
|
||||
database.GetChatMessagesByChatIDDescPaginatedParams{
|
||||
ChatID: chatID,
|
||||
BeforeID: 0,
|
||||
LimitVal: manualTitleMessageWindowLimit,
|
||||
},
|
||||
).Return(nil, nil)
|
||||
db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows)
|
||||
db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), organizationID).Return(nil, nil)
|
||||
|
||||
db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn(
|
||||
func(fn func(database.Store) error, opts *database.TxOptions) error {
|
||||
require.Nil(t, opts)
|
||||
return fn(usageTx)
|
||||
},
|
||||
)
|
||||
|
||||
usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(chat, nil)
|
||||
usageTx.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{
|
||||
ID: chatID,
|
||||
Title: wantTitle,
|
||||
}).Return(updatedChat, nil)
|
||||
|
||||
gotChat, err := server.RegenerateChatTitle(ctx, chat)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updatedChat, gotChat)
|
||||
|
||||
select {
|
||||
case event := <-messageEvents:
|
||||
require.NoError(t, event.err)
|
||||
require.Equal(t, codersdk.ChatWatchEventKindTitleChange, event.payload.Kind)
|
||||
require.Equal(t, chatID, event.payload.Chat.ID)
|
||||
require.Equal(t, wantTitle, event.payload.Chat.Title)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for title change pubsub event")
|
||||
}
|
||||
}
|
||||
|
||||
// With no request-level locking, persistManualTitle's re-read under
|
||||
// GetChatByIDForUpdate is the only protection against clobbering a title
|
||||
// that changed while the model call ran. The strict mock has no
|
||||
// UpdateChatByID expectation, so any persist attempt fails the test.
|
||||
// A skipped persist must also not publish a title_change event; the
|
||||
// wroteTitle comment in regenerateChatTitleWithStore explains why.
|
||||
func TestRegenerateChatTitle_SkipsPersistWhenTitleChangedConcurrently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
usageTx := dbmock.NewMockStore(ctrl)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
pubsub := dbpubsub.NewInMemory()
|
||||
clock := quartz.NewReal()
|
||||
|
||||
ownerID := uuid.New()
|
||||
chatID := uuid.New()
|
||||
modelConfigID := uuid.New()
|
||||
providerID := uuid.New()
|
||||
userPrompt := "review pull request 23633 and fix review threads"
|
||||
activeAPIKeyID := "key-" + uuid.NewString()
|
||||
generatedTitle := "Review PR 23633"
|
||||
|
||||
organizationID := uuid.New()
|
||||
chat := database.Chat{
|
||||
ID: chatID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: organizationID,
|
||||
LastModelConfigID: modelConfigID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
Title: chatprompt.FallbackTitle(userPrompt),
|
||||
}
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: modelConfigID,
|
||||
Model: "gpt-4o-mini",
|
||||
ContextLimit: 8192,
|
||||
Enabled: true,
|
||||
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
|
||||
OrganizationID: organizationID,
|
||||
}
|
||||
// Another writer (rename or a second regenerate) landed while the
|
||||
// model call was in flight.
|
||||
landedChat := chat
|
||||
landedChat.Title = "landed-concurrently"
|
||||
|
||||
titleEvents := make(chan codersdk.ChatWatchEvent, 1)
|
||||
cancelSub, err := pubsub.SubscribeWithErr(
|
||||
coderdpubsub.ChatWatchEventChannel(ownerID),
|
||||
coderdpubsub.HandleChatWatchEvent(func(_ context.Context, payload codersdk.ChatWatchEvent, err error) {
|
||||
require.NoError(t, err)
|
||||
titleEvents <- payload
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer cancelSub()
|
||||
|
||||
factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requireOutgoingRequestModel(t, req, modelConfig.Model)
|
||||
text := strconv.Quote(`{"title":"` + generatedTitle + `"}`)
|
||||
body := `{"id":"resp_test","object":"response","created_at":0,"status":"completed","model":"gpt-4o-mini","output":[{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"output_text","text":` + text + `}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
server := &Server{
|
||||
db: db,
|
||||
logger: logger,
|
||||
pubsub: pubsub,
|
||||
clock: quartz.NewReal(),
|
||||
configCache: newChatConfigCache(context.Background(), db, clock),
|
||||
aibridgeTransportFactory: aibridgeTestFactoryPointer(factory),
|
||||
}
|
||||
|
||||
db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{
|
||||
ID: providerID,
|
||||
Name: "primary-openai",
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{
|
||||
ID: providerID,
|
||||
Name: "primary-openai",
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
}}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
|
||||
db.EXPECT().GetChatGatewayAPIKey(gomock.Any(), database.GetChatGatewayAPIKeyParams{UserID: ownerID, TokenName: GatewayTokenName(ownerID)}).Return(database.APIKey{ID: activeAPIKeyID, UserID: ownerID, ExpiresAt: time.Now().Add(48 * time.Hour)}, nil)
|
||||
db.EXPECT().GetChatMessagesByChatIDAscPaginated(
|
||||
gomock.Any(),
|
||||
database.GetChatMessagesByChatIDAscPaginatedParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
LimitVal: manualTitleMessageWindowLimit,
|
||||
},
|
||||
).Return([]database.ChatMessage{
|
||||
mustChatMessage(
|
||||
t,
|
||||
database.ChatMessageRoleUser,
|
||||
database.ChatMessageVisibilityBoth,
|
||||
codersdk.ChatMessageText(userPrompt),
|
||||
),
|
||||
}, nil)
|
||||
db.EXPECT().GetChatMessagesByChatIDDescPaginated(
|
||||
gomock.Any(),
|
||||
database.GetChatMessagesByChatIDDescPaginatedParams{
|
||||
ChatID: chatID,
|
||||
BeforeID: 0,
|
||||
LimitVal: manualTitleMessageWindowLimit,
|
||||
},
|
||||
).Return(nil, nil)
|
||||
db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows)
|
||||
db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), organizationID).Return(nil, nil)
|
||||
|
||||
db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn(
|
||||
func(fn func(database.Store) error, _ *database.TxOptions) error {
|
||||
return fn(usageTx)
|
||||
},
|
||||
)
|
||||
|
||||
usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(landedChat, nil)
|
||||
|
||||
gotChat, err := server.RegenerateChatTitle(ctx, chat)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, landedChat.Title, gotChat.Title,
|
||||
"the concurrently landed title must survive; the generated title must not be persisted")
|
||||
|
||||
// The in-memory pubsub delivers synchronously, so any event published
|
||||
// during RegenerateChatTitle is already buffered by now.
|
||||
select {
|
||||
case event := <-titleEvents:
|
||||
t.Fatalf("unexpected %s event published for skipped regeneration (title %q)",
|
||||
event.Kind, event.Chat.Title)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserProviderAPIKeys_StripsDisabledFallbackKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -833,6 +833,26 @@ func renderManualTitlePrompt(
|
||||
return prompt.String()
|
||||
}
|
||||
|
||||
// manualTitleGenerationTimeout bounds the model call for manual title
|
||||
// generation so a slow or hung provider cannot hold the rename dialog's
|
||||
// Generate request indefinitely.
|
||||
const manualTitleGenerationTimeout = 30 * time.Second
|
||||
|
||||
// ErrManualTitleTimedOut marks a manual title failure caused by an expired
|
||||
// title-generation deadline, as opposed to an unrelated error that merely
|
||||
// wraps context.DeadlineExceeded. The handler maps it to a friendly 504.
|
||||
var ErrManualTitleTimedOut = xerrors.New("manual title generation timed out")
|
||||
|
||||
// markManualTitleTimeout tags err with ErrManualTitleTimedOut when it stems
|
||||
// from a deadline so the handler can distinguish a genuine title timeout from
|
||||
// other failures.
|
||||
func markManualTitleTimeout(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.Join(ErrManualTitleTimedOut, err)
|
||||
}
|
||||
|
||||
func generateManualTitle(
|
||||
ctx context.Context,
|
||||
messages []database.ChatMessage,
|
||||
@@ -858,7 +878,7 @@ func generateManualTitle(
|
||||
latestUserMsg,
|
||||
)
|
||||
|
||||
titleCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
titleCtx, cancel := context.WithTimeout(ctx, manualTitleGenerationTimeout)
|
||||
defer cancel()
|
||||
|
||||
userInput := strings.TrimSpace(latestUserMsg)
|
||||
@@ -874,6 +894,14 @@ func generateManualTitle(
|
||||
userInput,
|
||||
)
|
||||
if err != nil {
|
||||
// Tag genuine attempt-deadline expiry so the handler can map it to a
|
||||
// friendly 504. A provider failure can wrap an unrelated transport
|
||||
// deadline while titleCtx is still live; leave that untagged so it
|
||||
// keeps its provider-failure surface.
|
||||
if errors.Is(err, context.DeadlineExceeded) &&
|
||||
errors.Is(titleCtx.Err(), context.DeadlineExceeded) {
|
||||
return "", markManualTitleTimeout(err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
@@ -3178,21 +3178,6 @@ func (c *ExperimentalClient) ReconcileInvalidChatState(ctx context.Context, chat
|
||||
return chat, ReadBodyAsJSON(res, &chat)
|
||||
}
|
||||
|
||||
// RegenerateChatTitle requests the server to regenerate the chat's
|
||||
// title using richer conversation context.
|
||||
func (c *ExperimentalClient) RegenerateChatTitle(ctx context.Context, chatID uuid.UUID) (Chat, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/title/regenerate", chatID), nil)
|
||||
if err != nil {
|
||||
return Chat{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return Chat{}, ReadBodyAsError(res)
|
||||
}
|
||||
var chat Chat
|
||||
return chat, ReadBodyAsJSON(res, &chat)
|
||||
}
|
||||
|
||||
// ProposeChatTitleResponse is returned by the propose-title endpoint.
|
||||
type ProposeChatTitleResponse struct {
|
||||
Title string `json:"title"`
|
||||
|
||||
@@ -668,7 +668,7 @@ API:
|
||||
| **Labels** | Key-value metadata on chats for filtering (`label` query parameter) |
|
||||
| **Sub-agents** | Agent can spawn child agents for parallel work |
|
||||
| **Diff/PR tracking** | `GET /chats/{chat}/diff` returns change tracking and PR metadata |
|
||||
| **Title regeneration** | `POST /chats/{chat}/title/regenerate` |
|
||||
| **Title generation** | `POST /chats/{chat}/title/propose` returns a suggested title |
|
||||
| **Pinning** | Pin and reorder chats via the `pin_order` field |
|
||||
| **Automatic workspace provisioning** | No workspace needed for Q&A. Provisioned only when the agent needs to act |
|
||||
|
||||
|
||||
Generated
+7
-205
@@ -2982,18 +2982,18 @@ Experimental: this endpoint is subject to change.
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Regenerate chat title
|
||||
## Propose chat title
|
||||
|
||||
### Code samples
|
||||
|
||||
```sh
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/experimental/chats/{chat}/title/regenerate \
|
||||
curl -X POST http://coder-server:8080/api/experimental/chats/{chat}/title/propose \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /api/experimental/chats/{chat}/title/regenerate`
|
||||
`POST /api/experimental/chats/{chat}/title/propose`
|
||||
|
||||
Experimental: this endpoint is subject to change.
|
||||
|
||||
@@ -3009,212 +3009,14 @@ Experimental: this endpoint is subject to change.
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
|
||||
"archived": true,
|
||||
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
|
||||
"children": [
|
||||
{
|
||||
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
|
||||
"archived": true,
|
||||
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
|
||||
"children": [],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string",
|
||||
"resources": [
|
||||
{
|
||||
"error": "string",
|
||||
"kind": "instruction_file",
|
||||
"size_bytes": 0,
|
||||
"skill_description": "string",
|
||||
"skill_name": "string",
|
||||
"source": "string",
|
||||
"status": "ok",
|
||||
"tools": [
|
||||
{
|
||||
"description": "string",
|
||||
"name": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
"approved": true,
|
||||
"author_avatar_url": "string",
|
||||
"author_login": "string",
|
||||
"base_branch": "string",
|
||||
"changed_files": 0,
|
||||
"changes_requested": true,
|
||||
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
|
||||
"commits": 0,
|
||||
"deletions": 0,
|
||||
"head_branch": "string",
|
||||
"pr_number": 0,
|
||||
"pull_request_draft": true,
|
||||
"pull_request_state": "string",
|
||||
"pull_request_title": "string",
|
||||
"refreshed_at": "2019-08-24T14:15:22Z",
|
||||
"reviewer_count": 0,
|
||||
"stale_at": "2019-08-24T14:15:22Z",
|
||||
"url": "string"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"mime_type": "string",
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
|
||||
"size_bytes": 0
|
||||
}
|
||||
],
|
||||
"has_unread": true,
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"labels": {
|
||||
"property1": "string",
|
||||
"property2": "string"
|
||||
},
|
||||
"last_error": {
|
||||
"detail": "string",
|
||||
"kind": "generic",
|
||||
"message": "string",
|
||||
"provider": "string",
|
||||
"retryable": true,
|
||||
"status_code": 0
|
||||
},
|
||||
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
|
||||
"last_reasoning_effort": "string",
|
||||
"last_turn_summary": "string",
|
||||
"mcp_server_ids": [
|
||||
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
|
||||
],
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
|
||||
"owner_name": "string",
|
||||
"owner_username": "string",
|
||||
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
|
||||
"pin_order": 0,
|
||||
"plan_mode": "plan",
|
||||
"queued_for_capacity": true,
|
||||
"root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7",
|
||||
"shared": true,
|
||||
"status": "waiting",
|
||||
"summary": "string",
|
||||
"title": "string",
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"warnings": [
|
||||
"string"
|
||||
],
|
||||
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9"
|
||||
}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string",
|
||||
"resources": [
|
||||
{
|
||||
"error": "string",
|
||||
"kind": "instruction_file",
|
||||
"size_bytes": 0,
|
||||
"skill_description": "string",
|
||||
"skill_name": "string",
|
||||
"source": "string",
|
||||
"status": "ok",
|
||||
"tools": [
|
||||
{
|
||||
"description": "string",
|
||||
"name": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
"approved": true,
|
||||
"author_avatar_url": "string",
|
||||
"author_login": "string",
|
||||
"base_branch": "string",
|
||||
"changed_files": 0,
|
||||
"changes_requested": true,
|
||||
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
|
||||
"commits": 0,
|
||||
"deletions": 0,
|
||||
"head_branch": "string",
|
||||
"pr_number": 0,
|
||||
"pull_request_draft": true,
|
||||
"pull_request_state": "string",
|
||||
"pull_request_title": "string",
|
||||
"refreshed_at": "2019-08-24T14:15:22Z",
|
||||
"reviewer_count": 0,
|
||||
"stale_at": "2019-08-24T14:15:22Z",
|
||||
"url": "string"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"mime_type": "string",
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
|
||||
"size_bytes": 0
|
||||
}
|
||||
],
|
||||
"has_unread": true,
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"labels": {
|
||||
"property1": "string",
|
||||
"property2": "string"
|
||||
},
|
||||
"last_error": {
|
||||
"detail": "string",
|
||||
"kind": "generic",
|
||||
"message": "string",
|
||||
"provider": "string",
|
||||
"retryable": true,
|
||||
"status_code": 0
|
||||
},
|
||||
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
|
||||
"last_reasoning_effort": "string",
|
||||
"last_turn_summary": "string",
|
||||
"mcp_server_ids": [
|
||||
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
|
||||
],
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
|
||||
"owner_name": "string",
|
||||
"owner_username": "string",
|
||||
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
|
||||
"pin_order": 0,
|
||||
"plan_mode": "plan",
|
||||
"queued_for_capacity": true,
|
||||
"root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7",
|
||||
"shared": true,
|
||||
"status": "waiting",
|
||||
"summary": "string",
|
||||
"title": "string",
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"warnings": [
|
||||
"string"
|
||||
],
|
||||
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9"
|
||||
"title": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) |
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ProposeChatTitleResponse](schemas.md#codersdkproposechattitleresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
Generated
+14
@@ -12356,6 +12356,20 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|
||||
| `collect_db_metrics` | boolean | false | | |
|
||||
| `enable` | boolean | false | | |
|
||||
|
||||
## codersdk.ProposeChatTitleResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------|--------|----------|--------------|-------------|
|
||||
| `title` | string | false | | |
|
||||
|
||||
## codersdk.ProvisionerConfig
|
||||
|
||||
```json
|
||||
|
||||
Reference in New Issue
Block a user