Skip to content

Commit 945dddc

Browse files
committed
chore: add experiment agentic-chat, add tests for chat API routes
1 parent 85bae3c commit 945dddc

File tree

9 files changed

+156
-13
lines changed

9 files changed

+156
-13
lines changed

coderd/apidoc/docs.go

+5-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+5-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/chat.go

+10-8
Original file line numberDiff line numberDiff line change
@@ -161,29 +161,31 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) {
161161
return
162162
}
163163

164-
messages := make([]aisdk.Message, 0, len(dbMessages))
165-
for i, message := range dbMessages {
166-
err = json.Unmarshal(message.Content, &messages[i])
164+
messages := make([]codersdk.ChatMessage, 0)
165+
for _, dbMsg := range dbMessages {
166+
var msg codersdk.ChatMessage
167+
err = json.Unmarshal(dbMsg.Content, &msg)
167168
if err != nil {
168169
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
169170
Message: "Failed to unmarshal chat message",
170171
Detail: err.Error(),
171172
})
172173
return
173174
}
175+
messages = append(messages, msg)
174176
}
175177
messages = append(messages, req.Message)
176178

177179
client := codersdk.New(api.AccessURL)
178180
client.SetSessionToken(httpmw.APITokenFromRequest(r))
179181

180-
tools := make([]aisdk.Tool, len(toolsdk.All))
182+
tools := make([]aisdk.Tool, 0)
181183
handlers := map[string]toolsdk.GenericHandlerFunc{}
182-
for i, tool := range toolsdk.All {
183-
if tool.Tool.Schema.Required == nil {
184-
tool.Tool.Schema.Required = []string{}
184+
for _, tool := range toolsdk.All {
185+
if tool.Name == "coder_report_task" {
186+
continue // This tool requires an agent to run.
185187
}
186-
tools[i] = tool.Tool
188+
tools = append(tools, tool.Tool)
187189
handlers[tool.Tool.Name] = tool.Handler
188190
}
189191

coderd/chat_test.go

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package coderd_test
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/coder/coder/v2/coderd/coderdtest"
12+
"github.com/coder/coder/v2/coderd/database"
13+
"github.com/coder/coder/v2/coderd/database/dbgen"
14+
"github.com/coder/coder/v2/coderd/database/dbtime"
15+
"github.com/coder/coder/v2/codersdk"
16+
"github.com/coder/coder/v2/testutil"
17+
)
18+
19+
func TestChat(t *testing.T) {
20+
t.Parallel()
21+
22+
t.Run("ExperimentAgenticChatDisabled", func(t *testing.T) {
23+
t.Parallel()
24+
25+
client, _ := coderdtest.NewWithDatabase(t, nil)
26+
owner := coderdtest.CreateFirstUser(t, client)
27+
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
28+
29+
// Hit the endpoint to get the chat. It should return a 404.
30+
ctx := testutil.Context(t, testutil.WaitShort)
31+
_, err := memberClient.ListChats(ctx)
32+
require.Error(t, err, "list chats should fail")
33+
var sdkErr *codersdk.Error
34+
require.ErrorAs(t, err, &sdkErr, "request should fail with an SDK error")
35+
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
36+
})
37+
38+
t.Run("ChatCRUD", func(t *testing.T) {
39+
t.Parallel()
40+
41+
dv := coderdtest.DeploymentValues(t)
42+
dv.Experiments = []string{string(codersdk.ExperimentAgenticChat)}
43+
dv.AI.Value = codersdk.AIConfig{
44+
Providers: []codersdk.AIProviderConfig{
45+
{
46+
Type: "fake",
47+
APIKey: "",
48+
BaseURL: "http://localhost",
49+
Models: []string{"fake-model"},
50+
},
51+
},
52+
}
53+
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
54+
DeploymentValues: dv,
55+
})
56+
owner := coderdtest.CreateFirstUser(t, client)
57+
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
58+
59+
// Seed the database with some data.
60+
dbChat := dbgen.Chat(t, db, database.Chat{
61+
OwnerID: memberUser.ID,
62+
CreatedAt: dbtime.Now().Add(-time.Hour),
63+
UpdatedAt: dbtime.Now().Add(-time.Hour),
64+
Title: "This is a test chat",
65+
})
66+
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
67+
ChatID: dbChat.ID,
68+
CreatedAt: dbtime.Now().Add(-time.Hour),
69+
Content: []byte(`[{"content": "Hello world"}]`),
70+
Model: "fake model",
71+
Provider: "fake",
72+
})
73+
74+
ctx := testutil.Context(t, testutil.WaitShort)
75+
76+
// Listing chats should return the chat we just inserted.
77+
chats, err := memberClient.ListChats(ctx)
78+
require.NoError(t, err, "list chats should succeed")
79+
require.Len(t, chats, 1, "response should have one chat")
80+
require.Equal(t, dbChat.ID, chats[0].ID, "unexpected chat ID")
81+
require.Equal(t, dbChat.Title, chats[0].Title, "unexpected chat title")
82+
require.Equal(t, dbChat.CreatedAt.UTC(), chats[0].CreatedAt.UTC(), "unexpected chat created at")
83+
require.Equal(t, dbChat.UpdatedAt.UTC(), chats[0].UpdatedAt.UTC(), "unexpected chat updated at")
84+
85+
// Fetching a single chat by ID should return the same chat.
86+
chat, err := memberClient.Chat(ctx, dbChat.ID)
87+
require.NoError(t, err, "get chat should succeed")
88+
require.Equal(t, chats[0], chat, "get chat should return the same chat")
89+
90+
// Listing chat messages should return the message we just inserted.
91+
messages, err := memberClient.ChatMessages(ctx, dbChat.ID)
92+
require.NoError(t, err, "list chat messages should succeed")
93+
require.Len(t, messages, 1, "response should have one message")
94+
require.Equal(t, "Hello world", messages[0].Content, "response should have the correct message content")
95+
96+
// Creating a new chat will fail because the model does not exist.
97+
// TODO: Test the message streaming functionality with a mock model.
98+
// Inserting a chat message will fail due to the model not existing.
99+
_, err = memberClient.CreateChatMessage(ctx, dbChat.ID, codersdk.CreateChatMessageRequest{
100+
Model: "echo",
101+
Message: codersdk.ChatMessage{
102+
Role: "user",
103+
Content: "Hello world",
104+
},
105+
Thinking: false,
106+
})
107+
require.Error(t, err, "create chat message should fail")
108+
var sdkErr *codersdk.Error
109+
require.ErrorAs(t, err, &sdkErr, "create chat should fail with an SDK error")
110+
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode(), "create chat should fail with a 400 when model does not exist")
111+
112+
// Creating a new chat message with malformed content should fail.
113+
res, err := memberClient.Request(ctx, http.MethodPost, "/api/v2/chats/"+dbChat.ID.String()+"/messages", strings.NewReader(`{malformed json}`))
114+
require.NoError(t, err)
115+
defer res.Body.Close()
116+
apiErr := codersdk.ReadBodyAsError(res)
117+
require.Contains(t, apiErr.Error(), "Failed to decode chat message")
118+
119+
_, err = memberClient.CreateChat(ctx)
120+
require.NoError(t, err, "create chat should succeed")
121+
chats, err = memberClient.ListChats(ctx)
122+
require.NoError(t, err, "list chats should succeed")
123+
require.Len(t, chats, 2, "response should have two chats")
124+
})
125+
}

