mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-06 15:07:58 +00:00
S1 (strip_modalities): Add three Document test cases to TestStripImageContent: - Document with image/png MIME → stripped - Document with application/pdf MIME → preserved - Document part with nil Document pointer → preserved (defensive nil-guard) S2 (transcript): Add golden tests for Document rendering: - TestUserMessageWithImageDocument → image chip with name/mime/size - TestUserMessageWithPDFDocument → attachment chip with name/mime/size - TestUserMessageWithTextDocument → attachment chip with name/mime (no size for text) - TestFormatBytes → boundary tests for 0B, 512B, 1023B, 1536B, 1MB, 2MB, 1.5MB S3 (transcript): Add comment to writeUserMessage documenting the 'what was sent to the model' design decision: the text part in MultiContent contains the user's typed text PLUS dimension notes from image resizing and inlined file headers, which is intentional for transcripts. Callers wanting only the raw typed text should read msg.Message.Content directly. Assisted-By: docker-agent
166 lines
4.6 KiB
Go
166 lines
4.6 KiB
Go
package transcript
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/docker/docker-agent/pkg/chat"
|
|
"github.com/docker/docker-agent/pkg/session"
|
|
)
|
|
|
|
func PlainText(sess *session.Session) string {
|
|
var builder strings.Builder
|
|
|
|
messages := sess.GetAllMessages()
|
|
for i := range messages {
|
|
msg := messages[i]
|
|
|
|
if msg.Implicit {
|
|
continue
|
|
}
|
|
|
|
switch msg.Message.Role {
|
|
case chat.MessageRoleUser:
|
|
writeUserMessage(&builder, msg)
|
|
case chat.MessageRoleAssistant:
|
|
writeAssistantMessage(&builder, msg)
|
|
case chat.MessageRoleTool:
|
|
writeToolMessage(&builder, msg)
|
|
}
|
|
}
|
|
|
|
return strings.TrimSpace(builder.String())
|
|
}
|
|
|
|
func writeUserMessage(builder *strings.Builder, msg session.Message) {
|
|
builder.WriteString("\n## User\n")
|
|
|
|
// When MultiContent is present, render text and attachment chips.
|
|
// The text part reflects what was actually sent to the model (the user's
|
|
// original typed text, plus any dimension notes from image resizing and
|
|
// inlined file headers from ReadFileForInline). This is intentional: a
|
|
// transcript should show what the model received, not just what the user
|
|
// typed into the prompt box. Callers wanting just the raw typed text
|
|
// should read msg.Message.Content directly.
|
|
if len(msg.Message.MultiContent) > 0 {
|
|
for _, part := range msg.Message.MultiContent {
|
|
switch part.Type {
|
|
case chat.MessagePartTypeText:
|
|
if part.Text != "" {
|
|
fmt.Fprintf(builder, "\n%s\n", part.Text)
|
|
}
|
|
case chat.MessagePartTypeDocument:
|
|
if part.Document != nil {
|
|
doc := part.Document
|
|
switch {
|
|
case chat.IsImageMimeType(doc.MimeType):
|
|
size := doc.Size
|
|
if size == 0 {
|
|
size = int64(len(doc.Source.InlineData))
|
|
}
|
|
fmt.Fprintf(builder, "\n[image: %s (%s, %s)]\n", doc.Name, doc.MimeType, formatBytes(size))
|
|
case doc.Source.InlineText != "":
|
|
fmt.Fprintf(builder, "\n[attachment: %s (%s)]\n", doc.Name, doc.MimeType)
|
|
default:
|
|
size := doc.Size
|
|
if size == 0 {
|
|
size = int64(len(doc.Source.InlineData))
|
|
}
|
|
fmt.Fprintf(builder, "\n[attachment: %s (%s, %s)]\n", doc.Name, doc.MimeType, formatBytes(size))
|
|
}
|
|
}
|
|
// Note: superseded types kept for backward-compat with stored sessions.
|
|
case chat.MessagePartTypeImageURL:
|
|
if part.ImageURL != nil {
|
|
fmt.Fprintf(builder, "\n[image: %s]\n", part.ImageURL.URL[:min(len(part.ImageURL.URL), 60)])
|
|
}
|
|
case chat.MessagePartTypeFile:
|
|
if part.File != nil {
|
|
fmt.Fprintf(builder, "\n[file: %s]\n", part.File.Path)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
fmt.Fprintf(builder, "\n%s\n", msg.Message.Content)
|
|
}
|
|
|
|
// formatBytes returns a human-readable byte size string (e.g. "1.2 MB").
|
|
func formatBytes(b int64) string {
|
|
const unit = 1024
|
|
if b < unit {
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := b / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
func writeAssistantMessage(builder *strings.Builder, msg session.Message) {
|
|
builder.WriteString("\n## Assistant")
|
|
if msg.AgentName != "" {
|
|
fmt.Fprintf(builder, " (%s)", msg.AgentName)
|
|
}
|
|
builder.WriteString("\n\n")
|
|
|
|
if msg.Message.ReasoningContent != "" {
|
|
builder.WriteString("### Reasoning\n\n")
|
|
builder.WriteString(msg.Message.ReasoningContent)
|
|
builder.WriteString("\n\n")
|
|
}
|
|
|
|
if msg.Message.Content != "" {
|
|
builder.WriteString(msg.Message.Content)
|
|
builder.WriteString("\n")
|
|
}
|
|
|
|
if len(msg.Message.ToolCalls) > 0 {
|
|
builder.WriteString("\n### Tool Calls\n\n")
|
|
for _, toolCall := range msg.Message.ToolCalls {
|
|
fmt.Fprintf(builder, "- **%s**", toolCall.Function.Name)
|
|
if toolCall.ID != "" {
|
|
fmt.Fprintf(builder, " (ID: %s)", toolCall.ID)
|
|
}
|
|
|
|
builder.WriteString("\n")
|
|
toJSONString(builder, toolCall.Function.Arguments)
|
|
builder.WriteString("\n")
|
|
}
|
|
builder.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
func writeToolMessage(builder *strings.Builder, msg session.Message) {
|
|
builder.WriteString("### Tool Result")
|
|
if msg.Message.ToolCallID != "" {
|
|
fmt.Fprintf(builder, " (ID: %s)", msg.Message.ToolCallID)
|
|
}
|
|
fmt.Fprintf(builder, "\n\n")
|
|
|
|
toJSONString(builder, msg.Message.Content)
|
|
builder.WriteString("\n")
|
|
}
|
|
|
|
func toJSONString(builder *strings.Builder, in string) {
|
|
var content any
|
|
if err := json.Unmarshal([]byte(in), &content); err == nil {
|
|
if formatted, err := json.MarshalIndent(content, "", " "); err == nil {
|
|
builder.WriteString("```json\n")
|
|
builder.Write(formatted)
|
|
builder.WriteString("\n```\n")
|
|
} else {
|
|
builder.WriteString(in)
|
|
builder.WriteString("\n")
|
|
}
|
|
} else {
|
|
if in != "" {
|
|
builder.WriteString(in)
|
|
builder.WriteString("\n")
|
|
}
|
|
}
|
|
}
|