mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): download files on frontend (#2563)
* feat(web): download attachments in frontend * fix: proxy pico media and force svg downloads * feat(web): hide ephemeral media refs from persisted session history
This commit is contained in:
+73
-31
@@ -24,6 +24,8 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
|
||||
// This allows the frontend to connect via the same port as the web UI,
|
||||
// avoiding the need to expose extra ports for WebSocket communication.
|
||||
mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy())
|
||||
mux.HandleFunc("GET /pico/media/{id}", h.handlePicoMediaProxy())
|
||||
mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy())
|
||||
}
|
||||
|
||||
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
|
||||
@@ -55,6 +57,53 @@ func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *h
|
||||
return wsProxy
|
||||
}
|
||||
|
||||
func (h *Handler) createPicoHTTPProxy(token string) *httputil.ReverseProxy {
|
||||
return &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
target := h.gatewayProxyURL()
|
||||
r.SetURL(target)
|
||||
r.Out.Header.Set("Authorization", "Bearer "+token)
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
logger.Errorf("Failed to proxy Pico HTTP request: %v", err)
|
||||
http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) gatewayAvailableForProxy() bool {
|
||||
gateway.mu.Lock()
|
||||
ensurePicoTokenCachedLocked(h.configPath)
|
||||
cachedPID := gateway.pidData
|
||||
trackedCmd := gateway.cmd
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil {
|
||||
gateway.mu.Lock()
|
||||
gateway.pidData = pidData
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gateway.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
if cachedPID == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if isCmdProcessAliveLocked(trackedCmd) {
|
||||
return true
|
||||
}
|
||||
|
||||
gateway.mu.Lock()
|
||||
if gateway.cmd == trackedCmd {
|
||||
gateway.pidData = nil
|
||||
setGatewayRuntimeStatusLocked("stopped")
|
||||
}
|
||||
available := gateway.pidData != nil
|
||||
gateway.mu.Unlock()
|
||||
return available
|
||||
}
|
||||
|
||||
func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) {
|
||||
if cfg == nil {
|
||||
return config.PicoSettings{}, false
|
||||
@@ -101,37 +150,7 @@ func (h *Handler) writePicoInfoResponse(
|
||||
// on the upstream gateway request.
|
||||
func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gateway.mu.Lock()
|
||||
ensurePicoTokenCachedLocked(h.configPath)
|
||||
cachedPID := gateway.pidData
|
||||
trackedCmd := gateway.cmd
|
||||
gateway.mu.Unlock()
|
||||
|
||||
gatewayAvailable := false
|
||||
// Prefer fresh PID file data when available.
|
||||
if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil {
|
||||
gateway.mu.Lock()
|
||||
gateway.pidData = pidData
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gatewayAvailable = true
|
||||
gateway.mu.Unlock()
|
||||
} else if cachedPID != nil {
|
||||
// No PID file now: keep availability only while tracked process is
|
||||
// still alive (covers short PID-file races at startup/restart).
|
||||
if isCmdProcessAliveLocked(trackedCmd) {
|
||||
gatewayAvailable = true
|
||||
} else {
|
||||
gateway.mu.Lock()
|
||||
if gateway.cmd == trackedCmd {
|
||||
gateway.pidData = nil
|
||||
setGatewayRuntimeStatusLocked("stopped")
|
||||
}
|
||||
gatewayAvailable = gateway.pidData != nil
|
||||
gateway.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
if !gatewayAvailable {
|
||||
if !h.gatewayAvailableForProxy() {
|
||||
logger.Warnf("Gateway not available for WebSocket proxy")
|
||||
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -153,6 +172,29 @@ func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handlePicoMediaProxy() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.gatewayAvailableForProxy() {
|
||||
logger.Warnf("Gateway not available for Pico media proxy")
|
||||
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
gateway.mu.Lock()
|
||||
uiToken := gateway.picoToken
|
||||
gateway.mu.Unlock()
|
||||
|
||||
token := tokenPrefix + uiToken
|
||||
if token == "" {
|
||||
logger.Warnf("Missing Pico token for media proxy")
|
||||
http.Error(w, "Invalid Pico token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
h.createPicoHTTPProxy(token).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetPicoInfo returns non-secret Pico connection info for the launcher UI.
|
||||
//
|
||||
// GET /api/pico/info
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
@@ -649,6 +650,54 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePicoHTTPProxyInjectsGatewayAuth(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Gateway.Host = "127.0.0.1"
|
||||
cfg.Gateway.Port = 18790
|
||||
bc := cfg.Channels["pico"]
|
||||
bc.Enabled = true
|
||||
decoded, err := bc.GetDecoded()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDecoded() error = %v", err)
|
||||
}
|
||||
decoded.(*config.PicoSettings).SetToken("ui-token")
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
proxy := h.createPicoHTTPProxy(tokenPrefix + "test-token" + "ui-token")
|
||||
var capturedPath string
|
||||
var capturedAuth string
|
||||
proxy.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
capturedPath = req.URL.Path
|
||||
capturedAuth = req.Header.Get("Authorization")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("proxied")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/pico/media/attachment-1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if capturedPath != "/pico/media/attachment-1" {
|
||||
t.Fatalf("capturedPath = %q, want %q", capturedPath, "/pico/media/attachment-1")
|
||||
}
|
||||
expected := "Bearer " + tokenPrefix + "test-token" + "ui-token"
|
||||
if capturedAuth != expected {
|
||||
t.Fatalf("Authorization = %q, want %q", capturedAuth, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("HOME", tmpDir)
|
||||
@@ -797,3 +846,9 @@ func mustGatewayTestPort(t *testing.T, rawURL string) int {
|
||||
|
||||
return port
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
+133
-25
@@ -46,9 +46,17 @@ type sessionListItem struct {
|
||||
}
|
||||
|
||||
type sessionChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Media []string `json:"media,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Media []string `json:"media,omitempty"`
|
||||
Attachments []sessionChatAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
type sessionChatAttachment struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
// legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL
|
||||
@@ -398,10 +406,12 @@ func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessio
|
||||
}
|
||||
|
||||
func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem {
|
||||
transcript := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
|
||||
|
||||
preview := ""
|
||||
for _, msg := range sess.Messages {
|
||||
for _, msg := range transcript {
|
||||
if msg.Role == "user" {
|
||||
preview = sessionMessagePreview(msg)
|
||||
preview = sessionChatMessagePreview(msg)
|
||||
}
|
||||
if preview != "" {
|
||||
break
|
||||
@@ -414,13 +424,11 @@ func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArg
|
||||
}
|
||||
title := preview
|
||||
|
||||
validMessageCount := len(visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength))
|
||||
|
||||
return sessionListItem{
|
||||
ID: sessionID,
|
||||
Title: title,
|
||||
Preview: preview,
|
||||
MessageCount: validMessageCount,
|
||||
MessageCount: len(transcript),
|
||||
Created: sess.Created.Format(time.RFC3339),
|
||||
Updated: sess.Updated.Format(time.RFC3339),
|
||||
}
|
||||
@@ -441,16 +449,25 @@ func truncateRunes(s string, maxLen int) string {
|
||||
return string(runes[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
func sessionMessageVisible(msg providers.Message) bool {
|
||||
return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0
|
||||
func sessionChatMessageVisible(msg sessionChatMessage) bool {
|
||||
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 != "" {
|
||||
return content
|
||||
}
|
||||
if len(msg.Attachments) > 0 {
|
||||
if strings.EqualFold(strings.TrimSpace(msg.Attachments[0].Type), "image") {
|
||||
return "[image]"
|
||||
}
|
||||
return "[attachment]"
|
||||
}
|
||||
if len(msg.Media) > 0 {
|
||||
return "[image]"
|
||||
if strings.HasPrefix(strings.TrimSpace(msg.Media[0]), "data:image/") {
|
||||
return "[image]"
|
||||
}
|
||||
return "[attachment]"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -459,17 +476,21 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
||||
transcript := make([]sessionChatMessage, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
attachments := sessionAttachments(msg)
|
||||
|
||||
switch msg.Role {
|
||||
case "tool":
|
||||
continue
|
||||
|
||||
case "user":
|
||||
if sessionMessageVisible(msg) {
|
||||
transcript = append(transcript, sessionChatMessage{
|
||||
Role: "user",
|
||||
Content: msg.Content,
|
||||
Media: append([]string(nil), msg.Media...),
|
||||
})
|
||||
chatMsg := sessionChatMessage{
|
||||
Role: "user",
|
||||
Content: msg.Content,
|
||||
Media: append([]string(nil), msg.Media...),
|
||||
Attachments: attachments,
|
||||
}
|
||||
if sessionChatMessageVisible(chatMsg) {
|
||||
transcript = append(transcript, chatMsg)
|
||||
}
|
||||
|
||||
case "assistant":
|
||||
@@ -492,15 +513,25 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
||||
// Pico web chat can persist both visible `message` tool output and a
|
||||
// later plain assistant reply in the same turn. Hide only the fixed
|
||||
// internal summary that marks handled tool delivery.
|
||||
if !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) {
|
||||
content := msg.Content
|
||||
if assistantMessageInternalOnly(msg) {
|
||||
if len(attachments) == 0 {
|
||||
continue
|
||||
}
|
||||
content = ""
|
||||
}
|
||||
|
||||
chatMsg := sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Media: append([]string(nil), msg.Media...),
|
||||
Attachments: attachments,
|
||||
}
|
||||
if !sessionChatMessageVisible(chatMsg) {
|
||||
continue
|
||||
}
|
||||
|
||||
transcript = append(transcript, sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: msg.Content,
|
||||
Media: append([]string(nil), msg.Media...),
|
||||
})
|
||||
transcript = append(transcript, chatMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,11 +549,88 @@ func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessa
|
||||
return filtered
|
||||
}
|
||||
|
||||
func sessionAttachments(msg providers.Message) []sessionChatAttachment {
|
||||
if len(msg.Attachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
attachments := make([]sessionChatAttachment, 0, len(msg.Attachments))
|
||||
for _, attachment := range msg.Attachments {
|
||||
urlValue, ok := sessionAttachmentURL(attachment)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attachmentType := strings.TrimSpace(attachment.Type)
|
||||
if attachmentType == "" {
|
||||
attachmentType = sessionAttachmentType(attachment)
|
||||
}
|
||||
attachments = append(attachments, sessionChatAttachment{
|
||||
Type: attachmentType,
|
||||
URL: urlValue,
|
||||
Filename: strings.TrimSpace(attachment.Filename),
|
||||
ContentType: strings.TrimSpace(attachment.ContentType),
|
||||
})
|
||||
}
|
||||
|
||||
if len(attachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
func sessionAttachmentURL(attachment providers.Attachment) (string, bool) {
|
||||
if rawURL := strings.TrimSpace(attachment.URL); rawURL != "" {
|
||||
return rawURL, true
|
||||
}
|
||||
|
||||
ref := strings.TrimSpace(attachment.Ref)
|
||||
if ref == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(ref, "media://") {
|
||||
// Persisted session history must only expose durable attachment locations.
|
||||
// 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 ref, true
|
||||
}
|
||||
|
||||
func sessionAttachmentType(attachment providers.Attachment) string {
|
||||
contentType := strings.ToLower(strings.TrimSpace(attachment.ContentType))
|
||||
filename := strings.ToLower(strings.TrimSpace(attachment.Filename))
|
||||
rawRef := strings.ToLower(strings.TrimSpace(attachment.Ref))
|
||||
rawURL := strings.ToLower(strings.TrimSpace(attachment.URL))
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, "image/"),
|
||||
strings.HasPrefix(rawRef, "data:image/"),
|
||||
strings.HasPrefix(rawURL, "data:image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(contentType, "audio/"):
|
||||
return "audio"
|
||||
case strings.HasPrefix(contentType, "video/"):
|
||||
return "video"
|
||||
}
|
||||
|
||||
switch ext := filepath.Ext(filename); ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
||||
return "image"
|
||||
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
|
||||
return "audio"
|
||||
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
||||
return "video"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
func assistantMessageTransientThought(msg providers.Message) bool {
|
||||
return strings.TrimSpace(msg.Content) == "" &&
|
||||
strings.TrimSpace(msg.ReasoningContent) != "" &&
|
||||
len(msg.ToolCalls) == 0 &&
|
||||
len(msg.Media) == 0
|
||||
len(msg.Media) == 0 &&
|
||||
len(msg.Attachments) == 0
|
||||
}
|
||||
|
||||
func assistantMessageInternalOnly(msg providers.Message) bool {
|
||||
|
||||
@@ -218,6 +218,136 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_HidesHandledToolAttachmentsBackedByMediaRefs(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"
|
||||
for _, msg := range []providers.Message{
|
||||
{Role: "user", Content: "send me the report"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: handledToolResponseSummaryText,
|
||||
Attachments: []providers.Attachment{{
|
||||
Type: "file",
|
||||
Ref: "media://attachment-1",
|
||||
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", 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 {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Messages) != 1 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.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 {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Messages) != 2 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
|
||||
}
|
||||
|
||||
assistant := resp.Messages[1]
|
||||
if assistant.Role != "assistant" {
|
||||
t.Fatalf("assistant role = %q, want assistant", assistant.Role)
|
||||
}
|
||||
if assistant.Content != "" {
|
||||
t.Fatalf("assistant content = %q, want empty string", assistant.Content)
|
||||
}
|
||||
if len(assistant.Attachments) != 1 {
|
||||
t.Fatalf("len(assistant.Attachments) = %d, want 1", len(assistant.Attachments))
|
||||
}
|
||||
if assistant.Attachments[0].URL != "https://example.com/report.txt" {
|
||||
t.Fatalf(
|
||||
"attachment url = %q, want %q",
|
||||
assistant.Attachments[0].URL,
|
||||
"https://example.com/report.txt",
|
||||
)
|
||||
}
|
||||
if assistant.Attachments[0].Filename != "report.txt" {
|
||||
t.Fatalf("attachment filename = %q, want %q", assistant.Attachments[0].Filename, "report.txt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
Reference in New Issue
Block a user