chore: remove agents experiment flag and mark feature as beta (#24432)

Remove the `ExperimentAgents` feature flag so the Agents feature is
always available without requiring `--experiments=agents`. The feature
is now in beta.

Existing deployments that still pass `--experiments=agents` will get a
harmless "ignoring unknown experiment" warning on startup.

### Changes

**Backend:**
- Remove `RequireExperimentWithDevBypass` middleware from chat and MCP
server routes
- Always include `AgentsAccessRole` in assignable site roles (later
refactored to org-scoped on main; rebase keeps that)
- Always set `AgentsTabVisible = true`, then drop the entire dead
`AgentsTabVisible` metadata pipeline (Go htmlState field,
populateHTMLState goroutine, HTML meta tag, useEmbeddedMetadata
registration, mock); no production consumer reads it. `AgentsNavItem`
already gates on `permissions.createChat`.
- Make `blob:` CSP `img-src` addition unconditional
- Remove `ExperimentAgents` constant, `DisplayName` case, and
`ExperimentsKnown` entry

**CLI:**
- Graduate the agents TUI from `coder exp agents` to `coder agents`
(moved from `AGPLExperimental()` to `CoreSubcommands()`)
- Drop the `agent` alias so it does not collide with the hidden
workspace-agent command
- Rename implementation files `cli/exp_agents_*.go` -> `cli/agents_*.go`
and internal identifiers (`expChatsTUIModel` -> `chatsTUIModel`,
`newExpChatsTUIModel` -> `newChatsTUIModel`, `setupExpAgentsBackend` ->
`setupAgentsBackend`, `startExpAgentsSession` -> `startAgentsSession`,
`expAgentsPtr` -> `agentsPtr`, `expAgentsSession` -> `agentsSession`,
`TestExpAgents*` -> `TestAgents*`). `expClient` (the
`*codersdk.ExperimentalClient` local) is kept; `coderd/exp_chats*.go`
and other still-experimental `cli/exp_*.go` commands are intentionally
untouched.

**Frontend:**
- Remove experiment check from `AgentsNavItem` - render when
`canCreateChat` is true
- Remove `agentsEnabled` experiment check from `WorkspacesPage`, then
gate `chatsByWorkspace` on `permissions.createChat` so users without
chat access don't trigger the per-page DB query (Copilot review
feedback)
- Add `FeatureStageBadge` (beta) next to the Coder logo in the Agents
sidebar (desktop + mobile)

**Docs:**
- Remove experiment flag setup instructions from `early-access.md` and
`getting-started.md` (and rename `early-access.md`'s "Enable Coder
Agents" heading to "Set up Coder Agents", since there is no enablement
step left)
- Update `chats-api.md` and `getting-started.md`'s Chats API note to say
"beta" instead of "experimental"
- `docs/manifest.json`: drop "experimental" from the Chats API sidebar
description
- `make gen` regenerated `docs/reference/cli/agents.md` and the CLI
index
- `scripts/check_emdash.sh`: exclude `cli/testdata/*.golden` and
`enterprise/cli/testdata/*.golden` from the new repo-wide emdash lint,
since serpent emits emdash borders in every generated `--help` golden
file

**Tests:**
- Remove `ExperimentAgents` setup from all test files (14 occurrences
across 7 files)
- Update stale "with the agents experiment" comments in
`coderd/x/chatd/integration_test.go` and `coderd/mcp_test.go`


<img width="1185" height="900" alt="image"
src="https://github.com/user-attachments/assets/b420bc8f-41d6-42c6-abd8-ad572533d651"
/>


