Files
coder/coderd/x/chatd/attachments_test.go
Ethan ef6969dd70 feat(coderd/x/chatd): agent-created file attachments in chat (#24280)
Agents can already see workspace files and take screenshots, but users could not download those artifacts from chat. This PR adds durable chat attachments to chatd. `attach_file`, explicit `computer` screenshot actions (not the automatic post-action screenshots), and `propose_plan` now fetch bytes over the agent connection, store them in `chat_files`, link them to the chat, and carry attachment metadata in tool responses so `buildAssistantPartsForPersist` can materialize ordinary `type:"file"` assistant parts that the chat file APIs serve.

The same storage helpers are reused for other artifact-producing paths. `wait_agent` recordings and thumbnails are stored as chat files and linked back to the parent chat, with best-effort relinking so parent chats retain those artifacts without leaving orphaned rows when chat-file caps reject links. `storeChatAttachment` wraps insert + link in one transaction, files are capped at 10 MB each and 20 per chat, and serving defaults to `Content-Disposition: attachment` with an explicit inline-safe allowlist.

This PR also consolidates chat-file media policy in `coderd/chatfiles`. Uploads and tool-generated attachments share byte-based MIME detection, SVG blocking, inline-safety rules, and compatible `text/plain` refinement for JSON, CSV, and Markdown. Prompt construction still only inlines synthetic pasted text for model consumption; assistant-created attachments are persisted for the user and intentionally not replayed into later LLM turns.

UI follow-up lives in #24281.

Relates to CODAGT-91
2026-04-20 18:04:35 +10:00

137 lines
4.0 KiB
Go

package chatd //nolint:testpackage
import (
"context"
"testing"
"time"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestBuildAssistantPartsForPersist_PromotesToolAttachments(t *testing.T) {
t.Parallel()
fileID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
response := chattool.WithAttachments(
fantasy.NewTextResponse(`{"ok":true}`),
chattool.AttachmentMetadata{
FileID: fileID,
MediaType: "image/png",
Name: "screenshot.png",
},
)
toolCallAt := time.Date(2026, time.April, 10, 0, 0, 0, 0, time.UTC)
parts := buildAssistantPartsForPersist(
context.Background(),
testutil.Logger(t),
[]fantasy.Content{fantasy.TextContent{Text: "Here is the screenshot."}},
[]fantasy.ToolResultContent{{
ToolCallID: "call-1",
ToolName: "computer",
ClientMetadata: response.Metadata,
ProviderExecuted: false,
}},
chatloop.PersistedStep{
ToolCallCreatedAt: map[string]time.Time{
"call-1": toolCallAt,
},
},
nil,
)
require.Len(t, parts, 2)
require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type)
require.Equal(t, "Here is the screenshot.", parts[0].Text)
require.Equal(t, codersdk.ChatMessagePartTypeFile, parts[1].Type)
require.True(t, parts[1].FileID.Valid)
require.Equal(t, fileID, parts[1].FileID.UUID)
require.Equal(t, "image/png", parts[1].MediaType)
require.Equal(t, "screenshot.png", parts[1].Name)
}
func TestBuildAssistantPartsForPersist_PromotesProposePlanAttachment(t *testing.T) {
t.Parallel()
fileID := uuid.MustParse("bbbbbbbb-cccc-dddd-eeee-ffffffffffff")
response := chattool.WithAttachments(
fantasy.NewTextResponse(`{"ok":true,"kind":"plan"}`),
chattool.AttachmentMetadata{
FileID: fileID,
MediaType: "text/markdown",
Name: "PLAN.md",
},
)
parts := buildAssistantPartsForPersist(
context.Background(),
testutil.Logger(t),
[]fantasy.Content{fantasy.TextContent{Text: "Here is the proposed plan."}},
[]fantasy.ToolResultContent{{
ToolCallID: "call-plan",
ToolName: "propose_plan",
ClientMetadata: response.Metadata,
}},
chatloop.PersistedStep{},
nil,
)
require.Len(t, parts, 2)
require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type)
require.Equal(t, "Here is the proposed plan.", parts[0].Text)
require.Equal(t, codersdk.ChatMessagePartTypeFile, parts[1].Type)
require.True(t, parts[1].FileID.Valid)
require.Equal(t, fileID, parts[1].FileID.UUID)
require.Equal(t, "text/markdown", parts[1].MediaType)
require.Equal(t, "PLAN.md", parts[1].Name)
}
func TestBuildAssistantPartsForPersist_InvalidAttachmentMetadataSkipsOnlyBrokenResult(t *testing.T) {
t.Parallel()
goodFileID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
goodResponse := chattool.WithAttachments(
fantasy.NewTextResponse(`{"ok":true}`),
chattool.AttachmentMetadata{
FileID: goodFileID,
MediaType: "image/png",
Name: "good.png",
},
)
parts := buildAssistantPartsForPersist(
context.Background(),
testutil.Logger(t),
[]fantasy.Content{fantasy.TextContent{Text: "Here are the results."}},
[]fantasy.ToolResultContent{
{
ToolCallID: "call-good",
ToolName: "computer",
ClientMetadata: goodResponse.Metadata,
},
{
ToolCallID: "call-bad",
ToolName: "attach_file",
ClientMetadata: `{"attachments":[{"file_id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}]}`,
},
},
chatloop.PersistedStep{},
nil,
)
require.Len(t, parts, 2)
require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type)
require.Equal(t, codersdk.ChatMessagePartTypeFile, parts[1].Type)
require.True(t, parts[1].FileID.Valid)
require.Equal(t, goodFileID, parts[1].FileID.UUID)
require.Equal(t, "image/png", parts[1].MediaType)
require.Equal(t, "good.png", parts[1].Name)
}