1package message
2
3import (
4 "encoding/base64"
5 "errors"
6 "fmt"
7 "slices"
8 "strings"
9 "time"
10
11 "charm.land/catwalk/pkg/catwalk"
12 "charm.land/fantasy"
13 "charm.land/fantasy/providers/anthropic"
14 "charm.land/fantasy/providers/google"
15 "charm.land/fantasy/providers/openai"
16 "github.com/charmbracelet/crush/internal/stringext"
17)
18
19type MessageRole string
20
21const (
22 Assistant MessageRole = "assistant"
23 User MessageRole = "user"
24 System MessageRole = "system"
25 Tool MessageRole = "tool"
26)
27
28// mediaLoadFailedPlaceholder is the text substituted for image data that
29// cannot be decoded during session replay.
30const mediaLoadFailedPlaceholder = "[Image data could not be loaded]"
31
32type FinishReason string
33
34const (
35 FinishReasonEndTurn FinishReason = "end_turn"
36 FinishReasonMaxTokens FinishReason = "max_tokens"
37 FinishReasonToolUse FinishReason = "tool_use"
38 FinishReasonCanceled FinishReason = "canceled"
39 FinishReasonError FinishReason = "error"
40
41 // Should never happen
42 FinishReasonUnknown FinishReason = "unknown"
43)
44
45type ContentPart interface {
46 isPart()
47}
48
49type ReasoningContent struct {
50 Thinking string `json:"thinking"`
51 Signature string `json:"signature"`
52 ThoughtSignature string `json:"thought_signature"` // Used for google
53 ToolID string `json:"tool_id"` // Used for openrouter google models
54 ResponsesData *openai.ResponsesReasoningMetadata `json:"responses_data"`
55 StartedAt int64 `json:"started_at,omitempty"`
56 FinishedAt int64 `json:"finished_at,omitempty"`
57}
58
59func (tc ReasoningContent) String() string {
60 return tc.Thinking
61}
62func (ReasoningContent) isPart() {}
63
64type TextContent struct {
65 Text string `json:"text"`
66}
67
68func (tc TextContent) String() string {
69 return tc.Text
70}
71
72func (TextContent) isPart() {}
73
74type ImageURLContent struct {
75 URL string `json:"url"`
76 Detail string `json:"detail,omitempty"`
77}
78
79func (iuc ImageURLContent) String() string {
80 return iuc.URL
81}
82
83func (ImageURLContent) isPart() {}
84
85type BinaryContent struct {
86 Path string
87 MIMEType string
88 Data []byte
89}
90
91func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
92 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
93 if p == catwalk.InferenceProviderOpenAI {
94 return "data:" + bc.MIMEType + ";base64," + base64Encoded
95 }
96 return base64Encoded
97}
98
99func (BinaryContent) isPart() {}
100
101type ToolCall struct {
102 ID string `json:"id"`
103 Name string `json:"name"`
104 Input string `json:"input"`
105 ProviderExecuted bool `json:"provider_executed"`
106 Finished bool `json:"finished"`
107}
108
109func (ToolCall) isPart() {}
110
111type ToolResult struct {
112 ToolCallID string `json:"tool_call_id"`
113 Name string `json:"name"`
114 Content string `json:"content"`
115 Data string `json:"data"`
116 MIMEType string `json:"mime_type"`
117 Metadata string `json:"metadata"`
118 IsError bool `json:"is_error"`
119}
120
121func (ToolResult) isPart() {}
122
123type Finish struct {
124 Reason FinishReason `json:"reason"`
125 Time int64 `json:"time"`
126 Message string `json:"message,omitempty"`
127 Details string `json:"details,omitempty"`
128}
129
130func (Finish) isPart() {}
131
132type Message struct {
133 ID string
134 Role MessageRole
135 SessionID string
136 Parts []ContentPart
137 Model string
138 Provider string
139 CreatedAt int64
140 UpdatedAt int64
141 IsSummaryMessage bool
142}
143
144func (m *Message) Content() TextContent {
145 for _, part := range m.Parts {
146 if c, ok := part.(TextContent); ok {
147 return c
148 }
149 }
150 return TextContent{}
151}
152
153func (m *Message) ReasoningContent() ReasoningContent {
154 for _, part := range m.Parts {
155 if c, ok := part.(ReasoningContent); ok {
156 return c
157 }
158 }
159 return ReasoningContent{}
160}
161
162func (m *Message) ImageURLContent() []ImageURLContent {
163 imageURLContents := make([]ImageURLContent, 0)
164 for _, part := range m.Parts {
165 if c, ok := part.(ImageURLContent); ok {
166 imageURLContents = append(imageURLContents, c)
167 }
168 }
169 return imageURLContents
170}
171
172func (m *Message) BinaryContent() []BinaryContent {
173 binaryContents := make([]BinaryContent, 0)
174 for _, part := range m.Parts {
175 if c, ok := part.(BinaryContent); ok {
176 binaryContents = append(binaryContents, c)
177 }
178 }
179 return binaryContents
180}
181
182func (m *Message) ToolCalls() []ToolCall {
183 toolCalls := make([]ToolCall, 0)
184 for _, part := range m.Parts {
185 if c, ok := part.(ToolCall); ok {
186 toolCalls = append(toolCalls, c)
187 }
188 }
189 return toolCalls
190}
191
192func (m *Message) ToolResults() []ToolResult {
193 toolResults := make([]ToolResult, 0)
194 for _, part := range m.Parts {
195 if c, ok := part.(ToolResult); ok {
196 toolResults = append(toolResults, c)
197 }
198 }
199 return toolResults
200}
201
202func (m *Message) IsFinished() bool {
203 for _, part := range m.Parts {
204 if _, ok := part.(Finish); ok {
205 return true
206 }
207 }
208 return false
209}
210
211func (m *Message) FinishPart() *Finish {
212 for _, part := range m.Parts {
213 if c, ok := part.(Finish); ok {
214 return &c
215 }
216 }
217 return nil
218}
219
220func (m *Message) FinishReason() FinishReason {
221 for _, part := range m.Parts {
222 if c, ok := part.(Finish); ok {
223 return c.Reason
224 }
225 }
226 return ""
227}
228
229func (m *Message) IsThinking() bool {
230 if m.ReasoningContent().Thinking != "" && m.Content().Text == "" && !m.IsFinished() {
231 return true
232 }
233 return false
234}
235
236func (m *Message) AppendContent(delta string) {
237 found := false
238 for i, part := range m.Parts {
239 if c, ok := part.(TextContent); ok {
240 m.Parts[i] = TextContent{Text: c.Text + delta}
241 found = true
242 }
243 }
244 if !found {
245 m.Parts = append(m.Parts, TextContent{Text: delta})
246 }
247}
248
249func (m *Message) AppendReasoningContent(delta string) {
250 found := false
251 for i, part := range m.Parts {
252 if c, ok := part.(ReasoningContent); ok {
253 m.Parts[i] = ReasoningContent{
254 Thinking: c.Thinking + delta,
255 Signature: c.Signature,
256 StartedAt: c.StartedAt,
257 FinishedAt: c.FinishedAt,
258 }
259 found = true
260 }
261 }
262 if !found {
263 m.Parts = append(m.Parts, ReasoningContent{
264 Thinking: delta,
265 StartedAt: time.Now().Unix(),
266 })
267 }
268}
269
270func (m *Message) AppendThoughtSignature(signature string, toolCallID string) {
271 for i, part := range m.Parts {
272 if c, ok := part.(ReasoningContent); ok {
273 m.Parts[i] = ReasoningContent{
274 Thinking: c.Thinking,
275 ThoughtSignature: c.ThoughtSignature + signature,
276 ToolID: toolCallID,
277 Signature: c.Signature,
278 StartedAt: c.StartedAt,
279 FinishedAt: c.FinishedAt,
280 }
281 return
282 }
283 }
284 m.Parts = append(m.Parts, ReasoningContent{ThoughtSignature: signature})
285}
286
287func (m *Message) AppendReasoningSignature(signature string) {
288 for i, part := range m.Parts {
289 if c, ok := part.(ReasoningContent); ok {
290 m.Parts[i] = ReasoningContent{
291 Thinking: c.Thinking,
292 Signature: c.Signature + signature,
293 StartedAt: c.StartedAt,
294 FinishedAt: c.FinishedAt,
295 }
296 return
297 }
298 }
299 m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
300}
301
302func (m *Message) SetReasoningResponsesData(data *openai.ResponsesReasoningMetadata) {
303 for i, part := range m.Parts {
304 if c, ok := part.(ReasoningContent); ok {
305 m.Parts[i] = ReasoningContent{
306 Thinking: c.Thinking,
307 ResponsesData: data,
308 StartedAt: c.StartedAt,
309 FinishedAt: c.FinishedAt,
310 }
311 return
312 }
313 }
314}
315
316func (m *Message) FinishThinking() {
317 for i, part := range m.Parts {
318 if c, ok := part.(ReasoningContent); ok {
319 if c.FinishedAt == 0 {
320 m.Parts[i] = ReasoningContent{
321 Thinking: c.Thinking,
322 Signature: c.Signature,
323 StartedAt: c.StartedAt,
324 FinishedAt: time.Now().Unix(),
325 }
326 }
327 return
328 }
329 }
330}
331
332func (m *Message) ThinkingDuration() time.Duration {
333 reasoning := m.ReasoningContent()
334 if reasoning.StartedAt == 0 {
335 return 0
336 }
337
338 endTime := reasoning.FinishedAt
339 if endTime == 0 {
340 endTime = time.Now().Unix()
341 }
342
343 return time.Duration(endTime-reasoning.StartedAt) * time.Second
344}
345
346func (m *Message) FinishToolCall(toolCallID string) {
347 for i, part := range m.Parts {
348 if c, ok := part.(ToolCall); ok {
349 if c.ID == toolCallID {
350 m.Parts[i] = ToolCall{
351 ID: c.ID,
352 Name: c.Name,
353 Input: c.Input,
354 Finished: true,
355 }
356 return
357 }
358 }
359 }
360}
361
362func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
363 for i, part := range m.Parts {
364 if c, ok := part.(ToolCall); ok {
365 if c.ID == toolCallID {
366 m.Parts[i] = ToolCall{
367 ID: c.ID,
368 Name: c.Name,
369 Input: c.Input + inputDelta,
370 Finished: c.Finished,
371 }
372 return
373 }
374 }
375 }
376}
377
378func (m *Message) AddToolCall(tc ToolCall) {
379 for i, part := range m.Parts {
380 if c, ok := part.(ToolCall); ok {
381 if c.ID == tc.ID {
382 m.Parts[i] = tc
383 return
384 }
385 }
386 }
387 m.Parts = append(m.Parts, tc)
388}
389
390func (m *Message) SetToolCalls(tc []ToolCall) {
391 // remove any existing tool call part it could have multiple
392 parts := make([]ContentPart, 0)
393 for _, part := range m.Parts {
394 if _, ok := part.(ToolCall); ok {
395 continue
396 }
397 parts = append(parts, part)
398 }
399 m.Parts = parts
400 for _, toolCall := range tc {
401 m.Parts = append(m.Parts, toolCall)
402 }
403}
404
405func (m *Message) AddToolResult(tr ToolResult) {
406 m.Parts = append(m.Parts, tr)
407}
408
409func (m *Message) SetToolResults(tr []ToolResult) {
410 for _, toolResult := range tr {
411 m.Parts = append(m.Parts, toolResult)
412 }
413}
414
415// Clone returns a deep copy of the message with an independent Parts slice.
416// This prevents race conditions when the message is modified concurrently.
417func (m *Message) Clone() Message {
418 clone := *m
419 clone.Parts = make([]ContentPart, len(m.Parts))
420 copy(clone.Parts, m.Parts)
421 return clone
422}
423
424func (m *Message) AddFinish(reason FinishReason, message, details string) {
425 // remove any existing finish part
426 for i, part := range m.Parts {
427 if _, ok := part.(Finish); ok {
428 m.Parts = slices.Delete(m.Parts, i, i+1)
429 break
430 }
431 }
432 m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
433}
434
435func (m *Message) AddImageURL(url, detail string) {
436 m.Parts = append(m.Parts, ImageURLContent{URL: url, Detail: detail})
437}
438
439func (m *Message) AddBinary(mimeType string, data []byte) {
440 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
441}
442
443func PromptWithTextAttachments(prompt string, attachments []Attachment) string {
444 var sb strings.Builder
445 sb.WriteString(prompt)
446 addedAttachments := false
447 for _, content := range attachments {
448 if !content.IsText() {
449 continue
450 }
451 if !addedAttachments {
452 sb.WriteString("\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n")
453 addedAttachments = true
454 }
455 if content.FilePath != "" {
456 fmt.Fprintf(&sb, "<file path='%s'>\n", content.FilePath)
457 } else {
458 sb.WriteString("<file>\n")
459 }
460 sb.WriteString("\n")
461 sb.Write(content.Content)
462 sb.WriteString("\n</file>\n")
463 }
464 return sb.String()
465}
466
467func (m *Message) ToAIMessage() []fantasy.Message {
468 var messages []fantasy.Message
469 switch m.Role {
470 case User:
471 var parts []fantasy.MessagePart
472 text := strings.TrimSpace(m.Content().Text)
473 var textAttachments []Attachment
474 for _, content := range m.BinaryContent() {
475 if !strings.HasPrefix(content.MIMEType, "text/") {
476 continue
477 }
478 textAttachments = append(textAttachments, Attachment{
479 FilePath: content.Path,
480 MimeType: content.MIMEType,
481 Content: content.Data,
482 })
483 }
484 text = PromptWithTextAttachments(text, textAttachments)
485 if text != "" {
486 parts = append(parts, fantasy.TextPart{Text: text})
487 }
488 for _, content := range m.BinaryContent() {
489 // skip text attachements
490 if strings.HasPrefix(content.MIMEType, "text/") {
491 continue
492 }
493 parts = append(parts, fantasy.FilePart{
494 Filename: content.Path,
495 Data: content.Data,
496 MediaType: content.MIMEType,
497 })
498 }
499 messages = append(messages, fantasy.Message{
500 Role: fantasy.MessageRoleUser,
501 Content: parts,
502 })
503 case Assistant:
504 var parts []fantasy.MessagePart
505 text := strings.TrimSpace(m.Content().Text)
506 if text != "" {
507 parts = append(parts, fantasy.TextPart{Text: text})
508 }
509 reasoning := m.ReasoningContent()
510 if reasoning.Thinking != "" {
511 reasoningPart := fantasy.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: fantasy.ProviderOptions{}}
512 if reasoning.Signature != "" {
513 reasoningPart.ProviderOptions[anthropic.Name] = &anthropic.ReasoningOptionMetadata{
514 Signature: reasoning.Signature,
515 }
516 }
517 if reasoning.ResponsesData != nil {
518 reasoningPart.ProviderOptions[openai.Name] = reasoning.ResponsesData
519 }
520 if reasoning.ThoughtSignature != "" {
521 reasoningPart.ProviderOptions[google.Name] = &google.ReasoningMetadata{
522 Signature: reasoning.ThoughtSignature,
523 ToolID: reasoning.ToolID,
524 }
525 }
526 parts = append(parts, reasoningPart)
527 }
528 for _, call := range m.ToolCalls() {
529 parts = append(parts, fantasy.ToolCallPart{
530 ToolCallID: call.ID,
531 ToolName: call.Name,
532 Input: call.Input,
533 ProviderExecuted: call.ProviderExecuted,
534 })
535 }
536 messages = append(messages, fantasy.Message{
537 Role: fantasy.MessageRoleAssistant,
538 Content: parts,
539 })
540 case Tool:
541 var parts []fantasy.MessagePart
542 for _, result := range m.ToolResults() {
543 var content fantasy.ToolResultOutputContent
544 if result.IsError {
545 content = fantasy.ToolResultOutputContentError{
546 Error: errors.New(result.Content),
547 }
548 } else if result.Data != "" {
549 if stringext.IsValidBase64(result.Data) {
550 content = fantasy.ToolResultOutputContentMedia{
551 Data: result.Data,
552 MediaType: result.MIMEType,
553 }
554 } else {
555 content = fantasy.ToolResultOutputContentText{
556 Text: mediaLoadFailedPlaceholder,
557 }
558 }
559 } else {
560 content = fantasy.ToolResultOutputContentText{
561 Text: result.Content,
562 }
563 }
564 parts = append(parts, fantasy.ToolResultPart{
565 ToolCallID: result.ToolCallID,
566 Output: content,
567 })
568 }
569 messages = append(messages, fantasy.Message{
570 Role: fantasy.MessageRoleTool,
571 Content: parts,
572 })
573 }
574 return messages
575}