> 🤖 Generated by Coder Agents
This commit is contained in:
Dean Sheather
2026-05-01 01:49:00 +10:00
committed by GitHub
parent b975262a97
commit e57525002c
48 changed files with 214 additions and 265 deletions
+5 -7
View File
@@ -2,7 +2,6 @@ package cli
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
@@ -82,9 +81,8 @@ func (r *RootCmd) agentsCommand() *serpent.Command {
)
return &serpent.Command{
Use: "agents [chat-id]",
Short: "Interactive terminal UI for AI agents.",
Aliases: []string{"agent"},
Use: "agents [chat-id]",
Short: "Interactive terminal UI for AI agents.",
Options: serpent.OptionSet{
{
Name: "workspace",
@@ -152,7 +150,7 @@ func (r *RootCmd) agentsCommand() *serpent.Command {
)
renderer.SetHasDarkBackground(true)
model := newExpChatsTUIModel(inv.Context(), expClient, initialChatID, workspaceID, modelID, defaultOrgID)
model := newChatsTUIModel(inv.Context(), expClient, initialChatID, workspaceID, modelID, defaultOrgID)
model.setRenderer(renderer)
program := tea.NewProgram(
model,
@@ -171,8 +169,8 @@ func (r *RootCmd) agentsCommand() *serpent.Command {
return err
}
if _, ok := runModel.(expChatsTUIModel); !ok {
return xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", runModel, runModel))
if _, ok := runModel.(chatsTUIModel); !ok {
return xerrors.Errorf("unknown model found %T (%+v)", runModel, runModel)
}
return nil
@@ -16,15 +16,14 @@ import (
"github.com/coder/coder/v2/testutil"
)
func expAgentsPtr[T any](v T) *T {
func agentsPtr[T any](v T) *T {
return &v
}
func setupExpAgentsBackend(t *testing.T) (*codersdk.Client, *codersdk.ExperimentalClient, uuid.UUID) {
func setupAgentsBackend(t *testing.T) (*codersdk.Client, *codersdk.ExperimentalClient, uuid.UUID) {
t.Helper()
values := coderdtest.DeploymentValues(t)
values.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: values,
@@ -43,8 +42,8 @@ func setupExpAgentsBackend(t *testing.T) (*codersdk.Client, *codersdk.Experiment
_, err = expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
Provider: "openai",
Model: "gpt-4o-mini",
ContextLimit: expAgentsPtr(int64(4096)),
IsDefault: expAgentsPtr(true),
ContextLimit: agentsPtr(int64(4096)),
IsDefault: agentsPtr(true),
})
require.NoError(t, err)
@@ -68,59 +67,59 @@ func seedChat(t *testing.T, ctx context.Context, expClient *codersdk.Experimenta
return chat
}
type expAgentsSession struct {
type agentsSession struct {
t *testing.T
pty *ptytest.PTY
errCh <-chan error
}
func (s *expAgentsSession) expect(ctx context.Context, text string) {
func (s *agentsSession) expect(ctx context.Context, text string) {
s.t.Helper()
s.pty.ExpectMatchContext(ctx, text)
}
func (s *expAgentsSession) wait(ctx context.Context) error {
func (s *agentsSession) wait(ctx context.Context) error {
s.t.Helper()
return testutil.RequireReceive(ctx, s.t, s.errCh)
}
//nolint:unused // Kept as a small PTY helper for future multi-character input.
func (s *expAgentsSession) write(text string) {
func (s *agentsSession) write(text string) {
s.t.Helper()
s.pty.WriteLine(text)
}
func (s *expAgentsSession) writeRune(r rune) {
func (s *agentsSession) writeRune(r rune) {
s.t.Helper()
_, err := s.pty.Input().Write([]byte(string(r)))
require.NoError(s.t, err)
}
func (s *expAgentsSession) enter() {
func (s *agentsSession) enter() {
s.t.Helper()
_, err := s.pty.Input().Write([]byte("\r"))
require.NoError(s.t, err)
}
func (s *expAgentsSession) esc() {
func (s *agentsSession) esc() {
s.t.Helper()
_, err := s.pty.Input().Write([]byte("\x1b"))
require.NoError(s.t, err)
}
func (s *expAgentsSession) ctrlC() {
func (s *agentsSession) ctrlC() {
s.t.Helper()
_, err := s.pty.Input().Write([]byte{3})
require.NoError(s.t, err)
}
func (s *expAgentsSession) quit() {
func (s *agentsSession) quit() {
s.t.Helper()
s.writeRune('q')
}
//nolint:revive // Test helper signature keeps t first for consistency with other helpers.
func startExpAgentsSession(t *testing.T, ctx context.Context, client *codersdk.Client, args ...string) *expAgentsSession {
func startAgentsSession(t *testing.T, ctx context.Context, client *codersdk.Client, args ...string) *agentsSession {
t.Helper()
// Reading to / writing from the PTY is flaky on non-linux systems.
@@ -128,7 +127,7 @@ func startExpAgentsSession(t *testing.T, ctx context.Context, client *codersdk.C
t.Skip("skipping on non-linux")
}
fullArgs := append([]string{"exp", "agents"}, args...)
fullArgs := append([]string{"agents"}, args...)
inv, root := clitest.New(t, fullArgs...)
clitest.SetupConfig(t, client, root)
@@ -148,5 +147,5 @@ func startExpAgentsSession(t *testing.T, ctx context.Context, client *codersdk.C
errCh <- inv.WithContext(ctx).Run()
})
return &expAgentsSession{t: t, pty: pty, errCh: errCh}
return &agentsSession{t: t, pty: pty, errCh: errCh}
}
@@ -8,15 +8,15 @@ import (
"github.com/coder/coder/v2/testutil"
)
func TestExpAgentsE2E(t *testing.T) {
func TestAgentsE2E(t *testing.T) {
t.Parallel()
t.Run("EmptyStateBoot", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _, _ := setupExpAgentsBackend(t)
session := startExpAgentsSession(t, ctx, client)
client, _, _ := setupAgentsBackend(t)
session := startAgentsSession(t, ctx, client)
session.expect(ctx, "No chats yet. Press n to start a new chat.")
session.quit()
@@ -27,13 +27,13 @@ func TestExpAgentsE2E(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, expClient, orgID := setupExpAgentsBackend(t)
client, expClient, orgID := setupAgentsBackend(t)
_ = seedChat(t, ctx, expClient, orgID, "alpha nav seed")
_ = seedChat(t, ctx, expClient, orgID, "bravo nav seed")
_ = seedChat(t, ctx, expClient, orgID, "charlie nav seed")
session := startExpAgentsSession(t, ctx, client)
session := startAgentsSession(t, ctx, client)
session.expect(ctx, "charlie nav seed")
session.expect(ctx, "enter: open")
@@ -49,12 +49,12 @@ func TestExpAgentsE2E(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, expClient, orgID := setupExpAgentsBackend(t)
client, expClient, orgID := setupAgentsBackend(t)
_ = seedChat(t, ctx, expClient, orgID, "alpha filter seed")
_ = seedChat(t, ctx, expClient, orgID, "zulu filter seed")
session := startExpAgentsSession(t, ctx, client)
session := startAgentsSession(t, ctx, client)
session.expect(ctx, "alpha filter seed")
session.expect(ctx, "enter: open")
@@ -72,10 +72,10 @@ func TestExpAgentsE2E(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, expClient, orgID := setupExpAgentsBackend(t)
client, expClient, orgID := setupAgentsBackend(t)
chat := seedChat(t, ctx, expClient, orgID, "direct open seed")
session := startExpAgentsSession(t, ctx, client, chat.ID.String())
session := startAgentsSession(t, ctx, client, chat.ID.String())
// The initial render contains both the chat title/content
// and the status bar in a single frame. Their relative
+24 -24
View File
@@ -28,8 +28,8 @@ const (
)
type (
terminateTUIMsg struct{}
expChatsTUIModel struct {
terminateTUIMsg struct{}
chatsTUIModel struct {
ctx context.Context
client *codersdk.ExperimentalClient
styles tuiStyles
@@ -49,14 +49,14 @@ type (
}
)
func newExpChatsTUIModel(
func newChatsTUIModel(
ctx context.Context,
client *codersdk.ExperimentalClient,
initialChatID *uuid.UUID,
workspaceID *uuid.UUID,
modelOverride *string,
organizationID uuid.UUID,
) expChatsTUIModel {
) chatsTUIModel {
styles := newTUIStyles()
currentView := viewList
if initialChatID != nil {
@@ -72,7 +72,7 @@ func newExpChatsTUIModel(
chat.historyResolved = false
chatGeneration = 1
}
return expChatsTUIModel{
return chatsTUIModel{
ctx: ctx,
client: client,
styles: styles,
@@ -92,7 +92,7 @@ func newExpChatsTUIModel(
// window dimensions from the previous session, and advances
// the monotonic generation counter so in-flight async messages
// from the old session are ignored.
func (m *expChatsTUIModel) resetChatSession() {
func (m *chatsTUIModel) resetChatSession() {
old := m.chat
m.chat = newChatViewModel(m.ctx, m.client, m.workspaceID, m.modelOverride, m.organizationID, m.styles)
m.chat.width = old.width
@@ -104,7 +104,7 @@ func (m *expChatsTUIModel) resetChatSession() {
m.chat.chatGeneration = m.chatGeneration
}
func (m *expChatsTUIModel) setRenderer(renderer *lipgloss.Renderer) {
func (m *chatsTUIModel) setRenderer(renderer *lipgloss.Renderer) {
styles := newTUIStyles(renderer)
m.styles = styles
m.list.styles = styles
@@ -113,7 +113,7 @@ func (m *expChatsTUIModel) setRenderer(renderer *lipgloss.Renderer) {
m.chat.spinner.Style = styles.dimmedText
}
func (m expChatsTUIModel) Init() tea.Cmd {
func (m chatsTUIModel) Init() tea.Cmd {
if m.initialChatID != nil {
m.chat.activeChatID = *m.initialChatID
return tea.Batch(append([]tea.Cmd{m.chat.Init()}, m.loadChatCmd(*m.initialChatID, m.chat.chatGeneration)...)...)
@@ -121,17 +121,17 @@ func (m expChatsTUIModel) Init() tea.Cmd {
return tea.Batch(m.loadChatsCmd(), m.list.Init())
}
func (m expChatsTUIModel) loadChatsCmd() tea.Cmd {
func (m chatsTUIModel) loadChatsCmd() tea.Cmd {
return apiCmd(func() ([]codersdk.Chat, error) { return m.client.ListChats(m.ctx, nil) }, func(chats []codersdk.Chat, err error) tea.Msg { return chatsListedMsg{chats: chats, err: err} })
}
func (m expChatsTUIModel) loadChatCmd(chatID uuid.UUID, generation uint64) []tea.Cmd {
func (m chatsTUIModel) loadChatCmd(chatID uuid.UUID, generation uint64) []tea.Cmd {
return []tea.Cmd{apiCmd(func() (codersdk.Chat, error) { return m.client.GetChat(m.ctx, chatID) }, func(chat codersdk.Chat, err error) tea.Msg {
return chatOpenedMsg{generation: generation, chatID: chatID, chat: chat, err: err}
}), loadChatHistoryCmd(m.ctx, m.client, chatID, generation)}
}
func (m expChatsTUIModel) childWindowSizeMsg() tea.WindowSizeMsg {
func (m chatsTUIModel) childWindowSizeMsg() tea.WindowSizeMsg {
h := m.height
if m.currentView == viewList {
h = max(0, h-1)
@@ -139,7 +139,7 @@ func (m expChatsTUIModel) childWindowSizeMsg() tea.WindowSizeMsg {
return tea.WindowSizeMsg{Width: m.width, Height: h}
}
func (m *expChatsTUIModel) toggleOverlay(overlay tuiOverlay) bool {
func (m *chatsTUIModel) toggleOverlay(overlay tuiOverlay) bool {
if m.overlay == overlay {
m.overlay = overlayNone
return false
@@ -148,7 +148,7 @@ func (m *expChatsTUIModel) toggleOverlay(overlay tuiOverlay) bool {
return true
}
func (m *expChatsTUIModel) handleEsc(msg tea.KeyMsg) tea.Cmd {
func (m *chatsTUIModel) handleEsc(msg tea.KeyMsg) tea.Cmd {
if m.currentView == viewList && m.list.searching {
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
@@ -175,7 +175,7 @@ func isOverlayCloseKey(msg tea.KeyMsg) bool {
return key == "esc" || key == "ctrl+["
}
func (m *expChatsTUIModel) handleModelPickerKey(msg tea.KeyMsg) tea.Cmd {
func (m *chatsTUIModel) handleModelPickerKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "up", "k":
if m.chat.modelPickerCursor > 0 {
@@ -198,7 +198,7 @@ func (m *expChatsTUIModel) handleModelPickerKey(msg tea.KeyMsg) tea.Cmd {
return nil
}
func (m *expChatsTUIModel) handleAskUserQuestionKey(msg tea.KeyMsg) tea.Cmd {
func (m *chatsTUIModel) handleAskUserQuestionKey(msg tea.KeyMsg) tea.Cmd {
state := m.chat.pendingAskUserQuestion
if state == nil || state.Submitting || len(state.Questions) == 0 {
return nil
@@ -269,7 +269,7 @@ func (m *expChatsTUIModel) handleAskUserQuestionKey(msg tea.KeyMsg) tea.Cmd {
return nil
}
func (m *expChatsTUIModel) recordAskAnswer(answer, optionLabel string, freeform bool) tea.Cmd {
func (m *chatsTUIModel) recordAskAnswer(answer, optionLabel string, freeform bool) tea.Cmd {
state := m.chat.pendingAskUserQuestion
if state == nil || len(state.Questions) == 0 {
return nil
@@ -305,7 +305,7 @@ func (m *expChatsTUIModel) recordAskAnswer(answer, optionLabel string, freeform
return submitAskUserQuestionCmd(m.client.Client, m.chat.activeChatID, m.chat.chatGeneration, state)
}
func (m *expChatsTUIModel) openChatCmd(chatID *uuid.UUID) tea.Cmd {
func (m *chatsTUIModel) openChatCmd(chatID *uuid.UUID) tea.Cmd {
m.currentView = viewChat
m.chat.stopStream()
m.resetChatSession()
@@ -322,7 +322,7 @@ func (m *expChatsTUIModel) openChatCmd(chatID *uuid.UUID) tea.Cmd {
return tea.Batch(append([]tea.Cmd{m.chat.Init()}, m.loadChatCmd(*chatID, m.chat.chatGeneration)...)...)
}
func (m *expChatsTUIModel) toggleModelPickerCmd() tea.Cmd {
func (m *chatsTUIModel) toggleModelPickerCmd() tea.Cmd {
if !m.toggleOverlay(overlayModelPicker) {
return nil
}
@@ -337,7 +337,7 @@ func (m *expChatsTUIModel) toggleModelPickerCmd() tea.Cmd {
return nil
}
func (m *expChatsTUIModel) toggleDiffDrawerCmd() tea.Cmd {
func (m *chatsTUIModel) toggleDiffDrawerCmd() tea.Cmd {
if m.chat.chat == nil {
return nil
}
@@ -355,7 +355,7 @@ func (m *expChatsTUIModel) toggleDiffDrawerCmd() tea.Cmd {
return nil
}
func (m expChatsTUIModel) updateChild(msg tea.Msg, view tuiView) (expChatsTUIModel, tea.Cmd) {
func (m chatsTUIModel) updateChild(msg tea.Msg, view tuiView) (chatsTUIModel, tea.Cmd) {
var cmd tea.Cmd
if view == viewChat {
m.chat, cmd = m.chat.Update(msg)
@@ -365,11 +365,11 @@ func (m expChatsTUIModel) updateChild(msg tea.Msg, view tuiView) (expChatsTUIMod
return m, cmd
}
func (m expChatsTUIModel) renderOverlay(title, body string) string {
func (m chatsTUIModel) renderOverlay(title, body string) string {
return renderOverlayFrame(m.styles, m.width, m.styles.title.Render(title), body, m.styles.helpText.Render("Esc to close"))
}
func (m expChatsTUIModel) diffOverlayView() string {
func (m chatsTUIModel) diffOverlayView() string {
switch {
case m.chat.diffErr != nil:
return m.renderOverlay("Diff", m.styles.errorText.Render(wrapPreservingNewlines(m.chat.diffErr.Error(), contentWidth(m.width, 6))))
@@ -394,7 +394,7 @@ func padViewHeight(text string, height int) string {
return text + strings.Repeat("\n", height-lineCount)
}
func (m expChatsTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m chatsTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
@@ -480,7 +480,7 @@ func (m expChatsTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateChild(msg, m.currentView)
}
func (m expChatsTUIModel) View() string {
func (m chatsTUIModel) View() string {
if m.quitting {
return ""
}
@@ -16,7 +16,7 @@ import (
var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func TestExpAgentsRender(t *testing.T) {
func TestAgentsRender(t *testing.T) {
t.Parallel()
styles := newTUIStyles()
+39 -39
View File
@@ -21,7 +21,7 @@ import (
"github.com/coder/websocket"
)
func TestExpAgents(t *testing.T) {
func TestAgents(t *testing.T) {
t.Parallel()
t.Run("ResolveModel", func(t *testing.T) {
t.Parallel()
@@ -73,7 +73,7 @@ func TestExpAgents(t *testing.T) {
for _, tt := range tests {
t.Run("EscFromOverlayClosesIt/"+tt.name, func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.overlay = tt.overlay
@@ -99,7 +99,7 @@ func TestExpAgents(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.overlay = tt.overlay
@@ -113,7 +113,7 @@ func TestExpAgents(t *testing.T) {
t.Run("EscFromChatViewReturnsToListAndRefreshes", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.overlay = overlayNone
@@ -126,7 +126,7 @@ func TestExpAgents(t *testing.T) {
t.Run("EscFromChatViewAdvancesGeneration", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.overlay = overlayNone
model.chatGeneration = 4
@@ -142,7 +142,7 @@ func TestExpAgents(t *testing.T) {
t.Run("EscFromChatViewRejectsLateChatLoadMessages", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.overlay = overlayNone
model.chatGeneration = 4
@@ -175,7 +175,7 @@ func TestExpAgents(t *testing.T) {
{ID: uuid.New(), Title: "beta", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()},
{ID: uuid.New(), Title: "gamma", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()},
}
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 10})
model = mustTUIModel(t, updatedModel, cmd)
model.currentView = viewList
@@ -211,7 +211,7 @@ func TestExpAgents(t *testing.T) {
} {
t.Run("CtrlCQuitsFromAnyState/"+name, func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = view
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
@@ -237,7 +237,7 @@ func TestExpAgents(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.width, model.height = 100, 40
updatedModel, cmd := model.Update(tt.msg)
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
@@ -259,7 +259,7 @@ func TestExpAgents(t *testing.T) {
})
t.Run("EscFromChatViewRestoresListHeaderAndPadsTerminal", func(t *testing.T) {
t.Parallel()
assertReturnToList := func(t testing.TB, model expChatsTUIModel) {
assertReturnToList := func(t testing.TB, model chatsTUIModel) {
t.Helper()
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd)
@@ -271,7 +271,7 @@ func TestExpAgents(t *testing.T) {
t.Run("SelectedChat", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
model = mustTUIModel(t, updatedModel, cmd)
model.list.loading = false
@@ -292,7 +292,7 @@ func TestExpAgents(t *testing.T) {
t.Run("DraftChat", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
model = mustTUIModel(t, updatedModel, cmd)
model.list.loading = false
@@ -308,7 +308,7 @@ func TestExpAgents(t *testing.T) {
t.Run("ChatViewOmitsListHeaderAndLoadingSpinner", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
model = mustTUIModel(t, updatedModel, cmd)
model.currentView = viewChat
@@ -336,7 +336,7 @@ func TestExpAgents(t *testing.T) {
t.Run("ReopensModelPickerAfterClosing", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
catalog := codersdk.ChatModelsResponse{
Providers: []codersdk.ChatModelProvider{{
@@ -378,7 +378,7 @@ func TestExpAgents(t *testing.T) {
t.Run("CancelClosesOverlay", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.width = 80
model.height = 24
@@ -396,7 +396,7 @@ func TestExpAgents(t *testing.T) {
t.Run("EscClosesPickerWithoutLeavingChat", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.width = 80
model.height = 24
@@ -430,7 +430,7 @@ func TestExpAgents(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.width = 80
model.height = 24
@@ -456,7 +456,7 @@ func TestExpAgents(t *testing.T) {
t.Run("EnterSelectsModelWithoutSendingDraft", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.width = 80
model.height = 24
@@ -488,7 +488,7 @@ func TestExpAgents(t *testing.T) {
t.Run("LoadErrorClosesOverlay", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.width = 80
model.height = 24
@@ -507,7 +507,7 @@ func TestExpAgents(t *testing.T) {
t.Run("ScrollAndSelectModel", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.width = 80
model.height = 24
@@ -535,7 +535,7 @@ func TestExpAgents(t *testing.T) {
t.Run("DiffDrawerLoadingState", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
chat := testChat(codersdk.ChatStatusCompleted)
model.chat.chat = &chat
@@ -549,7 +549,7 @@ func TestExpAgents(t *testing.T) {
t.Run("DiffDrawerErrorState", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.width = 80
chat := testChat(codersdk.ChatStatusCompleted)
@@ -565,7 +565,7 @@ func TestExpAgents(t *testing.T) {
t.Run("DiffDrawerMemoizesSummary", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.width = 80
chat := testChat(codersdk.ChatStatusCompleted)
@@ -602,7 +602,7 @@ func TestExpAgents(t *testing.T) {
t.Run("OverlayDismissedOnViewSwitch", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.overlay = overlayModelPicker
@@ -634,7 +634,7 @@ func TestExpAgents(t *testing.T) {
}},
}
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.overlay = overlayModelPicker
model.catalog = &catalog
@@ -665,7 +665,7 @@ func TestExpAgents(t *testing.T) {
}},
}
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
model.catalog = &catalog
model.chat.modelPickerFlat = catalog.Providers[0].Models
@@ -685,7 +685,7 @@ func TestExpAgents(t *testing.T) {
t.Parallel()
firstChatID := uuid.New()
secondChatID := uuid.New()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.width = 100
model.height = 40
@@ -1877,7 +1877,7 @@ func TestExpAgents(t *testing.T) {
t.Run("ChatView/ViewportScrolling", func(t *testing.T) {
t.Parallel()
applyWindowSize := func(t *testing.T, model expChatsTUIModel, width int, height int) expChatsTUIModel {
applyWindowSize := func(t *testing.T, model chatsTUIModel, width int, height int) chatsTUIModel {
t.Helper()
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: width, Height: height})
return mustTUIModel(t, updatedModel, cmd)
@@ -2175,7 +2175,7 @@ func TestExpAgents(t *testing.T) {
}},
}
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
updatedModel, cmd := model.Update(modelsListedMsg{catalog: catalog})
@@ -2210,7 +2210,7 @@ func TestExpAgents(t *testing.T) {
t.Parallel()
t.Run("StreamingChatSwitchBackToList", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.currentView = viewChat
chat := testChat(codersdk.ChatStatusRunning)
model.chat.chat = &chat
@@ -2230,7 +2230,7 @@ func TestExpAgents(t *testing.T) {
t.Run("ReOpenSameChatAfterEsc", func(t *testing.T) {
t.Parallel()
chatID := uuid.New()
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
model.width = 100
model.height = 40
@@ -2895,7 +2895,7 @@ func TestExpAgents(t *testing.T) {
t.Run("RecordAskAnswer", func(t *testing.T) {
t.Parallel()
model := newExpChatsTUIModel(context.Background(), failingExperimentalClient(), nil, nil, nil, uuid.Nil)
model := newChatsTUIModel(context.Background(), failingExperimentalClient(), nil, nil, nil, uuid.Nil)
model.chat.activeChatID = uuid.New()
model.chat.chatGeneration = 4
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
@@ -3136,7 +3136,7 @@ func TestExpAgents(t *testing.T) {
})
}
func TestExpAgents_View_LongInputFitsTerminal(t *testing.T) {
func TestAgents_View_LongInputFitsTerminal(t *testing.T) {
t.Parallel()
model := newTestChatViewModel(nil)
model.width, model.height = 80, 24
@@ -3162,17 +3162,17 @@ func TestExpAgents_View_LongInputFitsTerminal(t *testing.T) {
require.NotEmpty(t, strings.TrimSpace(lines[len(lines)-1]))
}
func mustTUIModel(t testing.TB, model tea.Model, cmd tea.Cmd) expChatsTUIModel {
func mustTUIModel(t testing.TB, model tea.Model, cmd tea.Cmd) chatsTUIModel {
t.Helper()
updated, ok := model.(expChatsTUIModel)
updated, ok := model.(chatsTUIModel)
require.True(t, ok)
require.Nil(t, cmd)
return updated
}
func mustTUIModelWithCmd(t testing.TB, model tea.Model, cmd tea.Cmd) (expChatsTUIModel, tea.Cmd) {
func mustTUIModelWithCmd(t testing.TB, model tea.Model, cmd tea.Cmd) (chatsTUIModel, tea.Cmd) {
t.Helper()
updated, ok := model.(expChatsTUIModel)
updated, ok := model.(chatsTUIModel)
require.True(t, ok)
return updated, cmd
}
@@ -3264,8 +3264,8 @@ func newTestChatViewModel(client *codersdk.ExperimentalClient) chatViewModel {
return newChatViewModel(context.Background(), client, nil, nil, uuid.Nil, newTUIStyles())
}
func newTestTUIModel() expChatsTUIModel {
return newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
func newTestTUIModel() chatsTUIModel {
return newChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
}
func newReadyChatListModel() chatListModel {
+1 -1
View File
@@ -100,6 +100,7 @@ const (
func (r *RootCmd) CoreSubcommands() []*serpent.Command {
// Please re-sort this list alphabetically if you change it!
return []*serpent.Command{
r.agentsCommand(),
r.completion(),
r.dotfiles(),
externalAuth(),
@@ -163,7 +164,6 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
r.promptExample(),
r.rptyCommand(),
r.syncCommand(),
r.agentsCommand(),
}
}
+1
View File
@@ -14,6 +14,7 @@ USAGE:
$ coder templates init
SUBCOMMANDS:
agents Interactive terminal UI for AI agents.
autoupdate Toggle auto-update policy for a workspace
completion Install or update shell completion scripts for the
detected or chosen shell.
+16
View File
@@ -0,0 +1,16 @@
coder v0.0.0-devel
USAGE:
coder agents [flags] [chat-id]
Interactive terminal UI for AI agents.
OPTIONS:
--model string
Choose a model by ID, provider/model, or display name.
--workspace string
Associate the chat with a workspace by name, owner/name, or UUID.
———
Run `coder --help` for a list of global options.
-4
View File
@@ -16271,12 +16271,10 @@ const docTemplate = `{
"notifications",
"workspace-usage",
"oauth2",
"agents",
"mcp-server-http",
"workspace-build-updates"
],
"x-enum-comments": {
"ExperimentAgents": "Enables agent-powered chat functionality.",
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
"ExperimentExample": "This isn't used for anything.",
"ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.",
@@ -16291,7 +16289,6 @@ const docTemplate = `{
"Sends notifications via SMTP and webhooks following certain events.",
"Enables the new workspace usage tracking.",
"Enables OAuth2 provider functionality.",
"Enables agent-powered chat functionality.",
"Enables the MCP HTTP server functionality.",
"Enables publishing workspace build updates to the all builds pubsub channel."
],
@@ -16301,7 +16298,6 @@ const docTemplate = `{
"ExperimentNotifications",
"ExperimentWorkspaceUsage",
"ExperimentOAuth2",
"ExperimentAgents",
"ExperimentMCPServerHTTP",
"ExperimentWorkspaceBuildUpdates"
]
-4
View File
@@ -14731,12 +14731,10 @@
"notifications",
"workspace-usage",
"oauth2",
"agents",
"mcp-server-http",
"workspace-build-updates"
],
"x-enum-comments": {
"ExperimentAgents": "Enables agent-powered chat functionality.",
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
"ExperimentExample": "This isn't used for anything.",
"ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.",
@@ -14751,7 +14749,6 @@
"Sends notifications via SMTP and webhooks following certain events.",
"Enables the new workspace usage tracking.",
"Enables OAuth2 provider functionality.",
"Enables agent-powered chat functionality.",
"Enables the MCP HTTP server functionality.",
"Enables publishing workspace build updates to the all builds pubsub channel."
],
@@ -14761,7 +14758,6 @@
"ExperimentNotifications",
"ExperimentWorkspaceUsage",
"ExperimentOAuth2",
"ExperimentAgents",
"ExperimentMCPServerHTTP",
"ExperimentWorkspaceBuildUpdates"
]
+7 -14
View File
@@ -768,7 +768,7 @@ func New(options *Options) *API {
}
api.agentProvider = stn
{ // Experimental: agents — chat daemon and git sync worker initialization.
{ // Chat daemon and git sync worker initialization.
maxChatsPerAcquire := options.DeploymentValues.AI.Chat.AcquireBatchSize.Value()
if maxChatsPerAcquire > math.MaxInt32 {
maxChatsPerAcquire = math.MaxInt32
@@ -1153,11 +1153,9 @@ func New(options *Options) *API {
})
})
})
// Experimental(agents): chat API routes gated by ExperimentAgents.
r.Route("/chats", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentAgents),
)
r.Get("/by-workspace", api.chatsByWorkspace)
r.Get("/", api.listChats)
@@ -1280,7 +1278,6 @@ func New(options *Options) *API {
)
// MCP server configuration endpoints.
r.Route("/servers", func(r chi.Router) {
r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentAgents))
r.Get("/", api.listMCPServerConfigs)
r.Post("/", api.createMCPServerConfig)
r.Route("/{mcpServer}", func(r chi.Router) {
@@ -2006,14 +2003,10 @@ func New(options *Options) *API {
"parsing additional CSP headers", slog.Error(cspParseErrors))
}
// Add blob: to img-src for chat file attachment previews when
// the agents experiment is enabled.
if api.Experiments.Enabled(codersdk.ExperimentAgents) {
additionalCSPHeaders[httpmw.CSPDirectiveImgSrc] = append(
additionalCSPHeaders[httpmw.CSPDirectiveImgSrc], "blob:",
)
}
// Add blob: to img-src for chat file attachment previews.
additionalCSPHeaders[httpmw.CSPDirectiveImgSrc] = append(
additionalCSPHeaders[httpmw.CSPDirectiveImgSrc], "blob:",
)
// Add CSP headers to all static assets and pages. CSP headers only affect
// browsers, so these don't make sense on api routes.
cspProxyHosts := func() []*proxyhealth.ProxyHost {
@@ -2161,9 +2154,9 @@ type API struct {
// dbRolluper rolls up template usage stats from raw agent and app
// stats. This is used to provide insights in the WebUI.
dbRolluper *dbrollup.Rolluper
// Experimental(agents): chatDaemon handles background processing of pending chats.
// chatDaemon handles background processing of pending chats.
chatDaemon *chatd.Server
// Experimental(agents): gitSyncWorker refreshes stale chat diff statuses in the background.
// gitSyncWorker refreshes stale chat diff statuses in the background.
gitSyncWorker *gitsync.Worker
// AISeatTracker records AI seat usage.
AISeatTracker aiseats.SeatTracker
-1
View File
@@ -56,7 +56,6 @@ func chatDeploymentValues(t testing.TB) *codersdk.DeploymentValues {
t.Helper()
values := coderdtest.DeploymentValues(t)
values.Experiments = []string{string(codersdk.ExperimentAgents)}
return values
}
+4 -8
View File
@@ -18,19 +18,15 @@ import (
"github.com/coder/coder/v2/testutil"
)
// mcpDeploymentValues returns deployment values with the agents
// experiment enabled, which is required by the MCP server config
// endpoints.
// mcpDeploymentValues returns deployment values for tests of the MCP
// server config endpoints.
func mcpDeploymentValues(t testing.TB) *codersdk.DeploymentValues {
t.Helper()
values := coderdtest.DeploymentValues(t)
values.Experiments = []string{string(codersdk.ExperimentAgents)}
return values
return coderdtest.DeploymentValues(t)
}
// newMCPClient creates a test server with the agents experiment
// enabled and returns the admin client.
// newMCPClient creates a test server and returns the admin client.
func newMCPClient(t testing.TB) *codersdk.Client {
t.Helper()
-6
View File
@@ -201,7 +201,6 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
IncludeProvisionerDaemon: true,
@@ -366,7 +365,6 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
IncludeProvisionerDaemon: true,
@@ -549,7 +547,6 @@ func TestExploreSubagentIsReadOnly(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
IncludeProvisionerDaemon: true,
@@ -4936,7 +4933,6 @@ func TestCreateWorkspaceTool_EndToEnd(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
IncludeProvisionerDaemon: true,
@@ -5117,7 +5113,6 @@ func TestStartWorkspaceTool_EndToEnd(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitSuperLong)
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
IncludeProvisionerDaemon: true,
@@ -8504,7 +8499,6 @@ func TestAgentContextFilesAndSkillsLoadedIntoChat(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitSuperLong)
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
IncludeProvisionerDaemon: true,
+3 -6
View File
@@ -35,9 +35,8 @@ func TestAnthropicWebSearchRoundTrip(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Stand up a full coderd with the agents experiment.
// Stand up a full coderd.
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
})
@@ -296,9 +295,8 @@ func TestOpenAIReasoningRoundTrip(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Stand up a full coderd with the agents experiment.
// Stand up a full coderd.
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
})
@@ -451,9 +449,8 @@ func TestOpenAIReasoningRoundTripStoreFalse(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Stand up a full coderd with the agents experiment.
// Stand up a full coderd.
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
})
-5
View File
@@ -4403,7 +4403,6 @@ const (
ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events.
ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking.
ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality.
ExperimentAgents Experiment = "agents" // Enables agent-powered chat functionality.
ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality.
ExperimentWorkspaceBuildUpdates Experiment = "workspace-build-updates" // Enables publishing workspace build updates to the all builds pubsub channel.
)
@@ -4420,8 +4419,6 @@ func (e Experiment) DisplayName() string {
return "Workspace Usage Tracking"
case ExperimentOAuth2:
return "OAuth2 Provider Functionality"
case ExperimentAgents:
return "Agents"
case ExperimentMCPServerHTTP:
return "MCP HTTP Server Functionality"
case ExperimentWorkspaceBuildUpdates:
@@ -4441,7 +4438,6 @@ var ExperimentsKnown = Experiments{
ExperimentNotifications,
ExperimentWorkspaceUsage,
ExperimentOAuth2,
ExperimentAgents,
ExperimentMCPServerHTTP,
ExperimentWorkspaceBuildUpdates,
}
@@ -4450,7 +4446,6 @@ var ExperimentsKnown = Experiments{
// users to opt-in to via --experimental='*'.
// Experiments that are not ready for consumption by all users should
// not be included here and will be essentially hidden.
// TODO: Add ExperimentAgents to ExperimentsSafe once it is safe for general use.
var ExperimentsSafe = Experiments{}
// Experiments is a list of experiments.
+1 -1
View File
@@ -1,7 +1,7 @@
# Chats API
> [!NOTE]
> The Chats API is experimental and gated behind the `agents` experiment flag.
> The Chats API is in beta.
> Endpoints live under `/api/experimental/chats` and may change without notice.
The Chats API lets you create and interact with Coder Agents
+3 -19
View File
@@ -40,27 +40,11 @@ Functionality available during Early Access may be a subset of planned
capabilities. Some features may be incomplete, experimental, or subject to
redesign.
## Enable Coder Agents
## Set up Coder Agents
Coder Agents is experimental and must not be deployed to production
environments. It is gated behind the `agents` experiment flag. To enable it,
pass the flag when starting the Coder server using an environment variable
or CLI flag:
Coder Agents is available by default. No experiment flags are required.
```sh
CODER_EXPERIMENTS="agents" coder server
# or
coder server --experiments=agents
```
If you are already using other experiments, add `agents` to the
comma-separated list:
```sh
CODER_EXPERIMENTS="agents,oauth2,mcp-server-http" coder server
```
Once the server restarts with the experiment enabled:
To get started:
1. Navigate to the **Agents** page in the Coder dashboard.
1. Open **Admin** settings and configure at least one LLM provider and model.
+9 -31
View File
@@ -1,6 +1,6 @@
# Getting Started
This guide walks platform teams and administrators through enabling Coder
This guide walks platform teams and administrators through setting up Coder
Agents, preparing your deployment, and running your first Coder Agent.
> [!NOTE]
@@ -12,8 +12,7 @@ Agents, preparing your deployment, and running your first Coder Agent.
Before you begin, confirm the following:
- **Coder deployment** running the latest release with the `agents`
experiment flag available.
- **Coder deployment** running the latest release.
- **LLM provider credentials** — an API key for at least one
[supported provider](./models.md) (Anthropic, OpenAI, Google, Azure OpenAI,
AWS Bedrock, OpenAI Compatible, OpenRouter, or Vercel AI Gateway).
@@ -22,40 +21,19 @@ Before you begin, confirm the following:
- **At least one template** with a
[descriptive name and description](./platform-controls/template-optimization.md)
for the agent to select when provisioning workspaces.
- **Admin access** to the Coder deployment for enabling the experiment and
configuring providers.
- **Admin access** to the Coder deployment for configuring providers.
- **Coder Agents User role** assigned to each user who needs to interact with Coder Agents.
Owners can assign this from **Admin** > **Users**. See
[Grant Coder Agents User](#step-3-grant-coder-agents-user) below.
[Grant Coder Agents User](#step-2-grant-coder-agents-user) below.
## Step 1: Enable the experiment
Coder Agents is gated behind the `agents` experiment flag. Pass it when
starting the Coder server:
```sh
CODER_EXPERIMENTS="agents" coder server
# or
coder server --experiments=agents
```
If you already use other experiments, add `agents` to the comma-separated list:
```sh
CODER_EXPERIMENTS="agents,oauth2,mcp-server-http" coder server
```
See [Enable Coder Agents](./early-access.md#enable-coder-agents) for full
details.
## Step 2: Configure an LLM provider and model
## Step 1: Configure an LLM provider and model
> [!IMPORTANT]
> Configuring providers, models, and system prompts requires the
> **Owner** role (Coder administrator). Non-admin users cannot access the
> Admin panel or modify deployment-level Agents configuration.
Once the server restarts with the experiment enabled:
To configure Coder Agents:
1. Navigate to the **Agents** page in the Coder dashboard.
1. Click **Admin** to open the configuration dialog.
@@ -72,7 +50,7 @@ Detailed instructions for each provider and model option are in the
> Start with a single frontier model to validate your setup before adding
> additional providers.
## Step 3: Grant Coder Agents User
## Step 2: Grant Coder Agents User
The **Coder Agents User** role controls which users can interact with Coder Agents.
Members do not have Coder Agents User by default.
@@ -105,7 +83,7 @@ coder users list -o json \
done
```
## Step 4: Start your first Coder Agent
## Step 3: Start your first Coder Agent
1. Go to the **Agents** page in the Coder dashboard.
1. Select a model from the dropdown (your default will be pre-selected).
@@ -266,7 +244,7 @@ rather than developer session tokens. Keep automation credentials
narrowly scoped.
> [!NOTE]
> The Chats API is experimental and may change without notice.
> The Chats API is in beta and may change without notice.
> See [Chats API](./chats-api.md) for the full endpoint reference.
### Add workspace context with AGENTS.md
+1 -1
View File
@@ -1292,7 +1292,7 @@
},
{
"title": "Chats API",
"description": "Programmatic access to Coder Agents via the experimental Chats API",
"description": "Programmatic access to Coder Agents via the Chats API",
"path": "./ai-coder/agents/chats-api.md",
"state": ["early access"]
}
+3 -3
View File
@@ -4656,9 +4656,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
#### Enumerated Values
| Value(s) |
|-----------------------------------------------------------------------------------------------------------------------------------------|
| `agents`, `auto-fill-parameters`, `example`, `mcp-server-http`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` |
| Value(s) |
|-------------------------------------------------------------------------------------------------------------------------------|
| `auto-fill-parameters`, `example`, `mcp-server-http`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` |
## codersdk.ExternalAPIKeyScopes
+28
View File
@@ -0,0 +1,28 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# agents
Interactive terminal UI for AI agents.
## Usage
```console
coder agents [flags] [chat-id]
```
## Options
### --workspace
| | |
|------|---------------------|
| Type | <code>string</code> |
Associate the chat with a workspace by name, owner/name, or UUID.
### --model
| | |
|------|---------------------|
| Type | <code>string</code> |
Choose a model by ID, provider/model, or display name.
+1
View File
@@ -24,6 +24,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr
| Name | Purpose |
|--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| [<code>agents</code>](./agents.md) | Interactive terminal UI for AI agents. |
| [<code>completion</code>](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. |
| [<code>dotfiles</code>](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository |
| [<code>external-auth</code>](./external-auth.md) | Manage external authentication |
-2
View File
@@ -1104,7 +1104,6 @@ func TestCreateChatNonDefaultOrg(t *testing.T) {
Options: &coderdtest.Options{
DeploymentValues: func() *codersdk.DeploymentValues {
v := coderdtest.DeploymentValues(t)
v.Experiments = []string{string(codersdk.ExperimentAgents)}
return v
}(),
},
@@ -1181,7 +1180,6 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) {
Options: &coderdtest.Options{
DeploymentValues: func() *codersdk.DeploymentValues {
v := coderdtest.DeploymentValues(t)
v.Experiments = []string{string(codersdk.ExperimentAgents)}
return v
}(),
},
-1
View File
@@ -453,7 +453,6 @@ func TestListRoles(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentAgents)}
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
+3
View File
@@ -23,6 +23,9 @@ pattern="${emdash}|${endash}"
# Git exclude_pathspecs excluded from the check. Used in both ls-files and diff comparison.
exclude_pathspecs=(
":(exclude)aibridge/fixtures/**/*.txtar"
# Generated CLI golden files embed serpent's emdash-bordered footer.
":(exclude)cli/testdata/*.golden"
":(exclude)enterprise/cli/testdata/*.golden"
)
scan_all_files() {
-1
View File
@@ -29,7 +29,6 @@
<meta property="docs-url" content="{{ .DocsURL }}" />
<meta property="logo-url" content="{{ .LogoURL }}" />
<meta property="tasks-tab-visible" content="{{ .TasksTabVisible }}" />
<meta property="agents-tab-visible" content="{{ .AgentsTabVisible }}" />
<meta property="permissions" content="{{ .Permissions }}" />
<meta property="organizations" content="{{ .Organizations }}" />
<link
+3 -14
View File
@@ -266,10 +266,9 @@ type htmlState struct {
Regions string
DocsURL string
TasksTabVisible string
AgentsTabVisible string
Permissions string
Organizations string
TasksTabVisible string
Permissions string
Organizations string
}
type csrfState struct {
@@ -525,16 +524,6 @@ func (h *Handler) populateHTMLState(
state.TasksTabVisible = html.EscapeString(string(data))
}
})
wg.Go(func() {
agentsTabVisible := false
if experiments != nil {
agentsTabVisible = experiments.Enabled(codersdk.ExperimentAgents)
}
data, err := json.Marshal(agentsTabVisible)
if err == nil {
state.AgentsTabVisible = html.EscapeString(string(data))
}
})
wg.Go(func() {
sdkOrgs := slice.List(userOrgs, db2sdk.Organization)
data, err := json.Marshal(sdkOrgs)
-2
View File
@@ -3778,7 +3778,6 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning";
// From codersdk/deployment.go
export type Experiment =
| "agents"
| "auto-fill-parameters"
| "example"
| "mcp-server-http"
@@ -3788,7 +3787,6 @@ export type Experiment =
| "workspace-usage";
export const Experiments: Experiment[] = [
"agents",
"auto-fill-parameters",
"example",
"mcp-server-http",
@@ -1,7 +1,6 @@
import { act, renderHook } from "@testing-library/react";
import type { Region, User } from "#/api/typesGenerated";
import {
MockAgentsTabVisible,
MockAppearanceConfig,
MockBuildInfo,
MockEntitlements,
@@ -46,7 +45,6 @@ const mockDataForTags = {
userAppearance: MockUserAppearanceSettings,
regions: MockRegions,
"tasks-tab-visible": MockTasksTabVisible,
"agents-tab-visible": MockAgentsTabVisible,
permissions: MockPermissions,
organizations: [MockOrganization],
} as const satisfies Record<MetadataKey, MetadataValue>;
@@ -84,10 +82,6 @@ const emptyMetadata: RuntimeHtmlMetadata = {
available: false,
value: undefined,
},
"agents-tab-visible": {
available: false,
value: undefined,
},
permissions: {
available: false,
value: undefined,
@@ -131,10 +125,6 @@ const populatedMetadata: RuntimeHtmlMetadata = {
available: true,
value: MockTasksTabVisible,
},
"agents-tab-visible": {
available: true,
value: MockAgentsTabVisible,
},
permissions: {
available: true,
value: MockPermissions,
-2
View File
@@ -32,7 +32,6 @@ type AvailableMetadata = Readonly<{
regions: readonly Region[];
"build-info": BuildInfoResponse;
"tasks-tab-visible": boolean;
"agents-tab-visible": boolean;
permissions: Permissions;
organizations: Organization[];
}>;
@@ -97,7 +96,6 @@ export class MetadataManager implements MetadataManagerApi {
"build-info": this.registerValue<BuildInfoResponse>("build-info"),
regions: this.registerRegionValue(),
"tasks-tab-visible": this.registerValue<boolean>("tasks-tab-visible"),
"agents-tab-visible": this.registerValue<boolean>("agents-tab-visible"),
permissions: this.registerValue<Permissions>("permissions"),
organizations: this.registerValue<Organization[]>("organizations"),
};
@@ -20,7 +20,6 @@ const meta: Meta<typeof NavbarView> = {
parameters: {
chromatic: chromaticWithTablet,
layout: "fullscreen",
experiments: ["agents"],
queries: [
{
key: ["tasks", tasksFilter],
@@ -14,7 +14,6 @@ import {
} from "#/components/Tooltip/Tooltip";
import type { ProxyContextValue } from "#/contexts/ProxyContext";
import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { NotificationsInbox } from "#/modules/notifications/NotificationsInbox/NotificationsInbox";
import { getPrereleaseFlag } from "#/utils/buildInfo";
import { cn } from "#/utils/cn";
@@ -272,12 +271,7 @@ function idleTasksLabel(count: number) {
}
const AgentsNavItem: FC<{ canCreateChat: boolean }> = ({ canCreateChat }) => {
const { experiments, buildInfo } = useDashboard();
const prerelease = getPrereleaseFlag(buildInfo);
const experimentEnabled =
experiments.includes("agents") || prerelease === "devel";
if (!experimentEnabled || !canCreateChat) {
if (!canCreateChat) {
return null;
}
@@ -28,6 +28,7 @@ import {
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { FeatureStageBadge } from "#/components/FeatureStageBadge/FeatureStageBadge";
import { CoderIcon } from "#/components/Icons/CoderIcon";
import { Spinner } from "#/components/Spinner/Spinner";
import { useWebpushNotifications } from "#/contexts/useWebpushNotifications";
@@ -132,13 +133,16 @@ export const AgentPageHeader: FC<AgentPageHeaderProps> = ({
</Link>
</Button>
) : (
<NavLink to="/workspaces" className="inline-flex shrink-0 md:hidden">
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
<div className="inline-flex shrink-0 items-center gap-2 md:hidden">
<NavLink to="/workspaces" className="inline-flex">
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
<FeatureStageBadge contentType="beta" size="sm" />
</div>
)}
{isSidebarCollapsed && (
<Button
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type * as TypesGen from "#/api/typesGenerated";
import type { Chat } from "#/api/typesGenerated";
import { TooltipProvider } from "#/components/Tooltip/Tooltip";
import { ThemeOverride } from "#/contexts/ThemeProvider";
import { DashboardContext } from "#/modules/dashboard/DashboardProvider";
import {
@@ -91,11 +92,13 @@ const Wrapper: FC<PropsWithChildren> = ({ children }) => {
return (
<QueryClientProvider client={queryClient}>
<ThemeOverride theme={themes[DEFAULT_THEME]}>
<MemoryRouter initialEntries={["/agents"]}>
<DashboardContext.Provider value={dashboardValue}>
{children}
</DashboardContext.Provider>
</MemoryRouter>
<TooltipProvider>
<MemoryRouter initialEntries={["/agents"]}>
<DashboardContext.Provider value={dashboardValue}>
{children}
</DashboardContext.Provider>
</MemoryRouter>
</TooltipProvider>
</ThemeOverride>
</QueryClientProvider>
);
@@ -89,6 +89,7 @@ import {
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { FeatureStageBadge } from "#/components/FeatureStageBadge/FeatureStageBadge";
import { CoderIcon } from "#/components/Icons/CoderIcon";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { Skeleton } from "#/components/Skeleton/Skeleton";
@@ -1087,13 +1088,16 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
>
<div className="hidden border-b border-border-default px-2 pb-3 pt-1.5 md:block">
<div className="mb-2.5 flex items-center justify-between">
<NavLink to="/workspaces" className="inline-flex">
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
<div className="flex items-center gap-2">
<NavLink to="/workspaces" className="inline-flex">
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
<FeatureStageBadge contentType="beta" size="sm" />
</div>
<div className="flex items-center gap-0.5 -mr-1.5">
<Button
asChild
@@ -82,8 +82,6 @@ const WorkspacesPage: FC = () => {
},
});
const { permissions, user: me } = useAuthenticated();
const { experiments } = useDashboard();
const agentsEnabled = experiments.includes("agents");
const templatesQuery = useQuery(templates());
const workspacePermissionsQuery = useQuery(
workspacePermissionsByOrganization(
@@ -147,7 +145,11 @@ const WorkspacesPage: FC = () => {
);
const chatsByWorkspaceQuery = useQuery({
...chatsByWorkspace(workspaceIds),
enabled: agentsEnabled && workspaceIds.length > 0,
// Only fetch chat lookups for users who can actually create chats;
// the endpoint still runs a DB query + RBAC post-filter and the
// AgentsNavItem / chat link UI is already hidden for users without
// this permission, so the query would return nothing useful for them.
enabled: permissions.createChat && workspaceIds.length > 0,
});
const [activeBatchAction, setActiveBatchAction] = useState<BatchAction>();
-2
View File
@@ -572,8 +572,6 @@ export const MockUserAppearanceSettings: TypesGen.UserAppearanceSettings = {
export const MockTasksTabVisible: boolean = false;
export const MockAgentsTabVisible: boolean = false;
export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = {
organization_id: MockOrganization.id,
user_id: MockUserOwner.id,