mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add Coder Agents chat tools to the MCP toolsdk (#28025)
Exposes the experimental Coder Agents chats API through the MCP tool registry, so MCP clients (the hosted `/api/experimental/mcp/http` server and `coder exp mcp server`) can start and drive server-side coding agents. New tools in `codersdk/toolsdk`, all thin wrappers over existing `codersdk.ExperimentalClient` methods: | Tool | Wraps | |---|---| | `coder_create_chat` | `CreateChat` (prompt, optional org, model config, labels) | | `coder_get_chat` | `GetChat` (status, last error, last turn summary, workspace, files) | | `coder_get_chat_messages` | `GetChatMessages` (user-facing parts, chronological, cursor pagination, queued prompts) | | `coder_send_chat_message` | `CreateChatMessage` (queue or interrupt busy behavior) | | `coder_interrupt_chat` | `InterruptChat` | | `coder_archive_chat` | `UpdateChat` with `archived: true` | | `coder_list_chat_model_configs` | `ListChatModelConfigs` (enabled configs with default flag) | Both MCP servers register tools from `toolsdk.All`, so no additional wiring is needed. Responses are trimmed to what an MCP caller needs (IDs as strings, user-facing transcripts) rather than full SDK payloads. No new endpoints and no database changes. Also adds MCP [prompts](https://modelcontextprotocol.io/specification/2026-07-28/server/prompts) for the chat workflows, defined once in `codersdk/toolsdk` and registered by both servers: | Prompt | Purpose | |---|---| | `coder_agents_delegate` | delegate a task to a Coder Agents chat and monitor it to completion | | `coder_agents_check` | check the status and recent activity of an existing chat | Each prompt declares the tools its workflow needs; the stdio server skips prompts whose tools are excluded by `--allowed-tools`. Tests run the tools against a chat-enabled coderdtest instance (fake OpenAI-compatible provider plus in-process AI bridge), covering the full lifecycle, an interrupt against a blocked turn, pagination cursors, permission-dependent model config filtering, and argument validation. Prompt coverage spans SDK rendering, the hosted `prompts/list`/`prompts/get` round trip, and the stdio server including allowlist gating. > Mux created this PR on Mike's behalf.
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
package toolsdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/aisdk-go"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
const chatIDDescription = "UUID of the chat."
|
||||
|
||||
func isForbiddenError(err error) bool {
|
||||
var sdkErr *codersdk.Error
|
||||
return errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusForbidden
|
||||
}
|
||||
|
||||
func parseChatID(chatID string) (uuid.UUID, error) {
|
||||
if chatID == "" {
|
||||
return uuid.Nil, xerrors.New("chat_id is required")
|
||||
}
|
||||
id, err := uuid.Parse(chatID)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.New("chat_id must be a valid UUID")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
type ChatToolFile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MimeType string `json:"mime_type"`
|
||||
}
|
||||
|
||||
type ChatToolStatus struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status codersdk.ChatStatus `json:"status"`
|
||||
Archived bool `json:"archived"`
|
||||
LastError *codersdk.ChatError `json:"last_error,omitempty"`
|
||||
LastTurnSummary string `json:"last_turn_summary,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Files []ChatToolFile `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
func chatToolStatus(deps Deps, chat codersdk.Chat) ChatToolStatus {
|
||||
resp := ChatToolStatus{
|
||||
ID: chat.ID.String(),
|
||||
Title: chat.Title,
|
||||
Status: chat.Status,
|
||||
Archived: chat.Archived,
|
||||
LastError: chat.LastError,
|
||||
URL: fmt.Sprintf("%s/agents/%s", deps.ServerURL(), chat.ID),
|
||||
}
|
||||
if chat.LastTurnSummary != nil {
|
||||
resp.LastTurnSummary = *chat.LastTurnSummary
|
||||
}
|
||||
if chat.WorkspaceID != nil {
|
||||
resp.WorkspaceID = chat.WorkspaceID.String()
|
||||
}
|
||||
for _, file := range chat.Files {
|
||||
resp.Files = append(resp.Files, ChatToolFile{
|
||||
ID: file.ID.String(),
|
||||
Name: file.Name,
|
||||
MimeType: file.MimeType,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
type CreateChatArgs struct {
|
||||
Prompt string `json:"prompt"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
ModelConfigID string `json:"model_config_id"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
}
|
||||
|
||||
var CreateChat = Tool[CreateChatArgs, ChatToolStatus]{
|
||||
Tool: aisdk.Tool{
|
||||
Name: ToolNameCreateChat,
|
||||
Description: `Start a Coder Agents chat: a server-side AI coding agent that works autonomously from a prompt.
|
||||
|
||||
The chat runs asynchronously. Poll coder_get_chat for status and read the transcript with coder_get_chat_messages.`,
|
||||
Schema: aisdk.Schema{
|
||||
Properties: map[string]any{
|
||||
"prompt": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Initial prompt for the agent.",
|
||||
},
|
||||
"organization_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional organization UUID. Defaults to the authenticated user's first organization.",
|
||||
},
|
||||
"model_config_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional chat model config UUID from coder_list_chat_model_configs. Defaults to the deployment default model.",
|
||||
},
|
||||
"labels": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Optional string key/value labels to attach to the chat.",
|
||||
"additionalProperties": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
Required: []string{"prompt"},
|
||||
},
|
||||
},
|
||||
MCPAnnotations: mcpMutationAnnotations,
|
||||
Handler: func(ctx context.Context, deps Deps, args CreateChatArgs) (ChatToolStatus, error) {
|
||||
if args.Prompt == "" {
|
||||
return ChatToolStatus{}, xerrors.New("prompt is required")
|
||||
}
|
||||
var orgID uuid.UUID
|
||||
if args.OrganizationID != "" {
|
||||
var err error
|
||||
orgID, err = uuid.Parse(args.OrganizationID)
|
||||
if err != nil {
|
||||
return ChatToolStatus{}, xerrors.New("organization_id must be a valid UUID")
|
||||
}
|
||||
} else {
|
||||
me, err := deps.coderClient.User(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
return ChatToolStatus{}, err
|
||||
}
|
||||
// Admins can remove a user's only organization membership.
|
||||
if len(me.OrganizationIDs) == 0 {
|
||||
return ChatToolStatus{}, xerrors.New("authenticated user belongs to no organization; pass organization_id explicitly")
|
||||
}
|
||||
orgID = me.OrganizationIDs[0]
|
||||
}
|
||||
var modelConfigID *uuid.UUID
|
||||
if args.ModelConfigID != "" {
|
||||
id, err := uuid.Parse(args.ModelConfigID)
|
||||
if err != nil {
|
||||
return ChatToolStatus{}, xerrors.New("model_config_id must be a valid UUID")
|
||||
}
|
||||
modelConfigID = &id
|
||||
}
|
||||
chat, err := codersdk.NewExperimentalClient(deps.coderClient).CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: orgID,
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: args.Prompt,
|
||||
}},
|
||||
ModelConfigID: modelConfigID,
|
||||
Labels: args.Labels,
|
||||
})
|
||||
if err != nil {
|
||||
return ChatToolStatus{}, xerrors.Errorf("create chat: %w", err)
|
||||
}
|
||||
return chatToolStatus(deps, chat), nil
|
||||
},
|
||||
}
|
||||
|
||||
type GetChatArgs struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
var GetChat = Tool[GetChatArgs, ChatToolStatus]{
|
||||
Tool: aisdk.Tool{
|
||||
Name: ToolNameGetChat,
|
||||
Description: `Get the status of a Coder Agents chat, including its last error, last turn summary, workspace, and attached files.`,
|
||||
Schema: aisdk.Schema{
|
||||
Properties: map[string]any{
|
||||
"chat_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": chatIDDescription,
|
||||
},
|
||||
},
|
||||
Required: []string{"chat_id"},
|
||||
},
|
||||
},
|
||||
MCPAnnotations: mcpReadOnlyAnnotations,
|
||||
Handler: func(ctx context.Context, deps Deps, args GetChatArgs) (ChatToolStatus, error) {
|
||||
chatID, err := parseChatID(args.ChatID)
|
||||
if err != nil {
|
||||
return ChatToolStatus{}, err
|
||||
}
|
||||
chat, err := codersdk.NewExperimentalClient(deps.coderClient).GetChat(ctx, chatID)
|
||||
if err != nil {
|
||||
return ChatToolStatus{}, xerrors.Errorf("get chat: %w", err)
|
||||
}
|
||||
return chatToolStatus(deps, chat), nil
|
||||
},
|
||||
}
|
||||
|
||||
type GetChatMessagesArgs struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Limit int `json:"limit"`
|
||||
BeforeID int64 `json:"before_id"`
|
||||
}
|
||||
|
||||
type ChatToolMessage struct {
|
||||
ID int64 `json:"id"`
|
||||
Role codersdk.ChatMessageRole `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type GetChatMessagesResponse struct {
|
||||
Messages []ChatToolMessage `json:"messages"`
|
||||
HasMore bool `json:"has_more"`
|
||||
// NextBeforeID is the cursor for the next older page when HasMore is
|
||||
// true. It is derived from the unfiltered API page, so it stays valid
|
||||
// even when every message in this page was filtered out as non-text.
|
||||
NextBeforeID int64 `json:"next_before_id,omitempty"`
|
||||
// QueuedMessages is populated only on the initial page.
|
||||
QueuedMessages []string `json:"queued_messages,omitempty"`
|
||||
}
|
||||
|
||||
// Hook notices are user-facing per the SDK contract; hook context is model-only.
|
||||
func userFacingText(parts []codersdk.ChatMessagePart) string {
|
||||
var texts []string
|
||||
for _, part := range parts {
|
||||
isUserFacingText := part.Type == codersdk.ChatMessagePartTypeText ||
|
||||
part.Type == codersdk.ChatMessagePartTypeHookNotice
|
||||
if isUserFacingText && part.Text != "" {
|
||||
texts = append(texts, part.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
}
|
||||
|
||||
var GetChatMessages = Tool[GetChatMessagesArgs, GetChatMessagesResponse]{
|
||||
Tool: aisdk.Tool{
|
||||
Name: ToolNameGetChatMessages,
|
||||
Description: `Get the newest messages of a Coder Agents chat in chronological order.
|
||||
|
||||
Only user-facing text content is returned (including lifecycle hook notices); tool calls and other internal parts are omitted. Prompts still queued behind a busy chat appear in queued_messages. When has_more is true, pass next_before_id as before_id to page through older messages.`,
|
||||
Schema: aisdk.Schema{
|
||||
Properties: map[string]any{
|
||||
"chat_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": chatIDDescription,
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages to fetch, from newest to oldest (1-200, default 50).",
|
||||
},
|
||||
"before_id": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Only fetch messages with an id lower than this cursor. Omit to fetch the newest messages.",
|
||||
},
|
||||
},
|
||||
Required: []string{"chat_id"},
|
||||
},
|
||||
},
|
||||
MCPAnnotations: mcpReadOnlyAnnotations,
|
||||
Handler: func(ctx context.Context, deps Deps, args GetChatMessagesArgs) (GetChatMessagesResponse, error) {
|
||||
chatID, err := parseChatID(args.ChatID)
|
||||
if err != nil {
|
||||
return GetChatMessagesResponse{}, err
|
||||
}
|
||||
if args.Limit < 0 || args.Limit > 200 {
|
||||
return GetChatMessagesResponse{}, xerrors.New("limit must be between 1 and 200")
|
||||
}
|
||||
if args.BeforeID < 0 {
|
||||
return GetChatMessagesResponse{}, xerrors.New("before_id must be a positive message id")
|
||||
}
|
||||
var opts *codersdk.ChatMessagesPaginationOptions
|
||||
if args.Limit > 0 || args.BeforeID > 0 {
|
||||
opts = &codersdk.ChatMessagesPaginationOptions{
|
||||
Limit: args.Limit,
|
||||
BeforeID: args.BeforeID,
|
||||
}
|
||||
}
|
||||
resp, err := codersdk.NewExperimentalClient(deps.coderClient).GetChatMessages(ctx, chatID, opts)
|
||||
if err != nil {
|
||||
return GetChatMessagesResponse{}, xerrors.Errorf("get chat messages: %w", err)
|
||||
}
|
||||
// The API returns messages newest first; reverse into
|
||||
// chronological order so the transcript reads naturally.
|
||||
messages := make([]ChatToolMessage, 0, len(resp.Messages))
|
||||
for i := len(resp.Messages) - 1; i >= 0; i-- {
|
||||
msg := resp.Messages[i]
|
||||
text := userFacingText(msg.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, ChatToolMessage{
|
||||
ID: msg.ID,
|
||||
Role: msg.Role,
|
||||
CreatedAt: msg.CreatedAt,
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
var queued []string
|
||||
for _, msg := range resp.QueuedMessages {
|
||||
if text := userFacingText(msg.Content); text != "" {
|
||||
queued = append(queued, text)
|
||||
}
|
||||
}
|
||||
var nextBeforeID int64
|
||||
if resp.HasMore && len(resp.Messages) > 0 {
|
||||
nextBeforeID = resp.Messages[0].ID
|
||||
for _, msg := range resp.Messages {
|
||||
if msg.ID < nextBeforeID {
|
||||
nextBeforeID = msg.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return GetChatMessagesResponse{
|
||||
Messages: messages,
|
||||
HasMore: resp.HasMore,
|
||||
NextBeforeID: nextBeforeID,
|
||||
QueuedMessages: queued,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
type SendChatMessageArgs struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
BusyBehavior codersdk.ChatBusyBehavior `json:"busy_behavior"`
|
||||
}
|
||||
|
||||
type SendChatMessageResponse struct {
|
||||
Queued bool `json:"queued"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
var SendChatMessage = Tool[SendChatMessageArgs, SendChatMessageResponse]{
|
||||
Tool: aisdk.Tool{
|
||||
Name: ToolNameSendChatMessage,
|
||||
Description: `Send a message to a Coder Agents chat.`,
|
||||
Schema: aisdk.Schema{
|
||||
Properties: map[string]any{
|
||||
"chat_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": chatIDDescription,
|
||||
},
|
||||
"text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The message to send.",
|
||||
},
|
||||
"busy_behavior": map[string]any{
|
||||
"type": "string",
|
||||
"description": "What to do when the chat is already processing: \"queue\" (default) processes the message after the current run, \"interrupt\" stops the current run first.",
|
||||
"enum": []string{
|
||||
string(codersdk.ChatBusyBehaviorQueue),
|
||||
string(codersdk.ChatBusyBehaviorInterrupt),
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"chat_id", "text"},
|
||||
},
|
||||
},
|
||||
MCPAnnotations: mcpMutationAnnotations,
|
||||
Handler: func(ctx context.Context, deps Deps, args SendChatMessageArgs) (SendChatMessageResponse, error) {
|
||||
chatID, err := parseChatID(args.ChatID)
|
||||
if err != nil {
|
||||
return SendChatMessageResponse{}, err
|
||||
}
|
||||
if args.Text == "" {
|
||||
return SendChatMessageResponse{}, xerrors.New("text is required")
|
||||
}
|
||||
busyBehavior := args.BusyBehavior
|
||||
switch busyBehavior {
|
||||
case "":
|
||||
busyBehavior = codersdk.ChatBusyBehaviorQueue
|
||||
case codersdk.ChatBusyBehaviorQueue, codersdk.ChatBusyBehaviorInterrupt:
|
||||
default:
|
||||
return SendChatMessageResponse{}, xerrors.New(`busy_behavior must be "queue" or "interrupt"`)
|
||||
}
|
||||
resp, err := codersdk.NewExperimentalClient(deps.coderClient).CreateChatMessage(ctx, chatID, codersdk.CreateChatMessageRequest{
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: args.Text,
|
||||
}},
|
||||
BusyBehavior: busyBehavior,
|
||||
})
|
||||
if err != nil {
|
||||
return SendChatMessageResponse{}, xerrors.Errorf("send chat message: %w", err)
|
||||
}
|
||||
return SendChatMessageResponse{
|
||||
Queued: resp.Queued,
|
||||
Warnings: resp.Warnings,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
type InterruptChatArgs struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
var InterruptChat = Tool[InterruptChatArgs, ChatToolStatus]{
|
||||
Tool: aisdk.Tool{
|
||||
Name: ToolNameInterruptChat,
|
||||
Description: `Interrupt a running Coder Agents chat. Progress so far is preserved.`,
|
||||
Schema: aisdk.Schema{
|
||||
Properties: map[string]any{
|
||||
"chat_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": chatIDDescription,
|
||||
},
|
||||
},
|
||||
Required: []string{"chat_id"},
|
||||
},
|
||||
},
|
||||
MCPAnnotations: mcpMutationAnnotations,
|
||||
Handler: func(ctx context.Context, deps Deps, args InterruptChatArgs) (ChatToolStatus, error) {
|
||||
chatID, err := parseChatID(args.ChatID)
|
||||
if err != nil {
|
||||
return ChatToolStatus{}, err
|
||||
}
|
||||
chat, err := codersdk.NewExperimentalClient(deps.coderClient).InterruptChat(ctx, chatID)
|
||||
if err != nil {
|
||||
return ChatToolStatus{}, xerrors.Errorf("interrupt chat: %w", err)
|
||||
}
|
||||
return chatToolStatus(deps, chat), nil
|
||||
},
|
||||
}
|
||||
|
||||
type ArchiveChatArgs struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
var ArchiveChat = Tool[ArchiveChatArgs, codersdk.Response]{
|
||||
Tool: aisdk.Tool{
|
||||
Name: ToolNameArchiveChat,
|
||||
Description: `Archive a Coder Agents chat. The chat is hidden from default listings but can be unarchived from the UI.`,
|
||||
Schema: aisdk.Schema{
|
||||
Properties: map[string]any{
|
||||
"chat_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": chatIDDescription,
|
||||
},
|
||||
},
|
||||
Required: []string{"chat_id"},
|
||||
},
|
||||
},
|
||||
MCPAnnotations: mcpMutationAnnotations,
|
||||
Handler: func(ctx context.Context, deps Deps, args ArchiveChatArgs) (codersdk.Response, error) {
|
||||
chatID, err := parseChatID(args.ChatID)
|
||||
if err != nil {
|
||||
return codersdk.Response{}, err
|
||||
}
|
||||
archived := true
|
||||
err = codersdk.NewExperimentalClient(deps.coderClient).UpdateChat(ctx, chatID, codersdk.UpdateChatRequest{
|
||||
Archived: &archived,
|
||||
})
|
||||
if err != nil {
|
||||
return codersdk.Response{}, xerrors.Errorf("archive chat: %w", err)
|
||||
}
|
||||
return codersdk.Response{
|
||||
Message: "Chat archived successfully.",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
type ChatModelConfigSummary struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
type ListChatModelConfigsResponse struct {
|
||||
ModelConfigs []ChatModelConfigSummary `json:"model_configs"`
|
||||
}
|
||||
|
||||
var ListChatModelConfigs = Tool[NoArgs, ListChatModelConfigsResponse]{
|
||||
Tool: aisdk.Tool{
|
||||
Name: ToolNameListChatModelConfigs,
|
||||
Description: `List the enabled chat models available for Coder Agents chats. Use a model config ID with coder_create_chat to pick a model.
|
||||
|
||||
Per-user provider credentials are validated when creating a chat, so coder_create_chat can still reject a listed model with an explanatory error.`,
|
||||
Schema: aisdk.Schema{
|
||||
Properties: map[string]any{},
|
||||
Required: []string{},
|
||||
},
|
||||
},
|
||||
MCPAnnotations: mcpReadOnlyAnnotations,
|
||||
Handler: func(ctx context.Context, deps Deps, _ NoArgs) (ListChatModelConfigsResponse, error) {
|
||||
configs, err := codersdk.NewExperimentalClient(deps.coderClient).ListChatModelConfigs(ctx)
|
||||
if err != nil {
|
||||
return ListChatModelConfigsResponse{}, xerrors.Errorf("list chat model configs: %w", err)
|
||||
}
|
||||
// Admin model lists include disabled providers; non-admin lists are
|
||||
// already filtered server-side.
|
||||
var providerEnabled map[uuid.UUID]bool
|
||||
providers, err := deps.coderClient.AIProviders(ctx)
|
||||
switch {
|
||||
case err == nil:
|
||||
providerEnabled = make(map[uuid.UUID]bool, len(providers))
|
||||
for _, provider := range providers {
|
||||
providerEnabled[provider.ID] = provider.Enabled
|
||||
}
|
||||
case isForbiddenError(err):
|
||||
// Deployment-config readers can receive the unfiltered admin list
|
||||
// without provider access, so fail closed unless both requests return 403.
|
||||
_, dcErr := deps.coderClient.DeploymentConfig(ctx)
|
||||
switch {
|
||||
case dcErr == nil:
|
||||
return ListChatModelConfigsResponse{}, xerrors.New("cannot verify provider availability for the admin model config list: missing AI provider read permission")
|
||||
case !isForbiddenError(dcErr):
|
||||
return ListChatModelConfigsResponse{}, xerrors.Errorf("verify deployment config access: %w", dcErr)
|
||||
}
|
||||
default:
|
||||
return ListChatModelConfigsResponse{}, xerrors.Errorf("list AI providers: %w", err)
|
||||
}
|
||||
summaries := make([]ChatModelConfigSummary, 0, len(configs))
|
||||
for _, config := range configs {
|
||||
if !config.Enabled {
|
||||
continue
|
||||
}
|
||||
// A non-nil map is authoritative because soft-deleted providers are
|
||||
// absent while their configs remain in the admin response.
|
||||
if providerEnabled != nil && !providerEnabled[config.AIProviderID] {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, ChatModelConfigSummary{
|
||||
ID: config.ID.String(),
|
||||
Model: config.Model,
|
||||
DisplayName: config.DisplayName,
|
||||
IsDefault: config.IsDefault,
|
||||
})
|
||||
}
|
||||
return ListChatModelConfigsResponse{ModelConfigs: summaries}, nil
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package toolsdk_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"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/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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
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("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)
|
||||
transcript, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ChatID: created.ID})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, transcript.QueuedMessages, "Queued while busy.")
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package toolsdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
PromptNameAgentsDelegate = "coder_agents_delegate"
|
||||
PromptNameAgentsCheck = "coder_agents_check"
|
||||
)
|
||||
|
||||
// PromptArgument describes one argument accepted by a Prompt.
|
||||
type PromptArgument struct {
|
||||
Name string
|
||||
Description string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// Prompt defines an MCP prompt shared by the HTTP and CLI servers.
|
||||
// See https://modelcontextprotocol.io/specification/2026-07-28/server/prompts.
|
||||
type Prompt struct {
|
||||
Name string
|
||||
Description string
|
||||
Arguments []PromptArgument
|
||||
|
||||
// RequiredTools lists the tools the rendered workflow cannot run
|
||||
// without; optional suggestions are excluded. Servers with a
|
||||
// restricted tool set should skip prompts whose required tools are
|
||||
// unavailable.
|
||||
RequiredTools []string
|
||||
|
||||
Render func(args map[string]string) (string, error)
|
||||
}
|
||||
|
||||
// AllPrompts is the canonical list of MCP prompts exposed by Coder MCP
|
||||
// servers.
|
||||
var AllPrompts = []Prompt{AgentsDelegate, AgentsCheck}
|
||||
|
||||
var AgentsDelegate = Prompt{
|
||||
Name: PromptNameAgentsDelegate,
|
||||
Description: "Delegate a coding task to a Coder Agents chat and monitor it to completion.",
|
||||
RequiredTools: []string{
|
||||
ToolNameCreateChat,
|
||||
ToolNameGetChat,
|
||||
ToolNameGetChatMessages,
|
||||
ToolNameSendChatMessage,
|
||||
},
|
||||
Arguments: []PromptArgument{
|
||||
{
|
||||
Name: "task",
|
||||
Description: "The task the Coder Agent should perform, including all context it needs.",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "model_config_id",
|
||||
Description: "Optional model config UUID for the chat. When omitted, a model is picked from " + ToolNameListChatModelConfigs + ".",
|
||||
},
|
||||
},
|
||||
Render: func(args map[string]string) (string, error) {
|
||||
task, err := requiredPromptArg(args, "task")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var createStep string
|
||||
if modelConfigID := strings.TrimSpace(args["model_config_id"]); modelConfigID != "" {
|
||||
createStep = fmt.Sprintf("1. Call %s with the task above as the prompt and model_config_id %q.", ToolNameCreateChat, modelConfigID)
|
||||
} else {
|
||||
createStep = fmt.Sprintf("1. Call %s with the task above as the prompt. To pick a specific model, call %s first and pass its ID as model_config_id.", ToolNameCreateChat, ToolNameListChatModelConfigs)
|
||||
}
|
||||
return fmt.Sprintf(`Delegate the following task to a Coder Agent and see it through to completion.
|
||||
|
||||
<task>
|
||||
%s
|
||||
</task>
|
||||
|
||||
Follow these steps:
|
||||
%s
|
||||
2. Share the returned chat URL with the user right away so they can follow along.
|
||||
3. Poll %s until the chat stops running, waiting between polls.
|
||||
4. Read the transcript with %s; page older history with before_id while has_more is true.
|
||||
5. If the agent needs input or the result needs iteration, reply with %s and keep monitoring.
|
||||
6. Report the outcome to the user, including the chat URL and a summary of what the agent did.
|
||||
`, task, createStep, ToolNameGetChat, ToolNameGetChatMessages, ToolNameSendChatMessage), nil
|
||||
},
|
||||
}
|
||||
|
||||
var AgentsCheck = Prompt{
|
||||
Name: PromptNameAgentsCheck,
|
||||
Description: "Check the status and recent activity of an existing Coder Agents chat.",
|
||||
RequiredTools: []string{
|
||||
ToolNameGetChat,
|
||||
ToolNameGetChatMessages,
|
||||
},
|
||||
Arguments: []PromptArgument{
|
||||
{
|
||||
Name: "chat_id",
|
||||
Description: "UUID of the Coder Agents chat to check.",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Render: func(args map[string]string) (string, error) {
|
||||
chatID, err := requiredPromptArg(args, "chat_id")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf(`Check on the Coder Agents chat %q and report back.
|
||||
|
||||
Follow these steps:
|
||||
1. Call %s with the chat_id to get its status, last turn summary, and any last error.
|
||||
2. Call %s with the chat_id for recent transcript context, including queued_messages.
|
||||
3. Summarize for the user: what the agent is doing or has done, whether it is blocked or waiting for input, and any errors. Include the chat URL.
|
||||
`, chatID, ToolNameGetChat, ToolNameGetChatMessages), nil
|
||||
},
|
||||
}
|
||||
|
||||
func requiredPromptArg(args map[string]string, name string) (string, error) {
|
||||
value := strings.TrimSpace(args[name])
|
||||
if value == "" {
|
||||
return "", xerrors.Errorf("missing required prompt argument: %s", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package toolsdk_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/toolsdk"
|
||||
)
|
||||
|
||||
func TestChatPrompts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Metadata", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
names := map[string]bool{}
|
||||
for _, prompt := range toolsdk.AllPrompts {
|
||||
require.NotEmpty(t, prompt.Name)
|
||||
require.NotEmpty(t, prompt.Description)
|
||||
require.NotNil(t, prompt.Render)
|
||||
require.NotEmpty(t, prompt.RequiredTools)
|
||||
toolNames := make(map[string]bool, len(toolsdk.All))
|
||||
for _, tool := range toolsdk.All {
|
||||
toolNames[tool.Name] = true
|
||||
}
|
||||
for _, name := range prompt.RequiredTools {
|
||||
require.True(t, toolNames[name], "prompt %q requires unknown tool %q", prompt.Name, name)
|
||||
}
|
||||
require.False(t, names[prompt.Name], "duplicate prompt name %q", prompt.Name)
|
||||
names[prompt.Name] = true
|
||||
for _, arg := range prompt.Arguments {
|
||||
require.NotEmpty(t, arg.Name)
|
||||
require.NotEmpty(t, arg.Description)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DelegateRequiresTask", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := toolsdk.AgentsDelegate.Render(nil)
|
||||
require.ErrorContains(t, err, "missing required prompt argument: task")
|
||||
_, err = toolsdk.AgentsDelegate.Render(map[string]string{"task": " "})
|
||||
require.ErrorContains(t, err, "missing required prompt argument: task")
|
||||
})
|
||||
|
||||
t.Run("Delegate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
text, err := toolsdk.AgentsDelegate.Render(map[string]string{"task": "Fix the flaky test."})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, text, "Fix the flaky test.")
|
||||
for _, tool := range toolsdk.AgentsDelegate.RequiredTools {
|
||||
require.Contains(t, text, tool)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DelegateWithModelConfig", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
text, err := toolsdk.AgentsDelegate.Render(map[string]string{
|
||||
"task": "Fix the flaky test.",
|
||||
"model_config_id": "a2913789-b213-45e3-9d18-561fbb1ec97c",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, text, "a2913789-b213-45e3-9d18-561fbb1ec97c")
|
||||
require.NotContains(t, text, toolsdk.ToolNameListChatModelConfigs)
|
||||
})
|
||||
|
||||
t.Run("CheckRequiresChatID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := toolsdk.AgentsCheck.Render(map[string]string{})
|
||||
require.ErrorContains(t, err, "missing required prompt argument: chat_id")
|
||||
})
|
||||
|
||||
t.Run("Check", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
text, err := toolsdk.AgentsCheck.Render(map[string]string{"chat_id": "0bb52d1a-e239-4e7a-ae2a-5abbd7fbf9b5"})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, text, "0bb52d1a-e239-4e7a-ae2a-5abbd7fbf9b5")
|
||||
for _, tool := range toolsdk.AgentsCheck.RequiredTools {
|
||||
require.Contains(t, text, tool)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -58,6 +58,13 @@ const (
|
||||
ToolNameGetTaskStatus = "coder_get_task_status"
|
||||
ToolNameSendTaskInput = "coder_send_task_input"
|
||||
ToolNameGetTaskLogs = "coder_get_task_logs"
|
||||
ToolNameCreateChat = "coder_create_chat"
|
||||
ToolNameGetChat = "coder_get_chat"
|
||||
ToolNameGetChatMessages = "coder_get_chat_messages"
|
||||
ToolNameSendChatMessage = "coder_send_chat_message"
|
||||
ToolNameInterruptChat = "coder_interrupt_chat"
|
||||
ToolNameArchiveChat = "coder_archive_chat"
|
||||
ToolNameListChatModelConfigs = "coder_list_chat_model_configs"
|
||||
)
|
||||
|
||||
func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) {
|
||||
@@ -338,6 +345,13 @@ var All = []GenericTool{
|
||||
GetTaskStatus.Generic(),
|
||||
SendTaskInput.Generic(),
|
||||
GetTaskLogs.Generic(),
|
||||
CreateChat.Generic(),
|
||||
GetChat.Generic(),
|
||||
GetChatMessages.Generic(),
|
||||
SendChatMessage.Generic(),
|
||||
InterruptChat.Generic(),
|
||||
ArchiveChat.Generic(),
|
||||
ListChatModelConfigs.Generic(),
|
||||
}
|
||||
|
||||
type ReportTaskArgs struct {
|
||||
|
||||
Reference in New Issue
Block a user