mirror of
https://github.com/coder/coder.git
synced 2026-11-01 21:13:14 +08:00
Extends the Agent-chat MCP tools so an unattended UAT evidence loop can
fetch artifacts, monitor long runs, and find prior runs without burning
model context.
## Backend
- New `chat_files_token` crypto key feature (migration 000571) with
rotator support and a dedicated signing keycache on coderd.
- `POST /api/experimental/chats/files/{file}/download-url`
(authenticated) mints a short-lived (5 min) signed URL and returns it
with `sha256`, `size_bytes`, `name`, `mime_type`, and `expires_at`.
- `GET /api/experimental/chats/files/{file}/download?token=` (no session
token) redeems the signed URL: verifies the JWS, requires the token's
`file_id` to match the path, and re-checks the minting user's RBAC
access live at redemption. Clients can `curl -o` artifacts with zero
credentials in the URL consumer.
- `ChatFileMetadata` gains `size_bytes` (via `octet_length`, no bytes
fetched).
## MCP tools (`codersdk/toolsdk`)
- `coder_download_chat_file`: by `file_id` or `chat_id`+`file_name`;
returns the signed URL plus checksum and size instead of base64.
- `coder_await_chat`: blocks (bounded `wait_secs`, 1-120) until a chat
leaves `running`/`interrupting`, using the existing watch stream with
subscribe-before-read.
- `coder_list_chats`: label, query, and limit filtering; chat
projections now include labels.
- `coder_get_chat_messages`: `after_id` forward cursor with
`next_after_id` (exact incremental reads), plus per-message `files`
metadata so artifact-bearing messages are identifiable.
- `coder_get_chat`: file listings now include `size_bytes` and
`created_at`.
- `coder_list_templates`: exposes `agents_allowed` for pre-flight
checks.
## Testing
- coderd: mint/redeem happy path with an unauthenticated client,
expired/tampered/file-mismatched tokens, auth still required on the
plain file endpoint, non-owner mint rejection.
- toolsdk: harness + integration coverage for all new/changed tools,
including signed-URL redemption with checksum verification,
forward-cursor exactness, await transition/timeout paths, and label
filtering.
- Remote dogfood UAT (dev.coder.com Coder Agent) passed all six
acceptance scenarios end to end over both MCP transports.
Note: `go test ./codersdk/toolsdk/` has a pre-existing goleak flake on
main (leaked `agentssh` non-PTY session goroutines from SSH exec tests;
reproduced 3/3 on clean `b4971bc49f1`). It is unrelated to this diff.
> Mux acted on Mike's behalf to create this PR.
<!-- mux-attribution: model=claude-sonnet-4-6 thinking=high -->
835 lines
32 KiB
Go
835 lines
32 KiB
Go
package toolsdk_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/aibridgedtest"
|
|
"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/coderd/jwtutils"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/toolsdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
type failPathTransport struct {
|
|
path string
|
|
}
|
|
|
|
func (t *failPathTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Path == t.path {
|
|
return nil, xerrors.New("transport down")
|
|
}
|
|
return http.DefaultTransport.RoundTrip(req)
|
|
}
|
|
|
|
// stallTransport hangs every request until its context is canceled.
|
|
type stallTransport struct{}
|
|
|
|
func (stallTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
<-req.Context().Done()
|
|
return nil, req.Context().Err()
|
|
}
|
|
|
|
type signalPathTransport struct {
|
|
path string
|
|
seen chan struct{}
|
|
release chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (t *signalPathTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
res, err := http.DefaultTransport.RoundTrip(req)
|
|
if err != nil || req.URL.Path != t.path {
|
|
return res, err
|
|
}
|
|
t.once.Do(func() { close(t.seen) })
|
|
select {
|
|
case <-t.release:
|
|
return res, nil
|
|
case <-req.Context().Done():
|
|
_ = res.Body.Close()
|
|
return nil, req.Context().Err()
|
|
}
|
|
}
|
|
|
|
// Chat tools need a chat-enabled coderd (provider keys, a default model
|
|
// config, and an AI bridge daemon), so they are tested separately from
|
|
// TestTools. Subtests run sequentially and share the deployment.
|
|
// nolint:tparallel,paralleltest
|
|
func TestChatTools(t *testing.T) {
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
|
DeploymentValues: coderdtest.DeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
ChatFileTokenKeyCache: jwtutils.StaticKey{ID: "1", Key: bytes.Repeat([]byte("k"), 64)},
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
expClient := codersdk.NewExperimentalClient(client)
|
|
defaultModelConfig := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, "")
|
|
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
|
|
|
|
tb, err := toolsdk.NewDeps(client)
|
|
require.NoError(t, err)
|
|
|
|
t.Run("ListChatModelConfigs", func(t *testing.T) {
|
|
result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{})
|
|
require.NoError(t, err)
|
|
require.Len(t, result.ModelConfigs, 1)
|
|
require.Equal(t, defaultModelConfig.ID.String(), result.ModelConfigs[0].ID)
|
|
require.Equal(t, coderdtest.TestChatModelOpenAICompat, result.ModelConfigs[0].Model)
|
|
require.True(t, result.ModelConfigs[0].IsDefault)
|
|
})
|
|
|
|
t.Run("Lifecycle", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
created, err := testTool(t, toolsdk.CreateChat, tb, toolsdk.CreateChatArgs{
|
|
Prompt: "Say hello.",
|
|
Labels: map[string]string{"purpose": "toolsdk-test"},
|
|
})
|
|
require.NoError(t, err)
|
|
chatID, err := uuid.Parse(created.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, client.URL.String()+"/agents/"+created.ID, created.URL)
|
|
|
|
coderdtest.WaitForChatSettled(ctx, t, api, chatID)
|
|
|
|
got, err := testTool(t, toolsdk.GetChat, tb, toolsdk.GetChatArgs{ChatID: created.ID})
|
|
require.NoError(t, err)
|
|
require.Equal(t, created.ID, got.ID)
|
|
require.Equal(t, codersdk.ChatStatusWaiting, got.Status)
|
|
require.Nil(t, got.LastError)
|
|
require.False(t, got.Archived)
|
|
|
|
sent, err := testTool(t, toolsdk.SendChatMessage, tb, toolsdk.SendChatMessageArgs{
|
|
ChatID: created.ID,
|
|
Text: "Say hello again.",
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, sent.Queued)
|
|
|
|
coderdtest.WaitForChatSettled(ctx, t, api, chatID)
|
|
|
|
messages, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ChatID: created.ID})
|
|
require.NoError(t, err)
|
|
require.False(t, messages.HasMore)
|
|
var texts []string
|
|
for _, msg := range messages.Messages {
|
|
texts = append(texts, string(msg.Role)+": "+msg.Text)
|
|
}
|
|
require.Contains(t, texts, "user: Say hello.")
|
|
require.Contains(t, texts, "user: Say hello again.")
|
|
require.Contains(t, texts, "assistant: Hello from test server.")
|
|
require.Equal(t, "user: Say hello.", texts[0])
|
|
|
|
hookNoticeContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{
|
|
Type: codersdk.ChatMessagePartTypeHookNotice,
|
|
Text: "Command denied by policy.",
|
|
}})
|
|
require.NoError(t, err)
|
|
dbgen.ChatMessage(t, api.Database, database.ChatMessage{
|
|
ChatID: chatID,
|
|
ModelConfigID: uuid.NullUUID{UUID: defaultModelConfig.ID, Valid: true},
|
|
Role: database.ChatMessageRoleUser,
|
|
Content: hookNoticeContent,
|
|
})
|
|
withNotice, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ChatID: created.ID})
|
|
require.NoError(t, err)
|
|
var noticeTexts []string
|
|
for _, msg := range withNotice.Messages {
|
|
noticeTexts = append(noticeTexts, msg.Text)
|
|
}
|
|
require.Contains(t, noticeTexts, "Command denied by policy.")
|
|
|
|
// A tool-call-only message filters to an empty page, so the
|
|
// cursor must come from the unfiltered API page.
|
|
toolCallContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: "call-1",
|
|
ToolName: "execute",
|
|
}})
|
|
require.NoError(t, err)
|
|
toolCallMsg := dbgen.ChatMessage(t, api.Database, database.ChatMessage{
|
|
ChatID: chatID,
|
|
ModelConfigID: uuid.NullUUID{UUID: defaultModelConfig.ID, Valid: true},
|
|
Role: database.ChatMessageRoleAssistant,
|
|
Content: toolCallContent,
|
|
})
|
|
|
|
firstPage, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{
|
|
ChatID: created.ID,
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, firstPage.HasMore)
|
|
require.Empty(t, firstPage.Messages)
|
|
require.Equal(t, toolCallMsg.ID, firstPage.NextBeforeID)
|
|
olderPage, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{
|
|
ChatID: created.ID,
|
|
BeforeID: firstPage.NextBeforeID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, olderPage.Messages)
|
|
for _, msg := range olderPage.Messages {
|
|
require.Less(t, msg.ID, firstPage.NextBeforeID)
|
|
}
|
|
require.False(t, olderPage.HasMore)
|
|
require.Zero(t, olderPage.NextBeforeID)
|
|
|
|
archived, err := testTool(t, toolsdk.ArchiveChat, tb, toolsdk.ArchiveChatArgs{ChatID: created.ID})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, archived.Message)
|
|
|
|
got, err = testTool(t, toolsdk.GetChat, tb, toolsdk.GetChatArgs{ChatID: created.ID})
|
|
require.NoError(t, err)
|
|
require.True(t, got.Archived)
|
|
})
|
|
|
|
t.Run("DownloadChatFile", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
data := []byte("MCP chat UAT evidence\n")
|
|
uploaded, err := expClient.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "evidence.txt", bytes.NewReader(data))
|
|
require.NoError(t, err)
|
|
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{
|
|
{Type: codersdk.ChatInputPartTypeText, Text: "Review the evidence."},
|
|
{Type: codersdk.ChatInputPartTypeFile, FileID: uploaded.ID},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
assertDownload := func(t *testing.T, got toolsdk.DownloadChatFileResponse) {
|
|
t.Helper()
|
|
require.Equal(t, uploaded.ID.String(), got.FileID)
|
|
require.Equal(t, "evidence.txt", got.Name)
|
|
require.Equal(t, "text/plain", got.MimeType)
|
|
require.Equal(t, int64(len(data)), got.SizeBytes)
|
|
require.Equal(t, fmt.Sprintf("%x", sha256.Sum256(data)), got.SHA256)
|
|
require.NotEmpty(t, got.URL)
|
|
require.False(t, got.ExpiresAt.IsZero())
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, got.URL, nil)
|
|
require.NoError(t, err)
|
|
res, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
body, err := io.ReadAll(res.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, data, body)
|
|
}
|
|
|
|
byID, err := testTool(t, toolsdk.DownloadChatFile, tb, toolsdk.DownloadChatFileArgs{FileID: uploaded.ID.String()})
|
|
require.NoError(t, err)
|
|
assertDownload(t, byID)
|
|
byName, err := testTool(t, toolsdk.DownloadChatFile, tb, toolsdk.DownloadChatFileArgs{
|
|
ChatID: chat.ID.String(),
|
|
FileName: "evidence.txt",
|
|
})
|
|
require.NoError(t, err)
|
|
assertDownload(t, byName)
|
|
|
|
status, err := testTool(t, toolsdk.GetChat, tb, toolsdk.GetChatArgs{ChatID: chat.ID.String()})
|
|
require.NoError(t, err)
|
|
require.Len(t, status.Files, 1)
|
|
require.Equal(t, int64(len(data)), status.Files[0].SizeBytes)
|
|
require.False(t, status.Files[0].CreatedAt.IsZero())
|
|
|
|
messages, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ChatID: chat.ID.String()})
|
|
require.NoError(t, err)
|
|
var attachingMessage *toolsdk.ChatToolMessage
|
|
for i := range messages.Messages {
|
|
if messages.Messages[i].Text == "Review the evidence." {
|
|
attachingMessage = &messages.Messages[i]
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, attachingMessage)
|
|
require.Equal(t, []toolsdk.ChatToolMessageFile{{
|
|
ID: uploaded.ID.String(),
|
|
Name: "evidence.txt",
|
|
MimeType: "text/plain",
|
|
}}, attachingMessage.Files)
|
|
|
|
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
|
|
fileOnly, err := expClient.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "file-only.txt", bytes.NewReader([]byte("file only")))
|
|
require.NoError(t, err)
|
|
_, err = expClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeFile, FileID: fileOnly.ID}},
|
|
})
|
|
require.NoError(t, err)
|
|
messages, err = testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ChatID: chat.ID.String()})
|
|
require.NoError(t, err)
|
|
var fileOnlyMessage *toolsdk.ChatToolMessage
|
|
for i := range messages.Messages {
|
|
if len(messages.Messages[i].Files) == 1 && messages.Messages[i].Files[0].ID == fileOnly.ID.String() {
|
|
fileOnlyMessage = &messages.Messages[i]
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, fileOnlyMessage)
|
|
require.Empty(t, fileOnlyMessage.Text)
|
|
require.Equal(t, []toolsdk.ChatToolMessageFile{{
|
|
ID: fileOnly.ID.String(),
|
|
Name: "file-only.txt",
|
|
MimeType: "text/plain",
|
|
}}, fileOnlyMessage.Files)
|
|
|
|
duplicateA, err := expClient.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "duplicate.txt", bytes.NewReader([]byte("a")))
|
|
require.NoError(t, err)
|
|
duplicateB, err := expClient.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "duplicate.txt", bytes.NewReader([]byte("bb")))
|
|
require.NoError(t, err)
|
|
ambiguousChat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{
|
|
{Type: codersdk.ChatInputPartTypeText, Text: "Compare these files."},
|
|
{Type: codersdk.ChatInputPartTypeFile, FileID: duplicateA.ID},
|
|
{Type: codersdk.ChatInputPartTypeFile, FileID: duplicateB.ID},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = testTool(t, toolsdk.DownloadChatFile, tb, toolsdk.DownloadChatFileArgs{
|
|
ChatID: ambiguousChat.ID.String(),
|
|
FileName: "duplicate.txt",
|
|
})
|
|
require.ErrorContains(t, err, "multiple chat files")
|
|
require.ErrorContains(t, err, duplicateA.ID.String())
|
|
require.ErrorContains(t, err, duplicateB.ID.String())
|
|
require.ErrorContains(t, err, "mime_type")
|
|
require.ErrorContains(t, err, "size_bytes")
|
|
|
|
_, err = testTool(t, toolsdk.DownloadChatFile, tb, toolsdk.DownloadChatFileArgs{
|
|
ChatID: ambiguousChat.ID.String(),
|
|
FileName: "missing.txt",
|
|
})
|
|
require.ErrorContains(t, err, "no chat file")
|
|
require.ErrorContains(t, err, "duplicate.txt")
|
|
|
|
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
|
|
coderdtest.WaitForChatSettled(ctx, t, api, ambiguousChat.ID)
|
|
})
|
|
|
|
t.Run("ForwardMessagePagination", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "Create a pagination baseline."}},
|
|
})
|
|
require.NoError(t, err)
|
|
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
|
|
|
|
existing, err := expClient.GetChatMessages(ctx, chat.ID, nil)
|
|
require.NoError(t, err)
|
|
var baselineID int64
|
|
for _, msg := range existing.Messages {
|
|
baselineID = max(baselineID, msg.ID)
|
|
}
|
|
require.Positive(t, baselineID)
|
|
|
|
textContent := func(text string) database.ChatMessage {
|
|
content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: text}})
|
|
require.NoError(t, err)
|
|
return database.ChatMessage{
|
|
ChatID: chat.ID,
|
|
ModelConfigID: uuid.NullUUID{UUID: defaultModelConfig.ID, Valid: true},
|
|
Role: database.ChatMessageRoleUser,
|
|
Content: content,
|
|
}
|
|
}
|
|
first := dbgen.ChatMessage(t, api.Database, textContent("forward one"))
|
|
toolContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: "forward-call",
|
|
ToolName: "execute",
|
|
}})
|
|
require.NoError(t, err)
|
|
toolOnly := dbgen.ChatMessage(t, api.Database, database.ChatMessage{
|
|
ChatID: chat.ID,
|
|
ModelConfigID: uuid.NullUUID{UUID: defaultModelConfig.ID, Valid: true},
|
|
Role: database.ChatMessageRoleAssistant,
|
|
Content: toolContent,
|
|
})
|
|
last := dbgen.ChatMessage(t, api.Database, textContent("forward two"))
|
|
|
|
firstPage, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{
|
|
ChatID: chat.ID.String(),
|
|
AfterID: baselineID,
|
|
Limit: 2,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, firstPage.HasMore)
|
|
require.Equal(t, toolOnly.ID, firstPage.NextAfterID)
|
|
require.Zero(t, firstPage.NextBeforeID)
|
|
require.Len(t, firstPage.Messages, 1)
|
|
require.Equal(t, first.ID, firstPage.Messages[0].ID)
|
|
|
|
secondPage, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{
|
|
ChatID: chat.ID.String(),
|
|
AfterID: firstPage.NextAfterID,
|
|
Limit: 2,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, secondPage.HasMore)
|
|
require.Equal(t, last.ID, secondPage.NextAfterID)
|
|
require.Len(t, secondPage.Messages, 1)
|
|
require.Equal(t, last.ID, secondPage.Messages[0].ID)
|
|
require.Greater(t, secondPage.Messages[0].ID, firstPage.Messages[0].ID)
|
|
|
|
finalPage, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{
|
|
ChatID: chat.ID.String(),
|
|
AfterID: secondPage.NextAfterID,
|
|
Limit: 2,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, finalPage.HasMore)
|
|
require.Zero(t, finalPage.NextAfterID)
|
|
require.Empty(t, finalPage.Messages)
|
|
})
|
|
|
|
t.Run("ListChatsByLabel", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
labelValue := uuid.NewString()
|
|
matching, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "Matching chat."}},
|
|
Labels: map[string]string{"uat-evidence": labelValue},
|
|
})
|
|
require.NoError(t, err)
|
|
nonmatching, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "Nonmatching chat."}},
|
|
Labels: map[string]string{"uat-evidence": uuid.NewString()},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
result, err := testTool(t, toolsdk.ListChats, tb, toolsdk.ListChatsArgs{
|
|
Labels: map[string]string{"uat-evidence": labelValue},
|
|
Limit: 100,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Chats, 1)
|
|
require.Equal(t, matching.ID.String(), result.Chats[0].ID)
|
|
require.Equal(t, map[string]string{"uat-evidence": labelValue}, result.Chats[0].Labels)
|
|
|
|
coderdtest.WaitForChatSettled(ctx, t, api, matching.ID)
|
|
coderdtest.WaitForChatSettled(ctx, t, api, nonmatching.ID)
|
|
})
|
|
|
|
t.Run("AwaitChat", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
settled, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "Settle immediately."}},
|
|
})
|
|
require.NoError(t, err)
|
|
coderdtest.WaitForChatSettled(ctx, t, api, settled.ID)
|
|
immediate, err := testTool(t, toolsdk.AwaitChat, tb, toolsdk.AwaitChatArgs{ChatID: settled.ID.String()})
|
|
require.NoError(t, err)
|
|
require.False(t, immediate.TimedOut)
|
|
require.Equal(t, codersdk.ChatStatusWaiting, immediate.Chat.Status)
|
|
|
|
streamStarted := make(chan struct{})
|
|
providerRelease := make(chan struct{})
|
|
var providerStartedOnce sync.Once
|
|
var providerReleaseOnce sync.Once
|
|
releaseProvider := func() { providerReleaseOnce.Do(func() { close(providerRelease) }) }
|
|
t.Cleanup(releaseProvider)
|
|
blockingURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
|
if req.Stream {
|
|
providerStartedOnce.Do(func() { close(streamStarted) })
|
|
select {
|
|
case <-providerRelease:
|
|
case <-req.Context().Done():
|
|
}
|
|
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("Released.")...)
|
|
}
|
|
return chattest.OpenAINonStreamingResponse(`{"title": "Await Test"}`)
|
|
})
|
|
blockingModel := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, blockingURL)
|
|
awaitFile, err := expClient.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "await.txt", bytes.NewReader([]byte("await evidence")))
|
|
require.NoError(t, err)
|
|
running, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{
|
|
{Type: codersdk.ChatInputPartTypeText, Text: "Wait for release."},
|
|
{Type: codersdk.ChatInputPartTypeFile, FileID: awaitFile.ID},
|
|
},
|
|
ModelConfigID: &blockingModel.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
select {
|
|
case <-streamStarted:
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
|
|
getSeen := make(chan struct{})
|
|
getRelease := make(chan struct{})
|
|
transport := &signalPathTransport{
|
|
path: "/api/experimental/chats/" + running.ID.String(),
|
|
seen: getSeen,
|
|
release: getRelease,
|
|
}
|
|
awaitClient := codersdk.New(client.URL)
|
|
awaitClient.SetSessionToken(client.SessionToken())
|
|
awaitClient.HTTPClient = &http.Client{Transport: transport}
|
|
t.Cleanup(awaitClient.HTTPClient.CloseIdleConnections)
|
|
awaitDeps, err := toolsdk.NewDeps(awaitClient)
|
|
require.NoError(t, err)
|
|
type awaitResult struct {
|
|
response toolsdk.AwaitChatResponse
|
|
err error
|
|
}
|
|
result := make(chan awaitResult, 1)
|
|
go func() {
|
|
response, err := toolsdk.AwaitChat.Handler(ctx, awaitDeps, toolsdk.AwaitChatArgs{
|
|
ChatID: running.ID.String(),
|
|
WaitSecs: 10,
|
|
})
|
|
result <- awaitResult{response: response, err: err}
|
|
}()
|
|
select {
|
|
case <-getSeen:
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
close(getRelease)
|
|
releaseProvider()
|
|
select {
|
|
case awaited := <-result:
|
|
require.NoError(t, awaited.err)
|
|
require.False(t, awaited.response.TimedOut)
|
|
require.Equal(t, codersdk.ChatStatusWaiting, awaited.response.Chat.Status)
|
|
require.Len(t, awaited.response.Chat.Files, 1)
|
|
require.Equal(t, awaitFile.ID.String(), awaited.response.Chat.Files[0].ID)
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
coderdtest.WaitForChatSettled(ctx, t, api, running.ID)
|
|
|
|
sharedClient, sharedUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
|
sharedStreamStarted := make(chan struct{})
|
|
sharedProviderRelease := make(chan struct{})
|
|
var sharedProviderStartedOnce sync.Once
|
|
var sharedProviderReleaseOnce sync.Once
|
|
releaseSharedProvider := func() { sharedProviderReleaseOnce.Do(func() { close(sharedProviderRelease) }) }
|
|
t.Cleanup(releaseSharedProvider)
|
|
sharedBlockingURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
|
if req.Stream {
|
|
sharedProviderStartedOnce.Do(func() { close(sharedStreamStarted) })
|
|
select {
|
|
case <-sharedProviderRelease:
|
|
case <-req.Context().Done():
|
|
}
|
|
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("Released.")...)
|
|
}
|
|
return chattest.OpenAINonStreamingResponse(`{"title": "Shared Await Test"}`)
|
|
})
|
|
sharedBlockingModel := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, sharedBlockingURL)
|
|
sharedRunning, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "Wait for shared release."}},
|
|
ModelConfigID: &sharedBlockingModel.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
select {
|
|
case <-sharedStreamStarted:
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
err = expClient.UpdateChatACL(ctx, sharedRunning.ID, codersdk.UpdateChatACL{
|
|
UserRoles: map[string]codersdk.ChatRole{sharedUser.ID.String(): codersdk.ChatRoleRead},
|
|
})
|
|
require.NoError(t, err)
|
|
acl, err := expClient.GetChatACL(ctx, sharedRunning.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, acl.Users, 1)
|
|
require.Equal(t, sharedUser.ID, acl.Users[0].ID)
|
|
require.Equal(t, codersdk.ChatRoleRead, acl.Users[0].Role)
|
|
|
|
sharedGetSeen := make(chan struct{})
|
|
sharedGetRelease := make(chan struct{})
|
|
sharedAwaitClient := codersdk.New(sharedClient.URL)
|
|
sharedAwaitClient.SetSessionToken(sharedClient.SessionToken())
|
|
sharedAwaitClient.HTTPClient = &http.Client{Transport: &signalPathTransport{
|
|
path: "/api/experimental/chats/" + sharedRunning.ID.String(),
|
|
seen: sharedGetSeen,
|
|
release: sharedGetRelease,
|
|
}}
|
|
t.Cleanup(sharedAwaitClient.HTTPClient.CloseIdleConnections)
|
|
sharedAwaitDeps, err := toolsdk.NewDeps(sharedAwaitClient)
|
|
require.NoError(t, err)
|
|
sharedAwaitCtx := testutil.Context(t, testutil.WaitMedium)
|
|
sharedResult := make(chan awaitResult, 1)
|
|
go func() {
|
|
response, err := toolsdk.AwaitChat.Handler(sharedAwaitCtx, sharedAwaitDeps, toolsdk.AwaitChatArgs{
|
|
ChatID: sharedRunning.ID.String(),
|
|
WaitSecs: 20,
|
|
})
|
|
sharedResult <- awaitResult{response: response, err: err}
|
|
}()
|
|
select {
|
|
case <-sharedGetSeen:
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
close(sharedGetRelease)
|
|
releaseSharedProvider()
|
|
select {
|
|
case awaited := <-sharedResult:
|
|
require.NoError(t, awaited.err)
|
|
require.False(t, awaited.response.TimedOut)
|
|
require.Equal(t, codersdk.ChatStatusWaiting, awaited.response.Chat.Status)
|
|
case <-sharedAwaitCtx.Done():
|
|
t.Fatal(sharedAwaitCtx.Err())
|
|
}
|
|
coderdtest.WaitForChatSettled(ctx, t, api, sharedRunning.ID)
|
|
|
|
timeoutStarted := make(chan struct{})
|
|
timeoutRelease := make(chan struct{})
|
|
var timeoutStartedOnce sync.Once
|
|
var timeoutReleaseOnce sync.Once
|
|
releaseTimeout := func() { timeoutReleaseOnce.Do(func() { close(timeoutRelease) }) }
|
|
t.Cleanup(releaseTimeout)
|
|
timeoutURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
|
if req.Stream {
|
|
timeoutStartedOnce.Do(func() { close(timeoutStarted) })
|
|
select {
|
|
case <-timeoutRelease:
|
|
case <-req.Context().Done():
|
|
}
|
|
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("Released.")...)
|
|
}
|
|
return chattest.OpenAINonStreamingResponse(`{"title": "Await Timeout Test"}`)
|
|
})
|
|
timeoutModel := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, timeoutURL)
|
|
busy, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "Stay busy."}},
|
|
ModelConfigID: &timeoutModel.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
select {
|
|
case <-timeoutStarted:
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
timedOut, err := testTool(t, toolsdk.AwaitChat, tb, toolsdk.AwaitChatArgs{
|
|
ChatID: busy.ID.String(),
|
|
WaitSecs: 1,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, timedOut.TimedOut)
|
|
require.Equal(t, codersdk.ChatStatusRunning, timedOut.Chat.Status)
|
|
releaseTimeout()
|
|
coderdtest.WaitForChatSettled(ctx, t, api, busy.ID)
|
|
|
|
// An initial status request that outlives the wait window
|
|
// errors within the wait_secs bound; the old post-deadline
|
|
// fallback fetch added up to 15 extra seconds.
|
|
stallClient := codersdk.New(client.URL)
|
|
stallClient.SetSessionToken(client.SessionToken())
|
|
stallClient.HTTPClient = &http.Client{Transport: stallTransport{}}
|
|
t.Cleanup(stallClient.HTTPClient.CloseIdleConnections)
|
|
stallDeps, err := toolsdk.NewDeps(stallClient)
|
|
require.NoError(t, err)
|
|
stallStart := time.Now()
|
|
_, err = toolsdk.AwaitChat.Handler(ctx, stallDeps, toolsdk.AwaitChatArgs{
|
|
ChatID: busy.ID.String(),
|
|
WaitSecs: 1,
|
|
})
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
require.Less(t, time.Since(stallStart), 10*time.Second)
|
|
})
|
|
|
|
t.Run("ListChatModelConfigsSkipsDisabledProviders", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
disabledProviderConfig := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, chattest.OpenAI(t))
|
|
provider, err := client.UpdateAIProvider(ctx, disabledProviderConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{
|
|
Enabled: ptr.Ref(false),
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, provider.Enabled)
|
|
|
|
result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{})
|
|
require.NoError(t, err)
|
|
var ids []string
|
|
for _, config := range result.ModelConfigs {
|
|
ids = append(ids, config.ID)
|
|
}
|
|
require.NotContains(t, ids, disabledProviderConfig.ID.String())
|
|
require.Contains(t, ids, defaultModelConfig.ID.String())
|
|
})
|
|
|
|
t.Run("ListChatModelConfigsSkipsDeletedProviders", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
deletedProviderConfig := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, chattest.OpenAI(t))
|
|
err := client.DeleteAIProvider(ctx, deletedProviderConfig.AIProviderID.String())
|
|
require.NoError(t, err)
|
|
|
|
result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{})
|
|
require.NoError(t, err)
|
|
var ids []string
|
|
for _, config := range result.ModelConfigs {
|
|
ids = append(ids, config.ID)
|
|
}
|
|
require.NotContains(t, ids, deletedProviderConfig.ID.String())
|
|
require.Contains(t, ids, defaultModelConfig.ID.String())
|
|
})
|
|
|
|
t.Run("Interrupt", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Block the chat turn so the interrupt has a deterministic target.
|
|
release := make(chan struct{})
|
|
blockingURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
|
if req.Stream {
|
|
select {
|
|
case <-release:
|
|
case <-req.Context().Done():
|
|
}
|
|
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("Released.")...)
|
|
}
|
|
return chattest.OpenAINonStreamingResponse(`{"title": "Interrupt Test"}`)
|
|
})
|
|
blockingModelConfig := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, blockingURL)
|
|
|
|
created, err := testTool(t, toolsdk.CreateChat, tb, toolsdk.CreateChatArgs{
|
|
Prompt: "Block forever.",
|
|
ModelConfigID: blockingModelConfig.ID.String(),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.ChatStatusRunning, created.Status)
|
|
|
|
sent, err := testTool(t, toolsdk.SendChatMessage, tb, toolsdk.SendChatMessageArgs{
|
|
ChatID: created.ID,
|
|
Text: "Queued while busy.",
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, sent.Queued)
|
|
// A queued prompt carrying only a file must surface in
|
|
// queued_messages rather than being dropped.
|
|
queuedFile, err := expClient.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "queued-only.txt", bytes.NewReader([]byte("queued file")))
|
|
require.NoError(t, err)
|
|
_, err = expClient.CreateChatMessage(ctx, uuid.MustParse(created.ID), codersdk.CreateChatMessageRequest{
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeFile, FileID: queuedFile.ID}},
|
|
BusyBehavior: codersdk.ChatBusyBehaviorQueue,
|
|
})
|
|
require.NoError(t, err)
|
|
transcript, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ChatID: created.ID})
|
|
require.NoError(t, err)
|
|
require.Contains(t, transcript.QueuedMessages, "Queued while busy.")
|
|
require.Contains(t, transcript.QueuedMessages, "(attached files: queued-only.txt)")
|
|
|
|
interrupted, err := testTool(t, toolsdk.InterruptChat, tb, toolsdk.InterruptChatArgs{ChatID: created.ID})
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.ChatStatusInterrupting, interrupted.Status)
|
|
|
|
close(release)
|
|
coderdtest.WaitForChatSettled(ctx, t, api, uuid.MustParse(created.ID))
|
|
})
|
|
|
|
t.Run("ListChatModelConfigsMemberAndAuditor", func(t *testing.T) {
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
|
memberDeps, err := toolsdk.NewDeps(memberClient)
|
|
require.NoError(t, err)
|
|
result, err := testTool(t, toolsdk.ListChatModelConfigs, memberDeps, toolsdk.NoArgs{})
|
|
require.NoError(t, err)
|
|
var ids []string
|
|
for _, config := range result.ModelConfigs {
|
|
ids = append(ids, config.ID)
|
|
}
|
|
require.Contains(t, ids, defaultModelConfig.ID.String())
|
|
|
|
auditorClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID, rbac.RoleAuditor())
|
|
auditorDeps, err := toolsdk.NewDeps(auditorClient)
|
|
require.NoError(t, err)
|
|
_, err = testTool(t, toolsdk.ListChatModelConfigs, auditorDeps, toolsdk.NoArgs{})
|
|
require.ErrorContains(t, err, "missing AI provider read permission")
|
|
|
|
brokenProbeClient := codersdk.New(auditorClient.URL)
|
|
brokenProbeClient.SetSessionToken(auditorClient.SessionToken())
|
|
brokenProbeClient.HTTPClient = &http.Client{
|
|
Transport: &failPathTransport{path: "/api/v2/deployment/config"},
|
|
}
|
|
t.Cleanup(brokenProbeClient.HTTPClient.CloseIdleConnections)
|
|
brokenProbeDeps, err := toolsdk.NewDeps(brokenProbeClient)
|
|
require.NoError(t, err)
|
|
_, err = testTool(t, toolsdk.ListChatModelConfigs, brokenProbeDeps, toolsdk.NoArgs{})
|
|
require.ErrorContains(t, err, "verify deployment config access")
|
|
})
|
|
|
|
t.Run("CreateChatZeroOrgUser", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
orphanClient, orphan := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
|
require.NoError(t, client.DeleteOrganizationMember(ctx, firstUser.OrganizationID, orphan.ID.String()))
|
|
|
|
orphanDeps, err := toolsdk.NewDeps(orphanClient)
|
|
require.NoError(t, err)
|
|
_, err = testTool(t, toolsdk.CreateChat, orphanDeps, toolsdk.CreateChatArgs{Prompt: "hi"})
|
|
require.ErrorContains(t, err, "belongs to no organization")
|
|
})
|
|
|
|
t.Run("Validation", func(t *testing.T) {
|
|
_, err := testTool(t, toolsdk.CreateChat, tb, toolsdk.CreateChatArgs{})
|
|
require.ErrorContains(t, err, "prompt is required")
|
|
|
|
_, err = testTool(t, toolsdk.GetChat, tb, toolsdk.GetChatArgs{ChatID: "not-a-uuid"})
|
|
require.ErrorContains(t, err, "chat_id must be a valid UUID")
|
|
|
|
_, err = testTool(t, toolsdk.SendChatMessage, tb, toolsdk.SendChatMessageArgs{
|
|
ChatID: uuid.NewString(),
|
|
Text: "hi",
|
|
BusyBehavior: codersdk.ChatBusyBehavior("bogus"),
|
|
})
|
|
require.ErrorContains(t, err, "busy_behavior")
|
|
|
|
_, err = testTool(t, toolsdk.DownloadChatFile, tb, toolsdk.DownloadChatFileArgs{})
|
|
require.ErrorContains(t, err, "exactly one addressing mode")
|
|
|
|
_, err = testTool(t, toolsdk.AwaitChat, tb, toolsdk.AwaitChatArgs{ChatID: "not-a-uuid"})
|
|
require.ErrorContains(t, err, "chat_id must be a valid UUID")
|
|
|
|
listed, err := testTool(t, toolsdk.ListChats, tb, toolsdk.ListChatsArgs{Limit: 1})
|
|
require.NoError(t, err)
|
|
require.LessOrEqual(t, len(listed.Chats), 1)
|
|
|
|
_, err = testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{
|
|
ChatID: uuid.NewString(),
|
|
BeforeID: 1,
|
|
AfterID: 2,
|
|
})
|
|
require.ErrorContains(t, err, "before_id and after_id cannot be used together")
|
|
|
|
for _, limit := range []int{-1, 201} {
|
|
_, err = testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{
|
|
ChatID: uuid.NewString(),
|
|
Limit: limit,
|
|
})
|
|
require.ErrorContains(t, err, "limit must be between 1 and 200")
|
|
}
|
|
})
|
|
}
|