diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 81b9794903..d259a650a9 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1675,7 +1675,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { rootMsg := providers.Message{ Role: "user", - Content: ts.userMessage, + Content: resolvedCurrentUserMessageContent(messages, ts.userMessage), Media: append([]string(nil), ts.media...), } if len(rootMsg.Media) > 0 { diff --git a/pkg/agent/loop_media.go b/pkg/agent/loop_media.go index e8314c10d3..e0d05cab9e 100644 --- a/pkg/agent/loop_media.go +++ b/pkg/agent/loop_media.go @@ -21,7 +21,8 @@ import ( ) // resolveMediaRefs resolves media:// refs in messages. -// Images are base64-encoded into the Media array for multimodal LLMs. +// Images are base64-encoded into the Media array for multimodal LLMs and also +// have their local path injected into Content so tools can read the file. // Non-image files (documents, audio, video) have their local path injected // into Content so the agent can access them via file tools like read_file. // Returns a new slice; original messages are not mutated. @@ -68,6 +69,7 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS mime := detectMIME(localPath, meta) if strings.HasPrefix(mime, "image/") { + pathTags = append(pathTags, buildPathTag(mime, localPath)) dataURL := encodeImageToDataURL(localPath, mime, info, maxSize) if dataURL != "" { resolved = append(resolved, dataURL) @@ -105,6 +107,15 @@ func buildArtifactTags(store media.MediaStore, refs []string) []string { return tags } +func resolvedCurrentUserMessageContent(messages []providers.Message, fallback string) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" { + return messages[i].Content + } + } + return fallback +} + // detectMIME determines the MIME type from metadata or magic-bytes detection. // Returns empty string if detection fails. func detectMIME(localPath string, meta media.MediaMeta) string { @@ -160,9 +171,12 @@ func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) } // buildPathTag creates a structured tag exposing the local file path. -// Tag type is derived from MIME: [audio:/path], [video:/path], or [file:/path]. +// Tag type is derived from MIME: [image:/path], [audio:/path], +// [video:/path], or [file:/path]. func buildPathTag(mime, localPath string) string { switch { + case strings.HasPrefix(mime, "image/"): + return "[image:" + localPath + "]" case strings.HasPrefix(mime, "audio/"): return "[audio:" + localPath + "]" case strings.HasPrefix(mime, "video/"): @@ -178,6 +192,8 @@ func injectPathTags(content string, tags []string) string { for _, tag := range tags { var generic string switch { + case strings.HasPrefix(tag, "[image:"): + generic = "[image: photo]" case strings.HasPrefix(tag, "[audio:"): generic = "[audio]" case strings.HasPrefix(tag, "[video:"): diff --git a/pkg/agent/loop_media_path_test.go b/pkg/agent/loop_media_path_test.go new file mode 100644 index 0000000000..79c93d2e91 --- /dev/null +++ b/pkg/agent/loop_media_path_test.go @@ -0,0 +1,92 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestResolveMediaRefsImageAddsDataURLAndPathTag(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "photo.png") + if err := os.WriteFile(path, []byte("not a real png, but metadata supplies MIME"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := media.NewFileMediaStore() + ref, err := store.Store(path, media.MediaMeta{ContentType: "image/png"}, "scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + result := resolveMediaRefs([]providers.Message{{ + Role: "user", + Content: "[image: photo]", + Media: []string{ref}, + }}, store, 1024*1024) + + if len(result) != 1 { + t.Fatalf("result len = %d, want 1", len(result)) + } + if len(result[0].Media) != 1 || !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { + t.Fatalf("resolved media = %#v, want data:image/png", result[0].Media) + } + if want := "[image:" + path + "]"; !strings.Contains(result[0].Content, want) { + t.Fatalf("content = %q, want path tag %q", result[0].Content, want) + } + if strings.Contains(result[0].Content, "[image: photo]") { + t.Fatalf("content still contains generic image tag: %q", result[0].Content) + } +} + +func TestResolvedCurrentUserMessageContentReturnsResolvedLastUser(t *testing.T) { + messages := []providers.Message{ + {Role: "system", Content: "system"}, + {Role: "user", Content: "old"}, + {Role: "assistant", Content: "answer"}, + {Role: "user", Content: "new [image:/tmp/picoclaw_media/a.jpg]"}, + } + + got := resolvedCurrentUserMessageContent(messages, "fallback") + want := "new [image:/tmp/picoclaw_media/a.jpg]" + if got != want { + t.Fatalf("resolvedCurrentUserMessageContent() = %q, want %q", got, want) + } +} + +func TestResolvedImageMessageContentCanRemainInHistoryForFollowupText(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "photo.jpg") + if err := os.WriteFile(path, []byte("jpeg bytes supplied by Feishu"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := media.NewFileMediaStore() + ref, err := store.Store(path, media.MediaMeta{ContentType: "image/jpeg"}, "scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + resolved := resolveMediaRefs([]providers.Message{{ + Role: "user", + Content: "[image: photo]", + Media: []string{ref}, + }}, store, 1024*1024) + historyContent := resolvedCurrentUserMessageContent(resolved, "[image: photo]") + wantPathTag := "[image:" + path + "]" + if !strings.Contains(historyContent, wantPathTag) { + t.Fatalf("history content = %q, want path tag %q", historyContent, wantPathTag) + } + + followupMessages := []providers.Message{ + {Role: "user", Content: historyContent}, + {Role: "user", Content: "\u628a\u8fd9\u4e2a\u56fe\u7247\u8bc4\u8bae\u5230 issue"}, + } + if !strings.Contains(followupMessages[0].Content, wantPathTag) { + t.Fatalf("follow-up history lost path tag: %#v", followupMessages) + } +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 2366b12775..cce667b8fe 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -963,6 +963,45 @@ type artifactThenSendProvider struct { calls int } +func extractToolResultArtifactPath(content string) string { + prefixes := []string{"[image:", "[audio:", "[video:", "[file:"} + for _, prefix := range prefixes { + start := strings.Index(content, prefix) + if start < 0 { + continue + } + + rest := content[start+len(prefix):] + end := strings.Index(rest, "]") + if end <= 0 { + continue + } + + artifactPath := rest[:end] + if strings.TrimSpace(artifactPath) == "" { + continue + } + return artifactPath + } + + return "" +} + +func (m *artifactThenSendProvider) getArtifactPath(messages []providers.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role != "tool" { + continue + } + + artifactPath := extractToolResultArtifactPath(messages[i].Content) + if artifactPath != "" { + return artifactPath + } + } + + return "" +} + func (m *artifactThenSendProvider) Chat( ctx context.Context, messages []providers.Message, @@ -983,23 +1022,7 @@ func (m *artifactThenSendProvider) Chat( }, nil } - var artifactPath string - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role != "tool" { - continue - } - start := strings.Index(messages[i].Content, "[file:") - if start < 0 { - continue - } - rest := messages[i].Content[start+len("[file:"):] - end := strings.Index(rest, "]") - if end < 0 { - continue - } - artifactPath = rest[:end] - break - } + artifactPath := m.getArtifactPath(messages) if artifactPath == "" { return nil, fmt.Errorf("provider did not receive artifact path in tool result") } @@ -2733,6 +2756,8 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) { t.Fatal("expected image to be base64 encoded") } expectedContent := "check these [file:" + pdfPath + "]" + expectedImageTag := "[image:" + pngPath + "]" + expectedContent += " " + expectedImageTag if result[0].Content != expectedContent { t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 75ba9861df..4db21788da 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -1087,10 +1087,17 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) { foundResolvedMedia := false for _, msg := range msgs { - if msg.Role != "user" || msg.Content != "describe this image" || len(msg.Media) != 1 { + if msg.Role != "user" || len(msg.Media) != 1 { continue } - if strings.HasPrefix(msg.Media[0], "data:image/png;base64,") { + if strings.HasPrefix(msg.Content, "describe this image") && + strings.HasPrefix(msg.Media[0], "data:image/png;base64,") { + foundResolvedMedia = true + break + } + + wantTag := "[image:" + pngPath + "]" + if strings.Contains(msg.Content, wantTag) { foundResolvedMedia = true break } diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index 4952394b79..0ec4d57906 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -69,6 +69,78 @@ func extractFileKey(content string) string { return extractJSONStringField(conte // extractFileName extracts the file_name from a Feishu file message content JSON. func extractFileName(content string) string { return extractJSONStringField(content, "file_name") } +// extractPostImageKeys extracts image_key values from Feishu rich text post content. +// Format: {"title":"","content":[[{"tag":"img","image_key":"img_xxx"}]]} +func extractPostImageKeys(content string) []string { + var payload struct { + Content [][]map[string]any `json:"content"` + } + if err := json.Unmarshal([]byte(content), &payload); err != nil { + return nil + } + + var keys []string + for _, line := range payload.Content { + for _, elem := range line { + if tag, _ := elem["tag"].(string); tag != "img" { + continue + } + if key, _ := elem["image_key"].(string); key != "" { + keys = append(keys, key) + } + } + } + return keys +} + +// extractPostText flattens Feishu rich text post content into plain text. +// It handles the small set of rich-text tags needed for inbound instructions; +// image materialization is handled separately. +func extractPostText(content string) string { + var payload struct { + Title string `json:"title"` + Content [][]map[string]any `json:"content"` + } + if err := json.Unmarshal([]byte(content), &payload); err != nil { + return "" + } + + var lines []string + if title := strings.TrimSpace(payload.Title); title != "" { + lines = append(lines, title) + } + for _, line := range payload.Content { + var b strings.Builder + for _, elem := range line { + switch tag, _ := elem["tag"].(string); tag { + case "text", "a": + b.WriteString(postStringField(elem, "text")) + case "at": + name := postStringField(elem, "user_name") + if name == "" { + name = postStringField(elem, "text") + } + if name != "" { + b.WriteString("@") + b.WriteString(name) + } + } + } + if text := strings.TrimSpace(b.String()); text != "" { + lines = append(lines, text) + } + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func postStringField(m map[string]any, key string) string { + if m == nil { + return "" + } + value, _ := m[key].(string) + return value +} + // stripMentionPlaceholders removes @_user_N placeholders from the text content. // These are inserted by Feishu when users @mention someone in a message. func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string { diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 11660a6a7a..9936134493 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -463,8 +463,9 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. // Handle media messages (download and store) var mediaRefs []string + var mediaFailures []string if store := c.GetMediaStore(); store != nil && messageID != "" { - mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store) + mediaRefs, mediaFailures = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store) } // For interactive cards, pass external image URLs via media refs. @@ -478,6 +479,7 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. // Append media tags to content (like Telegram does) content = appendMediaTags(content, messageType, mediaRefs) + content = appendAttachmentFailures(content, mediaFailures) if content == "" { content = "[empty message]" @@ -859,8 +861,7 @@ func extractContent(messageType, rawContent string) string { return rawContent case larkim.MsgTypePost: - // Pass raw JSON to LLM — structured rich text is more informative than flattened plain text - return rawContent + return extractPostText(rawContent) case larkim.MsgTypeInteractive: // Pass raw JSON to LLM — structured card is more informative than flattened text @@ -888,19 +889,32 @@ func (c *FeishuChannel) downloadInboundMedia( ctx context.Context, chatID, messageID, messageType, rawContent string, store media.MediaStore, -) []string { +) ([]string, []string) { var refs []string + var failures []string scope := channels.BuildMediaScope("feishu", chatID, messageID) switch messageType { case larkim.MsgTypeImage: imageKey := extractImageKey(rawContent) if imageKey == "" { - return nil + return nil, nil } - ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + ref, failure := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) if ref != "" { refs = append(refs, ref) + } else if failure != "" { + failures = append(failures, failure) + } + + case larkim.MsgTypePost: + for _, imageKey := range extractPostImageKeys(rawContent) { + ref, failure := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } else if failure != "" { + failures = append(failures, failure) + } } case larkim.MsgTypeInteractive: @@ -908,9 +922,11 @@ func (c *FeishuChannel) downloadInboundMedia( feishuKeys, _ := extractCardImageKeys(rawContent) // Download Feishu-hosted images via API for _, imageKey := range feishuKeys { - ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + ref, failure := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) if ref != "" { refs = append(refs, ref) + } else if failure != "" { + failures = append(failures, failure) } } // External URLs are passed directly to LLM, not downloaded @@ -918,7 +934,7 @@ func (c *FeishuChannel) downloadInboundMedia( case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia: fileKey := extractFileKey(rawContent) if fileKey == "" { - return nil + return nil, nil } // Derive a fallback extension from the message type. var ext string @@ -930,13 +946,15 @@ func (c *FeishuChannel) downloadInboundMedia( default: ext = "" // generic file — rely on resp.FileName } - ref := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope) + ref, failure := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope) if ref != "" { refs = append(refs, ref) + } else if failure != "" { + failures = append(failures, failure) } } - return refs + return refs, failures } // downloadResource downloads a message resource (image/file) from Feishu, @@ -947,7 +965,7 @@ func (c *FeishuChannel) downloadResource( messageID, fileKey, resourceType, fallbackExt string, store media.MediaStore, scope string, -) string { +) (string, string) { req := larkim.NewGetMessageResourceReqBuilder(). MessageId(messageID). FileKey(fileKey). @@ -961,7 +979,7 @@ func (c *FeishuChannel) downloadResource( "file_key": fileKey, "error": err.Error(), }) - return "" + return "", mediaUnavailable(resourceType, fileKey, "download request failed") } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) @@ -969,11 +987,11 @@ func (c *FeishuChannel) downloadResource( "code": resp.Code, "msg": resp.Msg, }) - return "" + return "", mediaUnavailable(resourceType, fileKey, fmt.Sprintf("feishu resource API code=%d", resp.Code)) } if resp.File == nil { - return "" + return "", mediaUnavailable(resourceType, fileKey, "empty resource response") } // Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body). if closer, ok := resp.File.(io.Closer); ok { @@ -995,7 +1013,7 @@ func (c *FeishuChannel) downloadResource( logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{ "error": mkdirErr.Error(), }) - return "" + return "", mediaUnavailable(resourceType, fileKey, "local materialization failed") } ext := filepath.Ext(filename) localPath := filepath.Join(mediaDir, utils.SanitizeFilename(messageID+"-"+fileKey+ext)) @@ -1005,7 +1023,7 @@ func (c *FeishuChannel) downloadResource( logger.ErrorCF("feishu", "Failed to create local file for resource", map[string]any{ "error": err.Error(), }) - return "" + return "", mediaUnavailable(resourceType, fileKey, "local materialization failed") } if _, copyErr := io.Copy(out, resp.File); copyErr != nil { @@ -1014,7 +1032,7 @@ func (c *FeishuChannel) downloadResource( logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ "error": copyErr.Error(), }) - return "" + return "", mediaUnavailable(resourceType, fileKey, "local materialization failed") } out.Close() @@ -1029,10 +1047,25 @@ func (c *FeishuChannel) downloadResource( "error": err.Error(), }) os.Remove(localPath) - return "" + return "", mediaUnavailable(resourceType, fileKey, "local materialization failed") } - return ref + return ref, "" +} + +func mediaUnavailable(resourceType, key, reason string) string { + resourceType = strings.TrimSpace(resourceType) + if resourceType == "" { + resourceType = "resource" + } + reason = strings.TrimSpace(reason) + if reason == "" { + reason = "unavailable" + } + if key == "" { + return fmt.Sprintf("%s: unavailable (%s)", resourceType, reason) + } + return fmt.Sprintf("%s %s: unavailable (%s)", resourceType, key, reason) } // appendMediaTags appends media type tags to content (like Telegram's "[image: photo]"). @@ -1050,7 +1083,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { var tag string switch messageType { - case larkim.MsgTypeImage: + case larkim.MsgTypeImage, larkim.MsgTypePost: tag = "[image: photo]" case larkim.MsgTypeAudio: tag = "[audio]" @@ -1068,6 +1101,28 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { return content + " " + tag } +func appendAttachmentFailures(content string, failures []string) string { + if len(failures) == 0 { + return content + } + + var b strings.Builder + if strings.TrimSpace(content) != "" { + b.WriteString(content) + b.WriteString("\n\n") + } + b.WriteString("Attachments:") + for _, failure := range failures { + failure = strings.TrimSpace(failure) + if failure == "" { + continue + } + b.WriteString("\n- ") + b.WriteString(failure) + } + return b.String() +} + // sendCard sends an interactive card message to a chat. func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error { req := larkim.NewCreateMessageReqBuilder(). diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index cb8aab5fde..42a7ef70d9 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -30,10 +30,10 @@ func TestExtractContent(t *testing.T) { want: "not json", }, { - name: "post message returns raw JSON", + name: "post message returns flattened text", messageType: "post", rawContent: `{"title": "test post"}`, - want: `{"title": "test post"}`, + want: "test post", }, { name: "image message returns empty", diff --git a/pkg/channels/feishu/post_media_test.go b/pkg/channels/feishu/post_media_test.go new file mode 100644 index 0000000000..21f038ff20 --- /dev/null +++ b/pkg/channels/feishu/post_media_test.go @@ -0,0 +1,41 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + +package feishu + +import ( + "reflect" + "testing" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + +func TestExtractPostTextAndImageKeys(t *testing.T) { + raw := `{"title":"","content":[[{"tag":"img","image_key":"img_1"}],[{"tag":"text","text":"\u5c06\u8fd9\u4e2a\u56fe\u7247\u8bc4\u8bae\u5728 issue \u4e2d"}],[{"tag":"img","image_key":"img_2"}]]}` + + if got, want := extractPostText(raw), "\u5c06\u8fd9\u4e2a\u56fe\u7247\u8bc4\u8bae\u5728 issue \u4e2d"; got != want { + t.Fatalf("extractPostText() = %q, want %q", got, want) + } + if got, want := extractPostImageKeys(raw), []string{"img_1", "img_2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("extractPostImageKeys() = %#v, want %#v", got, want) + } +} + +func TestAppendMediaTagsPostUsesImageTag(t *testing.T) { + got := appendMediaTags( + "\u5c06\u8fd9\u4e2a\u56fe\u7247\u8bc4\u8bae\u5728 issue \u4e2d", + larkim.MsgTypePost, + []string{"media://img"}, + ) + want := "\u5c06\u8fd9\u4e2a\u56fe\u7247\u8bc4\u8bae\u5728 issue \u4e2d [image: photo]" + if got != want { + t.Fatalf("appendMediaTags(post) = %q, want %q", got, want) + } +} + +func TestAppendAttachmentFailures(t *testing.T) { + got := appendAttachmentFailures("hello", []string{"image img_1: unavailable (feishu resource API code=999)"}) + want := "hello\n\nAttachments:\n- image img_1: unavailable (feishu resource API code=999)" + if got != want { + t.Fatalf("appendAttachmentFailures() = %q, want %q", got, want) + } +} diff --git a/pkg/media/store.go b/pkg/media/store.go index 78cff8bb6f..9c7accdfc7 100644 --- a/pkg/media/store.go +++ b/pkg/media/store.go @@ -125,6 +125,25 @@ func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) ( s.mu.Lock() defer s.mu.Unlock() + for existingRef, entry := range s.refs { + if entry.path == localPath && s.refToScope[existingRef] == scope { + entry.meta = meta + entry.storedAt = s.nowFunc() + s.refs[existingRef] = entry + + pathState := s.pathStates[localPath] + if pathState.refCount <= 0 { + pathState.refCount = 1 + } + if meta.CleanupPolicy == CleanupPolicyForgetOnly { + pathState.deleteEligible = false + } + s.pathStates[localPath] = pathState + + return existingRef, nil + } + } + s.refs[ref] = mediaEntry{path: localPath, meta: meta, storedAt: s.nowFunc()} if s.scopeToRefs[scope] == nil { s.scopeToRefs[scope] = make(map[string]struct{}) diff --git a/pkg/media/store_dedupe_test.go b/pkg/media/store_dedupe_test.go new file mode 100644 index 0000000000..c272724061 --- /dev/null +++ b/pkg/media/store_dedupe_test.go @@ -0,0 +1,40 @@ +package media + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestFileMediaStoreStoreReusesRefForSameScopeAndPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "attachment.txt") + if err := os.WriteFile(path, []byte("attachment"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := NewFileMediaStore() + firstStoredAt := time.Unix(100, 0) + secondStoredAt := time.Unix(200, 0) + store.nowFunc = func() time.Time { return firstStoredAt } + + ref1, err := store.Store(path, MediaMeta{Filename: "attachment.txt"}, "scope") + if err != nil { + t.Fatalf("Store() first error = %v", err) + } + store.nowFunc = func() time.Time { return secondStoredAt } + + ref2, err := store.Store(path, MediaMeta{Filename: "renamed.txt"}, "scope") + if err != nil { + t.Fatalf("Store() second error = %v", err) + } + if ref1 != ref2 { + t.Fatalf("Store() refs = %q and %q, want same ref", ref1, ref2) + } + if got := store.refs[ref1].storedAt; !got.Equal(secondStoredAt) { + t.Fatalf("storedAt = %v, want refreshed %v", got, secondStoredAt) + } + if got := store.refs[ref1].meta.Filename; got != "renamed.txt" { + t.Fatalf("meta.Filename = %q, want updated metadata", got) + } +} diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go index dabcc31422..60e39aaa9c 100644 --- a/pkg/media/store_test.go +++ b/pkg/media/store_test.go @@ -144,6 +144,40 @@ func TestReleaseAllSharedPathDeletesOnFinalRefOnly(t *testing.T) { } } +func TestStoreOnExistingPathCanSwitchToForgetOnlyCleanupPolicy(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + path := createTempFile(t, dir, "artifact.png") + ref, err := store.Store(path, MediaMeta{ + Filename: "artifact.png", + ContentType: "image/png", + CleanupPolicy: CleanupPolicyDeleteOnCleanup, + }, "scope") + if err != nil { + t.Fatalf("Store initial delete policy failed: %v", err) + } + + if _, err := store.Store(path, MediaMeta{ + Filename: "artifact.png", + ContentType: "image/png", + CleanupPolicy: CleanupPolicyForgetOnly, + }, "scope"); err != nil { + t.Fatalf("Store existing path with forget-only policy failed: %v", err) + } + + if err := store.ReleaseAll("scope"); err != nil { + t.Fatalf("ReleaseAll failed: %v", err) + } + + if _, err := store.Resolve(ref); err == nil { + t.Fatalf("Resolve(%q) should fail after ReleaseAll", ref) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("forget-only updated policy should keep file in scope: %v", err) + } +} + func TestReleaseAllMixedPoliciesKeepsFile(t *testing.T) { dir := t.TempDir() store := NewFileMediaStore()