feat: add experimental agents support (#22290)

feat: add AI chat system with agent tools and chat UI

Introduce the chatd subsystem and Agents UI for AI-powered chat
within Coder workspaces.

- Add chatd package with chat loop, message compaction, prompt
  management, and LLM provider integration (OpenAI, Anthropic)
- Add agent tools: create workspace, list/read templates, read/write/
  edit files, execute commands
- Add chat API endpoints with streaming, message editing, and
  durable reconnection
- Add database schema and migrations for chats, chat messages, chat
  providers, and chat model configs
- Add RBAC policies and dbauthz enforcement for chat resources
- Add Agents UI pages with conversation timeline, queued messages
  list, diff viewer, and model configuration panel
- Add comprehensive test coverage including coderd integration tests,
  chatd unit tests, and Storybook stories
- Gate feature behind experiments flag

---------

Co-authored-by: Cian Johnston <cian@coder.com>
Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
Co-authored-by: Jeremy Ruppel <jeremy@coder.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle Carberry
2026-02-27 16:50:56 +00:00
committed by GitHub
co-authored by Cian Johnston Danielle Maywood Jeremy Ruppel Claude Sonnet 4.6
parent 67da4e8b56
commit edee917d88
201 changed files with 44828 additions and 1859 deletions
+50
View File
@@ -0,0 +1,50 @@
package httpmw
import (
"context"
"net/http"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
)
type chatParamContextKey struct{}
// ChatParam returns the chat from the ExtractChatParam handler.
func ChatParam(r *http.Request) database.Chat {
chat, ok := r.Context().Value(chatParamContextKey{}).(database.Chat)
if !ok {
panic("developer error: chat param middleware not provided")
}
return chat
}
// ExtractChatParam grabs a chat from the "chat" URL parameter.
func ExtractChatParam(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chatID, parsed := ParseUUIDParam(rw, r, "chat")
if !parsed {
return
}
chat, err := db.GetChatByID(ctx, chatID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat.",
Detail: err.Error(),
})
return
}
ctx = context.WithValue(ctx, chatParamContextKey{}, chat)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}
+159
View File
@@ -0,0 +1,159 @@
package httpmw_test
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/codersdk"
)
func TestChatParam(t *testing.T) {
t.Parallel()
setupAuthentication := func(db database.Store) (*http.Request, database.User) {
user := dbgen.User(t, db, database.User{})
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: user.ID,
})
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, token)
ctx := chi.NewRouteContext()
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx))
return r, user
}
insertChat := func(t *testing.T, db database.Store, ownerID uuid.UUID) database.Chat {
t.Helper()
_, err := db.InsertChatProvider(context.Background(), database.InsertChatProviderParams{
Provider: "openai",
DisplayName: "OpenAI",
APIKey: "test-api-key",
BaseUrl: "https://api.openai.com/v1",
ApiKeyKeyID: sql.NullString{},
CreatedBy: uuid.NullUUID{UUID: ownerID, Valid: true},
Enabled: true,
})
require.NoError(t, err)
modelConfig, err := db.InsertChatModelConfig(context.Background(), database.InsertChatModelConfigParams{
Provider: "openai",
Model: "gpt-4o-mini",
DisplayName: "Test model",
Enabled: true,
IsDefault: true,
ContextLimit: 128000,
CompressionThreshold: 70,
Options: []byte("{}"),
})
require.NoError(t, err)
chat, err := db.InsertChat(context.Background(), database.InsertChatParams{
OwnerID: ownerID,
WorkspaceID: uuid.NullUUID{},
WorkspaceAgentID: uuid.NullUUID{},
ParentChatID: uuid.NullUUID{},
RootChatID: uuid.NullUUID{},
LastModelConfigID: modelConfig.ID,
Title: "Test chat",
})
require.NoError(t, err)
return chat
}
t.Run("None", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
rtr := chi.NewRouter()
rtr.Use(httpmw.ExtractChatParam(db))
rtr.Get("/", nil)
r, _ := setupAuthentication(db)
rw := httptest.NewRecorder()
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
rtr := chi.NewRouter()
rtr.Use(httpmw.ExtractChatParam(db))
rtr.Get("/", nil)
r, _ := setupAuthentication(db)
chi.RouteContext(r.Context()).URLParams.Add("chat", uuid.NewString())
rw := httptest.NewRecorder()
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("BadUUID", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
rtr := chi.NewRouter()
rtr.Use(httpmw.ExtractChatParam(db))
rtr.Get("/", nil)
r, _ := setupAuthentication(db)
chi.RouteContext(r.Context()).URLParams.Add("chat", "not-a-uuid")
rw := httptest.NewRecorder()
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("Found", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),
httpmw.ExtractChatParam(db),
)
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
_ = httpmw.ChatParam(r)
rw.WriteHeader(http.StatusOK)
})
r, user := setupAuthentication(db)
chat := insertChat(t, db, user.ID)
chi.RouteContext(r.Context()).URLParams.Add("chat", chat.ID.String())
rw := httptest.NewRecorder()
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
})
}
+12 -1
View File
@@ -15,13 +15,24 @@ type requestIDContextKey struct{}
// RequestID returns the ID of the request.
func RequestID(r *http.Request) uuid.UUID {
rid, ok := r.Context().Value(requestIDContextKey{}).(uuid.UUID)
rid, ok := RequestIDOptional(r)
if !ok {
panic("developer error: request id middleware not provided")
}
return rid
}
// RequestIDOptional returns the request ID when present.
func RequestIDOptional(r *http.Request) (uuid.UUID, bool) {
rid, ok := r.Context().Value(requestIDContextKey{}).(uuid.UUID)
return rid, ok
}
// WithRequestID stores a request ID in the context.
func WithRequestID(ctx context.Context, rid uuid.UUID) context.Context {
return context.WithValue(ctx, requestIDContextKey{}, rid)
}
// AttachRequestID adds a request ID to each HTTP request.
func AttachRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+15
View File
@@ -1,11 +1,13 @@
package httpmw_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/httpmw"
@@ -31,3 +33,16 @@ func TestRequestID(t *testing.T) {
require.NotEmpty(t, res.Header.Get("X-Coder-Request-ID"))
require.NotEmpty(t, rw.Body.Bytes())
}
func TestRequestIDHelpers(t *testing.T) {
t.Parallel()
requestID := uuid.New()
ctx := httpmw.WithRequestID(context.Background(), requestID)
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
gotRequestID, ok := httpmw.RequestIDOptional(req)
require.True(t, ok)
require.Equal(t, requestID, gotRequestID)
require.Equal(t, requestID, httpmw.RequestID(req))
}