Skip to content

Commit dcd9483

Browse files
committed
fix(daemon): seed Claude image attachments
1 parent 2a48ffa commit dcd9483

12 files changed

Lines changed: 407 additions & 28 deletions

File tree

server/internal/daemon/client.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,60 @@ func (c *Client) postJSONViaWithRetry(ctx context.Context, httpClient *http.Clie
670670
}
671671
}
672672

673+
type AttachmentResponse struct {
674+
ID string `json:"id"`
675+
DownloadURL string `json:"download_url"`
676+
Filename string `json:"filename"`
677+
ContentType string `json:"content_type"`
678+
SizeBytes int64 `json:"size_bytes"`
679+
}
680+
681+
func (c *Client) DownloadAttachment(ctx context.Context, attachmentID string) (*AttachmentResponse, []byte, error) {
682+
var att AttachmentResponse
683+
if err := c.getJSON(ctx, fmt.Sprintf("/api/attachments/%s", attachmentID), &att); err != nil {
684+
return nil, nil, err
685+
}
686+
if att.DownloadURL == "" {
687+
return nil, nil, fmt.Errorf("attachment %s has no download_url", attachmentID)
688+
}
689+
data, err := c.downloadFile(ctx, att.DownloadURL)
690+
if err != nil {
691+
return nil, nil, err
692+
}
693+
return &att, data, nil
694+
}
695+
696+
func (c *Client) downloadFile(ctx context.Context, downloadURL string) ([]byte, error) {
697+
isRelative := !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://")
698+
if isRelative {
699+
if c.baseURL == "" {
700+
return nil, fmt.Errorf("download URL %q is relative but client has no base URL", downloadURL)
701+
}
702+
downloadURL = c.baseURL + downloadURL
703+
}
704+
705+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
706+
if err != nil {
707+
return nil, err
708+
}
709+
if isRelative && c.token != "" {
710+
req.Header.Set("Authorization", "Bearer "+c.token)
711+
c.setIdentityHeaders(req)
712+
}
713+
714+
resp, err := c.client.Do(req)
715+
if err != nil {
716+
return nil, err
717+
}
718+
defer resp.Body.Close()
719+
720+
if resp.StatusCode >= 400 {
721+
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
722+
return nil, &requestError{Method: http.MethodGet, Path: downloadURL, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))}
723+
}
724+
return io.ReadAll(io.LimitReader(resp.Body, 100*1024*1024+1))
725+
}
726+
673727
func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBody any) error {
674728
return c.postJSONVia(ctx, c.client, path, reqBody, respBody)
675729
}

server/internal/daemon/client_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,74 @@ func TestDefaultTerminalRetrySchedule_MatchesAgreedPlan(t *testing.T) {
255255
}
256256
}
257257

