mirror of
https://github.com/coder/coder.git
synced 2026-06-03 21:18:24 +00:00
ef6969dd70
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
137 lines
4.0 KiB
Go
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)
|
|
}
|