feat(web): hide ephemeral media refs from persisted session history

This commit is contained in:
afjcjsbx 2026-04-21 19:19:39 +02:00
parent ba5ef787b4
commit 19c35317ad
2 changed files with 90 additions and 39 deletions

View file

@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@ -407,10 +406,12 @@ func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessio
} }
func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem { func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem {
transcript := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
preview := "" preview := ""
for _, msg := range sess.Messages { for _, msg := range transcript {
if msg.Role == "user" { if msg.Role == "user" {
preview = sessionMessagePreview(msg) preview = sessionChatMessagePreview(msg)
} }
if preview != "" { if preview != "" {
break break
@ -423,13 +424,11 @@ func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArg
} }
title := preview title := preview
validMessageCount := len(visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength))
return sessionListItem{ return sessionListItem{
ID: sessionID, ID: sessionID,
Title: title, Title: title,
Preview: preview, Preview: preview,
MessageCount: validMessageCount, MessageCount: len(transcript),
Created: sess.Created.Format(time.RFC3339), Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339), Updated: sess.Updated.Format(time.RFC3339),
} }
@ -450,11 +449,11 @@ func truncateRunes(s string, maxLen int) string {
return string(runes[:maxLen]) + "..." return string(runes[:maxLen]) + "..."
} }
func sessionMessageVisible(msg providers.Message) bool { func sessionChatMessageVisible(msg sessionChatMessage) bool {
return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0 || len(msg.Attachments) > 0 return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0 || len(msg.Attachments) > 0
} }
func sessionMessagePreview(msg providers.Message) string { func sessionChatMessagePreview(msg sessionChatMessage) string {
if content := strings.TrimSpace(msg.Content); content != "" { if content := strings.TrimSpace(msg.Content); content != "" {
return content return content
} }
@ -484,13 +483,14 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
continue continue
case "user": case "user":
if sessionMessageVisible(msg) { chatMsg := sessionChatMessage{
transcript = append(transcript, sessionChatMessage{
Role: "user", Role: "user",
Content: msg.Content, Content: msg.Content,
Media: append([]string(nil), msg.Media...), Media: append([]string(nil), msg.Media...),
Attachments: attachments, Attachments: attachments,
}) }
if sessionChatMessageVisible(chatMsg) {
transcript = append(transcript, chatMsg)
} }
case "assistant": case "assistant":
@ -513,10 +513,6 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
// Pico web chat can persist both visible `message` tool output and a // Pico web chat can persist both visible `message` tool output and a
// later plain assistant reply in the same turn. Hide only the fixed // later plain assistant reply in the same turn. Hide only the fixed
// internal summary that marks handled tool delivery. // internal summary that marks handled tool delivery.
if !sessionMessageVisible(msg) {
continue
}
content := msg.Content content := msg.Content
if assistantMessageInternalOnly(msg) { if assistantMessageInternalOnly(msg) {
if len(attachments) == 0 { if len(attachments) == 0 {
@ -525,12 +521,17 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
content = "" content = ""
} }
transcript = append(transcript, sessionChatMessage{ chatMsg := sessionChatMessage{
Role: "assistant", Role: "assistant",
Content: content, Content: content,
Media: append([]string(nil), msg.Media...), Media: append([]string(nil), msg.Media...),
Attachments: attachments, Attachments: attachments,
}) }
if !sessionChatMessageVisible(chatMsg) {
continue
}
transcript = append(transcript, chatMsg)
} }
} }
@ -587,12 +588,11 @@ func sessionAttachmentURL(attachment providers.Attachment) (string, bool) {
return "", false return "", false
} }
if strings.HasPrefix(ref, "media://") { if strings.HasPrefix(ref, "media://") {
refID := strings.TrimSpace(strings.TrimPrefix(ref, "media://")) // Persisted session history must only expose durable attachment locations.
if refID == "" { // media:// refs depend on the live in-memory MediaStore and may stop
// resolving after a restart or cleanup, so omit them from reopened history.
return "", false return "", false
} }
return "/pico/media/" + url.PathEscape(refID), true
}
return ref, true return ref, true
} }

View file

@ -218,7 +218,7 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
} }
} }
func TestHandleGetSession_ExposesHandledToolAttachments(t *testing.T) { func TestHandleGetSession_HidesHandledToolAttachmentsBackedByMediaRefs(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -260,16 +260,63 @@ func TestHandleGetSession_ExposesHandledToolAttachments(t *testing.T) {
} }
var resp struct { var resp struct {
Messages []struct { Messages []sessionChatMessage `json:"messages"`
Role string `json:"role"` }
Content string `json:"content"` if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
Attachments []struct { t.Fatalf("Unmarshal() error = %v", err)
Type string `json:"type"` }
URL string `json:"url"`
Filename string `json:"filename"` if len(resp.Messages) != 1 {
ContentType string `json:"content_type"` t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
} `json:"attachments"` }
} `json:"messages"` if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "send me the report" {
t.Fatalf("message = %#v, want only user request", resp.Messages[0])
}
}
func TestHandleGetSession_ExposesHandledToolAttachmentsWithDurableURL(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := legacyPicoSessionPrefix + "attachment-history-durable"
for _, msg := range []providers.Message{
{Role: "user", Content: "send me the report"},
{
Role: "assistant",
Content: handledToolResponseSummaryText,
Attachments: []providers.Attachment{{
Type: "file",
URL: "https://example.com/report.txt",
Filename: "report.txt",
ContentType: "text/plain",
}},
},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history-durable", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []sessionChatMessage `json:"messages"`
} }
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err) t.Fatalf("Unmarshal() error = %v", err)
@ -289,8 +336,12 @@ func TestHandleGetSession_ExposesHandledToolAttachments(t *testing.T) {
if len(assistant.Attachments) != 1 { if len(assistant.Attachments) != 1 {
t.Fatalf("len(assistant.Attachments) = %d, want 1", len(assistant.Attachments)) t.Fatalf("len(assistant.Attachments) = %d, want 1", len(assistant.Attachments))
} }
if assistant.Attachments[0].URL != "/pico/media/attachment-1" { if assistant.Attachments[0].URL != "https://example.com/report.txt" {
t.Fatalf("attachment url = %q, want %q", assistant.Attachments[0].URL, "/pico/media/attachment-1") t.Fatalf(
"attachment url = %q, want %q",
assistant.Attachments[0].URL,
"https://example.com/report.txt",
)
} }
if assistant.Attachments[0].Filename != "report.txt" { if assistant.Attachments[0].Filename != "report.txt" {
t.Fatalf("attachment filename = %q, want %q", assistant.Attachments[0].Filename, "report.txt") t.Fatalf("attachment filename = %q, want %q", assistant.Attachments[0].Filename, "report.txt")