258+
func TestClient_DownloadAttachmentDownloadsRelativeURLWithAuthHeaders(t *testing.T) {
259+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
260+
switch r.URL.Path {
261+
case "/api/attachments/att-1":
262+
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
263+
t.Errorf("metadata request Authorization = %q, want Bearer tok", got)
264+
}
265+
w.Header().Set("Content-Type", "application/json")
266+
json.NewEncoder(w).Encode(map[string]any{
267+
"id": "att-1",
268+
"download_url": "/download/att-1",
269+
"filename": "shot.png",
270+
"content_type": "image/png",
271+
})
272+
case "/download/att-1":
273+
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
274+
t.Errorf("download request Authorization = %q, want Bearer tok", got)
275+
}
276+
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
277+
t.Errorf("download request X-Client-Platform = %q, want daemon", got)
278+
}
279+
w.Write([]byte("png-bytes"))
280+
default:
281+
t.Errorf("unexpected path: %s", r.URL.Path)
282+
http.NotFound(w, r)
283+
}
284+
}))
285+
defer srv.Close()
286+
287+
c := NewClient(srv.URL)
288+
c.SetToken("tok")
289+
290+
meta, data, err := c.DownloadAttachment(context.Background(), "att-1")
291+
if err != nil {
292+
t.Fatalf("DownloadAttachment: %v", err)
293+
}
294+
if meta.Filename != "shot.png" || meta.ContentType != "image/png" {
295+
t.Fatalf("unexpected attachment metadata: %+v", meta)
296+
}
297+
if string(data) != "png-bytes" {
298+
t.Fatalf("downloaded data = %q, want png-bytes", string(data))
299+
}
300+
}
301+
302+
func TestClient_DownloadFileAbsoluteURLOmitsAuthHeaders(t *testing.T) {
303+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
304+
if got := r.Header.Get("Authorization"); got != "" {
305+
t.Errorf("absolute download Authorization = %q, want empty", got)
306+
}
307+
if got := r.Header.Get("X-Client-Platform"); got != "" {
308+
t.Errorf("absolute download X-Client-Platform = %q, want empty", got)
309+
}
310+
w.Write([]byte("image-data"))
311+
}))
312+
defer srv.Close()
313+
314+
c := NewClient("https://api.example.test")
315+
c.SetToken("tok")
316+
317+
data, err := c.downloadFile(context.Background(), srv.URL+"/signed")
318+
if err != nil {
319+
t.Fatalf("downloadFile: %v", err)
320+
}
321+
if string(data) != "image-data" {
322+
t.Fatalf("downloaded data = %q, want image-data", string(data))
323+
}
324+
}
325+
258326
func TestNormalizeGOOS(t *testing.T) {
259327
cases := map[string]string{
260328
"darwin": "macos",

server/internal/daemon/daemon.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3262,6 +3262,56 @@ func providerNeedsInlineSystemPrompt(provider string) bool {
32623262
}
32633263
}
32643264

3265+
const (
3266+
maxClaudeInitialImages = 4
3267+
maxClaudeInitialImageBytes = 10 * 1024 * 1024
3268+
)
3269+
3270+
func (d *Daemon) claudeInitialImages(ctx context.Context, task Task, taskLog *slog.Logger) []agent.InputImage {
3271+
attachments := make([]AttachmentMeta, 0, len(task.IssueAttachments)+len(task.ChatMessageAttachments))
3272+
attachments = append(attachments, task.IssueAttachments...)
3273+
attachments = append(attachments, task.ChatMessageAttachments...)
3274+
if len(attachments) == 0 {
3275+
return nil
3276+
}
3277+
3278+
images := make([]agent.InputImage, 0, min(len(attachments), maxClaudeInitialImages))
3279+
for _, attachment := range attachments {
3280+
if len(images) >= maxClaudeInitialImages {
3281+
break
3282+
}
3283+
if attachment.ID == "" || !strings.HasPrefix(strings.ToLower(attachment.ContentType), "image/") {
3284+
continue
3285+
}
3286+
meta, data, err := d.client.DownloadAttachment(ctx, attachment.ID)
3287+
if err != nil {
3288+
taskLog.Warn("claude initial image download failed", "attachment_id", attachment.ID, "error", err)
3289+
continue
3290+
}
3291+
if len(data) == 0 || len(data) > maxClaudeInitialImageBytes {
3292+
taskLog.Warn("claude initial image skipped due to size", "attachment_id", attachment.ID, "bytes", len(data))
3293+
continue
3294+
}
3295+
mediaType := attachment.ContentType
3296+
if meta != nil && meta.ContentType != "" {
3297+
mediaType = meta.ContentType
3298+
}
3299+
if !strings.HasPrefix(strings.ToLower(mediaType), "image/") {
3300+
continue
3301+
}
3302+
filename := attachment.Filename
3303+
if meta != nil && meta.Filename != "" {
3304+
filename = meta.Filename
3305+
}
3306+
images = append(images, agent.InputImage{
3307+
Filename: filename,
3308+
MediaType: mediaType,
3309+
Data: data,
3310+
})
3311+
}
3312+
return images
3313+
}
3314+
32653315
// gateResumeToReusedWorkdir clears the task's prior session unless the task
32663316
// runs in the exact workdir the session was recorded against, and reports
32673317
// whether that workdir was reused. CLI backends key their session stores to
@@ -3922,6 +3972,9 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
39223972
ThinkingLevel: thinkingLevel,
39233973
OpenclawMode: openclawMode,
39243974
}
3975+
if provider == "claude" {
3976+
execOpts.InitialImages = d.claudeInitialImages(ctx, task, taskLog)
3977+
}
39253978
// Some providers do not reliably load the per-task runtime config files we
39263979
// write into the task workdir:
39273980
// - openclaw is pinned to the task workdir via the per-task config we