coderd/coderd.go

+5-1
Original file line numberDiff line numberDiff line change
@@ -1001,8 +1001,12 @@ func New(options *Options) *API {
10011001
r.Get("/{fileID}", api.fileByID)
10021002
r.Post("/", api.postFile)
10031003
})
1004+
// Chats are an experimental feature
10041005
r.Route("/chats", func(r chi.Router) {
1005-
r.Use(apiKeyMiddleware)
1006+
r.Use(
1007+
apiKeyMiddleware,
1008+
httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentAgenticChat),
1009+
)
10061010
r.Get("/", api.listChats)
10071011
r.Post("/", api.postChats)
10081012
r.Route("/{chat}", func(r chi.Router) {

codersdk/chat.go

+3
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ func (c *Client) ListChats(ctx context.Context) ([]Chat, error) {
4040
return nil, xerrors.Errorf("execute request: %w", err)
4141
}
4242
defer res.Body.Close()
43+
if res.StatusCode != http.StatusOK {
44+
return nil, ReadBodyAsError(res)
45+
}
4346

4447
var chats []Chat
4548
return chats, json.NewDecoder(res.Body).Decode(&chats)

codersdk/deployment.go

+1
Original file line numberDiff line numberDiff line change
@@ -3328,6 +3328,7 @@ const (
33283328
ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser.
33293329
ExperimentDynamicParameters Experiment = "dynamic-parameters" // Enables dynamic parameters when creating a workspace.
33303330
ExperimentWorkspacePrebuilds Experiment = "workspace-prebuilds" // Enables the new workspace prebuilds feature.
3331+
ExperimentAgenticChat Experiment = "agentic-chat" // Enables the new agentic AI chat feature.
33313332
)
33323333

33333334
// ExperimentsSafe should include all experiments that are safe for

docs/reference/api/schemas.md

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/api/typesGenerated.ts

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)