Files
coder/coderd/chat_routes_test.go
Garrett DelfosseandClaude Opus 5.5 7cd1b2040b feat: add chat projects API (#29242)
- Experiment, behind `chat-projects` (off by default). First of three
stacked PRs: #29301 (memory), #29583 (UI). This PR is API only; the
screenshots below are from the UI in #29583.
- CRUD under
`/api/experimental/organizations/{organization}/chats/projects/{project}`.
`GET /api/experimental/chats/projects` lists the caller's projects
across all organizations.
- `project_id` on chat create and as a chat list filter. A chat joins a
project only at creation; there is no move.
- Projects are identified by ID. Names are display labels (64
characters, description 1024) with no uniqueness constraint. An optional
`icon` URL (usually an emoji image under `/emojis`) is capped at 256
characters and audited.
- A user can own at most 100 projects across all organizations; creating
another answers 409. Creates count and insert under a per-owner advisory
lock so concurrent requests cannot overshoot.
- Projects are private to their `owner_id`. Members may only create
their own, and a chat can only be bound to a project its owner owns. An
unreadable or foreign project on chat create answers 404 so IDs cannot
be probed; a project from another organization answers 400.
- Permissions ride on the `agents-access` role next to chats (after
#30004), so whoever can use Coder Agents can use projects. Owners and
org admins reach every project.

### Examples

Create a project in an organization:

```http
POST /api/experimental/organizations/{organization}/chats/projects
{"name": "Platform", "description": "Infra and deploy work", "icon": "/emojis/1f680.png"}
```

```json
{
  "id": "7c0e4a3e-5b1f-4f7d-9a51-2f3c8f6d2b10",
  "organization_id": "bb640d07-ca8a-4869-b6bc-ae61ebb2fda1",
  "owner_id": "0ed9befc-4911-4ccf-a8e2-559bf72daa94",
  "name": "Platform",
  "description": "Infra and deploy work",
  "icon": "/emojis/1f680.png",
  "created_at": "2026-09-30T15:04:05Z",
  "updated_at": "2026-09-30T15:04:05Z"
}
```

List the caller's projects across organizations with `GET
/api/experimental/chats/projects` (an array of the object above). Update
with `PATCH .../chats/projects/{project}` and any of `name`,
`description`, `icon`; delete with `DELETE`, which detaches its chats.

Create a chat in the project, and list only its chats:

```http
POST /api/v2/chats
{"organization_id": "bb640d07-…", "project_id": "7c0e4a3e-…", "content": [{"type": "text", "text": "Plan the deploy"}]}

GET /api/v2/chats?project_id=7c0e4a3e-…
```

At the limit:

```json
409 {"message": "You can have at most 100 chat projects. Delete a project to create another."}
```

<details>
<summary>Screenshots (UI from #29583)</summary>

![Create
project](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr1-01-create-project-dialog.png)
![Sidebar
folders](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr1-02-sidebar-folders.png)
![Project
page](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr1-03-project-page.png)
![Folder
menu](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr1-05-folder-menu.png)
![Another member sees no
projects](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr5-member-sees-no-projects.png)

</details>

---
Generated with Coder Agents on behalf of @f0ssel.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:49:21 -04:00

73 lines
2.6 KiB
Go

package coderd_test
import (
"fmt"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/testutil"
)
func TestChatRouteMounts(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := coderdtest.NewWithDatabase(t, nil)
firstUser := coderdtest.CreateFirstUser(t, client)
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
OrganizationID: firstUser.OrganizationID,
})
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: model.ID,
})
for _, route := range []string{
"/api/v2/chats",
"/api/v2/chats/config/system-prompt",
fmt.Sprintf("/api/experimental/chats/%s/debug/runs", chat.ID),
"/api/experimental/chats/projects",
} {
res, err := client.Request(ctx, http.MethodGet, route, nil)
require.NoError(t, err)
_ = res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode, route)
}
for _, route := range []struct {
method string
path string
}{
// Promoted routes no longer answer on /api/experimental.
{http.MethodGet, "/api/experimental/chats"},
{http.MethodGet, "/api/experimental/chats/config/system-prompt"},
{http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models", firstUser.OrganizationID)},
{http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers", firstUser.OrganizationID)},
{http.MethodDelete, fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/disconnect", uuid.New())},
{http.MethodGet, "/api/experimental/users/me/ai-provider-keys"},
// Never-promoted routes are absent from /api/v2.
{http.MethodGet, "/api/v2/chats/model-configs"},
{http.MethodPost, "/api/v2/chats/model-configs"},
{http.MethodGet, "/api/v2/chats/config/computer-use-provider"},
{http.MethodGet, "/api/v2/chats/config/advisor"},
{http.MethodGet, "/api/v2/chats/projects"},
{http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/chats/projects", firstUser.OrganizationID)},
{http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/debug/runs", chat.ID)},
{http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/stream/desktop", chat.ID)},
{http.MethodGet, "/api/v2/mcp/servers/not-a-uuid/oauth2/callback"},
{http.MethodPost, "/api/v2/mcp/http/server"},
} {
res, err := client.Request(ctx, route.method, route.path, nil)
require.NoError(t, err)
_ = res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode, "%s %s", route.method, route.path)
}
}