server/internal/daemon/daemon_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,44 @@ func TestWatchTaskCancellation_RunningTaskNotInterrupted(t *testing.T) {
903903
}
904904
}
905905

906+
func TestClaudeInitialImagesDownloadsImageAttachments(t *testing.T) {
907+
t.Parallel()
908+
909+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
910+
switch r.URL.Path {
911+
case "/api/attachments/img-1":
912+
w.Header().Set("Content-Type", "application/json")
913+
json.NewEncoder(w).Encode(map[string]any{
914+
"id": "img-1",
915+
"download_url": "/files/img-1",
916+
"filename": "server-shot.png",
917+
"content_type": "image/png",
918+
})
919+
case "/files/img-1":
920+
w.Write([]byte("image-bytes"))
921+
default:
922+
http.NotFound(w, r)
923+
}
924+
}))
925+
t.Cleanup(srv.Close)
926+
927+
d := &Daemon{client: NewClient(srv.URL), logger: slog.Default()}
928+
images := d.claudeInitialImages(context.Background(), Task{
929+
IssueAttachments: []AttachmentMeta{
930+
{ID: "img-1", Filename: "client-shot.png", ContentType: "image/png"},
931+
{ID: "txt-1", Filename: "notes.txt", ContentType: "text/plain"},
932+
},
933+
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
934+
935+
if len(images) != 1 {
936+
t.Fatalf("initial images length = %d, want 1", len(images))
937+
}
938+
got := images[0]
939+
if got.Filename != "server-shot.png" || got.MediaType != "image/png" || string(got.Data) != "image-bytes" {
940+
t.Fatalf("unexpected initial image: %+v data=%q", got, string(got.Data))
941+
}
942+
}
943+
906944
func TestMergeUsage(t *testing.T) {
907945
t.Parallel()
908946

server/internal/daemon/prompt_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ func TestBuildChatPromptAttachmentIDsCanBeBoundToCreatedIssues(t *testing.T) {
253253
task := Task{
254254
ChatSessionID: "sess-1",
255255
ChatMessage: "please create an issue with this screenshot",
256-
ChatMessageAttachments: []ChatAttachmentMeta{
256+
ChatMessageAttachments: []AttachmentMeta{
257257
{ID: "019ec09d-6222-722b-bdfa-427b105d80be", Filename: "shot.png", ContentType: "image/png"},
258258
},
259259
}

server/internal/daemon/types.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,12 @@ type Task struct {
7979
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
8080
NewCommentCount int `json:"new_comment_count,omitempty"` // issue-wide comments since this agent's last run (excludes its own and the injected trigger); 0/omitted for old daemons or cold start
8181
NewCommentsSince string `json:"new_comments_since,omitempty"` // RFC3339 anchor (last run's started_at) the count is measured from; empty on cold start
82+
IssueAttachments []AttachmentMeta `json:"issue_attachments,omitempty"` // attachments linked to the issue; Claude embeds image attachments directly when possible
8283
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
8384
ChatChannelType string `json:"chat_channel_type,omitempty"` // "slack" when the chat session is backed by an IM channel; empty for a web-only chat. Drives the channel-awareness block in the prompt
8485
ChatInThread bool `json:"chat_in_thread,omitempty"` // true when the latest @mention was a thread reply; selects which read command the prompt tells the agent to start with
8586
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
86-
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments linked to the chat message; agent uses these to `multica attachment download <id>`
87+
ChatMessageAttachments []AttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments linked to the chat message; agent uses these to `multica attachment download <id>`
8788
ChatIntro bool `json:"chat_intro,omitempty"` // true for the agent's proactive self-introduction chat (no user message); selects the self-introduction prompt in buildChatPrompt
8889
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
8990
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
@@ -130,12 +131,11 @@ type Task struct {
130131
AuthToken string `json:"auth_token,omitempty"`
131132
}
132133

133-
// ChatAttachmentMeta is the structured attachment metadata the daemon
134-
// hands to the agent for chat tasks. We pass id + filename + content_type
135-
// so the chat prompt can list them explicitly and instruct the agent to
136-
// run `multica attachment download <id>` instead of guessing from a
137-
// signed CDN URL (which expires).
138-
type ChatAttachmentMeta struct {
134+
// AttachmentMeta is the structured attachment metadata the daemon receives
135+
// in claim responses. We pass id + filename + content_type so prompts can
136+
// list attachments explicitly, and Claude can embed image files directly in
137+
// the initial user message instead of going through the Read tool_result path.
138+
type AttachmentMeta struct {
139139
ID string `json:"id"`
140140
Filename string `json:"filename"`
141141
ContentType string `json:"content_type,omitempty"`

server/internal/handler/agent.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,12 @@ type AgentTaskResponse struct {
320320
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
321321
NewCommentCount int `json:"new_comment_count,omitempty"` // trigger-thread comments since last run; excludes injected trigger + own comments; omitempty so old daemons ignore it
322322
NewCommentsSince string `json:"new_comments_since,omitempty"` // RFC3339 anchor (last run's started_at) the count is measured from; omitempty so old daemons ignore it
323+
IssueAttachments []AttachmentMeta `json:"issue_attachments,omitempty"` // attachments linked to the issue
323324
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
324325
ChatChannelType string `json:"chat_channel_type,omitempty"` // "slack" when the chat session is backed by an IM channel; empty for a web-only chat. Makes the agent channel-aware (read history from the channel, not Multica)
325326
ChatInThread bool `json:"chat_in_thread,omitempty"` // true when the latest @mention was a thread reply; tells the agent to start with `multica chat thread` vs `multica chat history`
326327
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
327-
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments on the user message — agent calls `multica attachment download <id>` per entry
328+
ChatMessageAttachments []AttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments on the user message — agent calls `multica attachment download <id>` per entry
328329
ChatIntro bool `json:"chat_intro,omitempty"` // true for the agent's proactive self-introduction chat (is_agent_intro session, no user message); the daemon builds an intro prompt instead of a reply prompt
329330
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks
330331
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this task
@@ -376,13 +377,13 @@ type AgentTaskResponse struct {
376377
AuthToken string `json:"auth_token,omitempty"`
377378
}
378379

379-
// ChatAttachmentMeta is the structured attachment metadata embedded in
380-
// claim responses for chat tasks. The agent uses these to run
381-
// `multica attachment download <id>` rather than guessing from the
382-
// markdown URL (which is signed and 30-min expiring on private CDN).
380+
// AttachmentMeta is the structured attachment metadata embedded in
381+
// claim responses. The daemon uses these to embed image attachments directly
382+
// for Claude or to tell agents which IDs they can download through the CLI
383+
// rather than guessing from signed markdown URLs.
383384
// The mirror struct on the daemon side lives in internal/daemon/types.go
384385
// and uses the same JSON field names.
385-
type ChatAttachmentMeta struct {
386+
type AttachmentMeta struct {
386387
ID string `json:"id"`
387388
Filename string `json:"filename"`
388389
ContentType string `json:"content_type,omitempty"`

server/internal/handler/daemon.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1651,6 +1651,20 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
16511651
resp.Repos = repos
16521652
}
16531653
}
1654+
1655+
if atts, attErr := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
1656+
IssueID: issue.ID,
1657+
WorkspaceID: issue.WorkspaceID,
1658+
}); attErr == nil && len(atts) > 0 {
1659+
resp.IssueAttachments = make([]AttachmentMeta, len(atts))
1660+
for j, a := range atts {
1661+
resp.IssueAttachments[j] = AttachmentMeta{
1662+
ID: uuidToString(a.ID),
1663+
Filename: a.Filename,
1664+
ContentType: a.ContentType,
1665+
}
1666+
}
1667+
}
16541668
}
16551669

16561670
// Load every planned input as one chronological, de-duplicated set.
@@ -1943,7 +1957,7 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
19431957
WorkspaceID: parseUUID(resp.WorkspaceID),
19441958
}); attErr == nil && len(atts) > 0 {
19451959
for _, a := range atts {
1946-
resp.ChatMessageAttachments = append(resp.ChatMessageAttachments, ChatAttachmentMeta{
1960+
resp.ChatMessageAttachments = append(resp.ChatMessageAttachments, AttachmentMeta{
19471961
ID: uuidToString(a.ID),
19481962
Filename: a.Filename,
19491963
ContentType: a.ContentType,

0 commit comments

Comments
 (0)