1package core
2
3import (
4 "bufio"
5 "bytes"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "io"
11 "os"
12 "path/filepath"
13 "sort"
14 "strings"
15 "time"
16
17 "github.com/google/uuid"
18 "github.com/patriceckhart/zot/packages/provider"
19)
20
21// Session is a JSONL-backed conversation transcript tied to a cwd.
22type Session struct {
23 ID string
24 Path string
25 Meta SessionMeta
26 writer *os.File
27 buf *bufio.Writer
28
29 // freshFile is true when the file was created by NewSession (this
30 // process owns it) and false when OpenSession reopened an existing
31 // transcript. Used by Close() to delete the file if the run never
32 // appended any messages — prevents a flood of empty session files
33 // from sessions the user opens then exits without prompting.
34 freshFile bool
35
36 // messagesAppended counts AppendMessage calls. Combined with
37 // freshFile it tells Close() whether the session left any content
38 // worth keeping.
39 messagesAppended int
40}
41
42// SessionMeta is written as the first line of every session file.
43type SessionMeta struct {
44 ID string `json:"id"`
45 CWD string `json:"cwd"`
46 Model string `json:"model"`
47 Provider string `json:"provider"`
48 Started time.Time `json:"started"`
49 Version string `json:"version"`
50 Title string `json:"title,omitempty"`
51
52 // Parent is the ID of the session this one was forked from, or
53 // empty for top-level sessions. The tree picker walks parents
54 // upward and sibling files (same cwd dir, same parent ID)
55 // laterally to render the branch topology.
56 Parent string `json:"parent,omitempty"`
57 // ForkPoint is the 0-indexed message position within the parent
58 // transcript where this branch diverges. Messages 0..ForkPoint-1
59 // are copied from the parent verbatim; the user's next turn on
60 // the child session continues from there.
61 ForkPoint int `json:"fork_point,omitempty"`
62}
63
64// sessionLine is the on-disk row type. Message is kept as a raw
65// JSON message on reads (because Content is an interface slice that
66// the default unmarshaler cannot reconstruct); it is written with a
67// regular provider.Message value.
68type sessionLine struct {
69 Type string `json:"type"`
70 Meta *SessionMeta `json:"meta,omitempty"`
71 Message *provider.Message `json:"message,omitempty"`
72 Messages []provider.Message `json:"messages,omitempty"`
73 Usage *provider.Usage `json:"usage,omitempty"`
74 Cumulative *provider.Usage `json:"cumulative,omitempty"`
75}
76
77type sessionLineHead struct {
78 Type string `json:"type"`
79}
80
81// SessionsDir returns the per-cwd sessions directory under root.
82func SessionsDir(root, cwd string) string {
83 sum := sha256.Sum256([]byte(cwd))
84 short := hex.EncodeToString(sum[:8])
85 return filepath.Join(root, "sessions", short)
86}
87
88// NewSession creates and opens a new session file under
89// SessionsDir(root, cwd) with an autogenerated, time-stamped name.
90func NewSession(root, cwd, providerName, model, version string) (*Session, error) {
91 dir := SessionsDir(root, cwd)
92 if err := os.MkdirAll(dir, 0o755); err != nil {
93 return nil, err
94 }
95 id := uuid.NewString()
96 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), id[:8])
97 p := filepath.Join(dir, name)
98 return newSessionAt(p, cwd, providerName, model, version)
99}
100
101// NewSessionAtPath creates a session at an explicit file path. Used
102// by callers (notably the swarm-agent child) that need the session
103// file to live at a path chosen by their parent rather than under
104// SessionsDir. Returns an error if the file already exists — use
105// OpenSession for that case.
106func NewSessionAtPath(path, cwd, providerName, model, version string) (*Session, error) {
107 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
108 return nil, err
109 }
110 return newSessionAt(path, cwd, providerName, model, version)
111}
112
113// newSessionAt is the shared implementation. Both NewSession and
114// NewSessionAtPath funnel through here so the meta-line layout,
115// freshFile bookkeeping, and id format stay identical.
116func newSessionAt(p, cwd, providerName, model, version string) (*Session, error) {
117 id := uuid.NewString()
118 f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
119 if err != nil {
120 return nil, err
121 }
122 s := &Session{
123 ID: id,
124 Path: p,
125 Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
126 writer: f,
127 buf: bufio.NewWriter(f),
128 freshFile: true,
129 }
130 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
131 f.Close()
132 return nil, err
133 }
134 return s, nil
135}
136
137func forEachJSONLLine(r io.Reader, fn func([]byte) error) error {
138 br := bufio.NewReader(r)
139 for {
140 line, err := br.ReadBytes('\n')
141 if len(line) > 0 {
142 line = bytes.TrimRight(line, "\r\n")
143 if len(line) > 0 {
144 if ferr := fn(line); ferr != nil {
145 return ferr
146 }
147 }
148 }
149 if err == io.EOF {
150 return nil
151 }
152 if err != nil {
153 return err
154 }
155 }
156}
157
158// SessionUsage returns the most recent cumulative usage row stored in
159// a session file. Sessions append one usage row per completed turn; the
160// latest row's cumulative field is the session total. Missing usage rows
161// are valid for old/empty sessions and return the zero value.
162func SessionUsage(path string) (provider.Usage, error) {
163 cum, _, err := SessionUsageDetail(path)
164 return cum, err
165}
166
167// SessionUsageDetail returns the latest cumulative usage and the
168// per-turn usage of the final completed turn. The per-turn row drives
169// the live "context used" gauge in the status bar (input + cache
170// approximates the prompt size the model just saw), letting the TUI
171// rehydrate the gauge on resume instead of starting at 0% until the
172// next turn lands.
173func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err error) {
174 f, ferr := os.Open(path)
175 if ferr != nil {
176 return provider.Usage{}, provider.Usage{}, ferr
177 }
178 defer f.Close()
179
180 // Some historical sessions logged the per-turn `usage` field as a copy
181 // of `cumulative` instead of the true delta. To recover an accurate
182 // last-turn snapshot (used by the status-bar context gauge on resume),
183 // we always derive lastTurn from the delta between the final two
184 // cumulative rows. For prompt-size purposes, cache_read/cache_write
185 // reflect the most recent prompt directly, so we take those from the
186 // final cumulative row as-is rather than as a delta.
187 var prevCum provider.Usage
188 var haveCum bool
189 if ierr := forEachJSONLLine(f, func(line []byte) error {
190 var head sessionLineHead
191 if err := json.Unmarshal(line, &head); err != nil || head.Type != "usage" {
192 return nil
193 }
194 var row struct {
195 Cumulative provider.Usage `json:"cumulative"`
196 }
197 if err := json.Unmarshal(line, &row); err != nil {
198 return nil
199 }
200 if haveCum {
201 prevCum = cumulative
202 }
203 cumulative = row.Cumulative
204 haveCum = true
205 return nil
206 }); ierr != nil {
207 return provider.Usage{}, provider.Usage{}, ierr
208 }
209 if haveCum {
210 // input/output are monotonic totals -> per-turn = delta.
211 lastTurn.InputTokens = nonNegDelta(cumulative.InputTokens, prevCum.InputTokens)
212 lastTurn.OutputTokens = nonNegDelta(cumulative.OutputTokens, prevCum.OutputTokens)
213 // cache_read/write on the final row already represent the last prompt's
214 // cache hit/creation, not a running total of bytes; use directly.
215 lastTurn.CacheReadTokens = cumulative.CacheReadTokens - prevCum.CacheReadTokens
216 if lastTurn.CacheReadTokens < 0 {
217 lastTurn.CacheReadTokens = cumulative.CacheReadTokens
218 }
219 lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens - prevCum.CacheWriteTokens
220 if lastTurn.CacheWriteTokens < 0 {
221 lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens
222 }
223 lastTurn.CostUSD = cumulative.CostUSD - prevCum.CostUSD
224 if lastTurn.CostUSD < 0 {
225 lastTurn.CostUSD = 0
226 }
227 }
228 return cumulative, lastTurn, nil
229}
230
231func nonNegDelta(cur, prev int) int {
232 if cur < prev {
233 return cur
234 }
235 return cur - prev
236}
237
238// OpenSession opens an existing session for appending.
239func OpenSession(path string) (*Session, []provider.Message, error) {
240 f, err := os.Open(path)
241 if err != nil {
242 return nil, nil, err
243 }
244 defer f.Close()
245
246 var meta SessionMeta
247 var messages []provider.Message
248 if err := forEachJSONLLine(f, func(line []byte) error {
249 var head sessionLineHead
250 if err := json.Unmarshal(line, &head); err != nil {
251 return nil
252 }
253 switch head.Type {
254 case "meta":
255 var row struct {
256 Meta SessionMeta `json:"meta"`
257 }
258 if err := json.Unmarshal(line, &row); err == nil {
259 meta = row.Meta
260 }
261 case "message":
262 if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 {
263 messages = append(messages, msg)
264 }
265 case "compaction":
266 if compacted, err := hydrateCompaction(line); err == nil {
267 messages = compacted
268 }
269 }
270 return nil
271 }); err != nil {
272 return nil, nil, err
273 }
274 messages = repairToolUseResultPairs(messages)
275 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
276 if err != nil {
277 return nil, nil, err
278 }
279 s := &Session{ID: meta.ID, Path: path, Meta: meta, writer: out, buf: bufio.NewWriter(out)}
280 return s, messages, nil
281}
282
283// repairToolUseResultPairs walks a restored transcript and
284// synthesises stub tool_result blocks for any assistant
285// tool_use blocks that aren't paired with a matching result in
286// the next message. Anthropic (and OpenAI via the responses API)
287// reject any request whose transcript leaves a tool_use without
288// its matching tool_result immediately after, with errors like:
289//
290// messages.8: `tool_use` ids were found without `tool_result`
291// blocks immediately after
292//
293// Corruption gets into the transcript two ways we know of:
294//
295// - Older zot builds that persisted the assistant tool_use row
296// before the tool_result row, then crashed between the two.
297// - Abort paths in older builds that didn't drop the mid-turn
298// assistant message cleanly.
299//
300// Rather than change runtime semantics (which would risk hiding a
301// real bug), we scrub on load: any unmatched tool_use gets a stub
302// tool_result injected as a RoleTool message so the next
303// outbound request passes the provider's validity check. The stub
304// reads "tool call was aborted; no result recorded." so the
305// model can see what happened and decide whether to retry.
306//
307// Runs once per OpenSession call. No cost on the hot path.
308func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {
309 if len(msgs) == 0 {
310 return msgs
311 }
312 out := make([]provider.Message, 0, len(msgs)+2)
313 for i, m := range msgs {
314 out = append(out, m)
315 if m.Role != provider.RoleAssistant {
316 continue
317 }
318 // Collect tool_use ids in this assistant message.
319 var ids []string
320 for _, c := range m.Content {
321 if tc, ok := c.(provider.ToolCallBlock); ok {
322 ids = append(ids, tc.ID)
323 }
324 }
325 if len(ids) == 0 {
326 continue
327 }
328 // Look at the next message (if any) and collect tool_result
329 // CallIDs it covers.
330 have := map[string]bool{}
331 if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
332 for _, c := range msgs[i+1].Content {
333 if tr, ok := c.(provider.ToolResultBlock); ok {
334 have[tr.CallID] = true
335 }
336 }
337 }
338 // Build stubs for any missing id.
339 var stubs []provider.Content
340 for _, id := range ids {
341 if have[id] {
342 continue
343 }
344 stubs = append(stubs, provider.ToolResultBlock{
345 CallID: id,
346 Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
347 IsError: true,
348 })
349 }
350 if len(stubs) == 0 {
351 continue
352 }
353 // Merge into the next tool-role message if present,
354 // otherwise insert a synthetic one right after the
355 // assistant message. Merging keeps the tool-role row
356 // count stable; inserting handles the common case where
357 // no tool message was persisted at all.
358 if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
359 msgs[i+1].Content = append(msgs[i+1].Content, stubs...)
360 // We already appended m to out; the modified next
361 // message will be appended on the following iteration.
362 continue
363 }
364 out = append(out, provider.Message{
365 Role: provider.RoleTool,
366 Content: stubs,
367 Time: m.Time,
368 })
369 }
370 return out
371}
372
373// LatestSession returns the most recent session file for cwd, or "".
374func LatestSession(root, cwd string) string {
375 paths := ListSessions(root, cwd)
376 if len(paths) == 0 {
377 return ""
378 }
379 return paths[0]
380}
381
382// SessionSummary describes one on-disk session at a glance for UI pickers.
383type SessionSummary struct {
384 Path string
385 Started time.Time
386 Model string
387 Provider string
388 MessageCount int
389 FirstUserText string
390 TotalCost float64
391 Title string
392}
393
394// RenameSession updates the title field in the session's meta line.
395// It rewrites the first line of the file (the meta line) with the
396// updated title.
397// RenameSession appends a rename line to the session file. This is
398// safe even for the currently active session because it opens the
399// file independently and appends (doesn't rewrite).
400func RenameSession(path, title string) error {
401 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
402 if err != nil {
403 return err
404 }
405 defer f.Close()
406 line, _ := json.Marshal(map[string]string{"type": "rename", "title": title})
407 line = append(line, '\n')
408 _, err = f.Write(line)
409 return err
410}
411
412// DescribeSessions returns lightweight summaries for every session in
413// cwd, newest first. Parses only the first few lines and the last usage
414// line so it's cheap to run on every dialog open.
415func DescribeSessions(root, cwd string) []SessionSummary {
416 paths := ListSessions(root, cwd)
417 summaries := make([]SessionSummary, 0, len(paths))
418 for _, p := range paths {
419 summaries = append(summaries, describeSession(p))
420 }
421 return summaries
422}
423
424func describeSession(path string) SessionSummary {
425 s := SessionSummary{Path: path}
426 f, err := os.Open(path)
427 if err != nil {
428 return s
429 }
430 defer f.Close()
431 _ = forEachJSONLLine(f, func(line []byte) error {
432 var head sessionLineHead
433 if err := json.Unmarshal(line, &head); err != nil {
434 return nil
435 }
436 switch head.Type {
437 case "meta":
438 var row struct {
439 Meta SessionMeta `json:"meta"`
440 }
441 if err := json.Unmarshal(line, &row); err == nil {
442 s.Started = row.Meta.Started
443 s.Model = row.Meta.Model
444 s.Provider = row.Meta.Provider
445 s.Title = row.Meta.Title
446 }
447 case "message":
448 s.MessageCount++
449 if s.FirstUserText == "" {
450 s.FirstUserText = firstUserText(line)
451 }
452 case "compaction":
453 if compacted, err := hydrateCompaction(line); err == nil {
454 s.MessageCount = len(compacted)
455 if s.FirstUserText == "" && len(compacted) > 0 {
456 s.FirstUserText = firstTextFromMessage(compacted[0])
457 }
458 }
459 case "rename":
460 var row struct {
461 Title string `json:"title"`
462 }
463 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
464 s.Title = row.Title
465 }
466 case "usage":
467 var row struct {
468 Cumulative provider.Usage `json:"cumulative"`
469 }
470 if err := json.Unmarshal(line, &row); err == nil {
471 s.TotalCost = row.Cumulative.CostUSD
472 }
473 }
474 return nil
475 })
476 return s
477}
478
479func firstUserText(line []byte) string {
480 var row struct {
481 Message struct {
482 Role string `json:"role"`
483 Content []struct {
484 Text string `json:"text"`
485 } `json:"content"`
486 } `json:"message"`
487 }
488 if err := json.Unmarshal(line, &row); err != nil {
489 return ""
490 }
491 if row.Message.Role != "user" {
492 return ""
493 }
494 for _, c := range row.Message.Content {
495 if c.Text != "" {
496 return c.Text
497 }
498 }
499 return ""
500}
501
502func firstTextFromMessage(msg provider.Message) string {
503 for _, c := range msg.Content {
504 if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
505 return tb.Text
506 }
507 }
508 return ""
509}
510
511// PruneEmptySessions deletes session files in cwd's session directory
512// that contain only a meta line (no messages were ever appended).
513// Cleans up the backlog of empty stubs created by old zot versions
514// that wrote a meta line at NewSession time and never followed up.
515// Errors are swallowed; the caller treats this as best-effort.
516func PruneEmptySessions(root, cwd string) {
517 dir := SessionsDir(root, cwd)
518 entries, err := os.ReadDir(dir)
519 if err != nil {
520 return
521 }
522 for _, e := range entries {
523 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
524 continue
525 }
526 p := filepath.Join(dir, e.Name())
527 if sessionHasNoMessages(p) {
528 _ = os.Remove(p)
529 }
530 }
531}
532
533// sessionHasNoMessages returns true when the file at path contains
534// no lines of type "message". Meta-only / usage-only files count as
535// empty. Used by PruneEmptySessions and the Describe path.
536func sessionHasNoMessages(path string) bool {
537 f, err := os.Open(path)
538 if err != nil {
539 return false
540 }
541 defer f.Close()
542 hasMessage := false
543 _ = forEachJSONLLine(f, func(line []byte) error {
544 var head sessionLineHead
545 if err := json.Unmarshal(line, &head); err != nil {
546 return nil
547 }
548 if head.Type == "message" {
549 hasMessage = true
550 return io.EOF
551 }
552 return nil
553 })
554 return !hasMessage
555}
556
557// ListSessions returns session file paths for cwd, most-recently-
558// modified first. Sorting on filesystem ModTime instead of the
559// timestamp embedded in the filename means a long-running session
560// the user actually returned to recently floats to the top of
561// /sessions, /continue, and the resume picker, even when it was
562// originally created days earlier than newer but idle sessions.
563// Files with identical ModTime fall back to filename desc so the
564// order stays stable across calls.
565func ListSessions(root, cwd string) []string {
566 dir := SessionsDir(root, cwd)
567 entries, err := os.ReadDir(dir)
568 if err != nil {
569 return nil
570 }
571 type rec struct {
572 path string
573 mod time.Time
574 }
575 var files []rec
576 for _, e := range entries {
577 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
578 continue
579 }
580 p := filepath.Join(dir, e.Name())
581 info, err := e.Info()
582 if err != nil {
583 continue
584 }
585 files = append(files, rec{path: p, mod: info.ModTime()})
586 }
587 sort.Slice(files, func(i, j int) bool {
588 if !files[i].mod.Equal(files[j].mod) {
589 return files[i].mod.After(files[j].mod)
590 }
591 return files[i].path > files[j].path
592 })
593 out := make([]string, 0, len(files))
594 for _, r := range files {
595 out = append(out, r.path)
596 }
597 return out
598}
599
600// AppendMessage writes a message to the session.
601func (s *Session) AppendMessage(m provider.Message) error {
602 if s == nil {
603 return nil
604 }
605 if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
606 return err
607 }
608 s.messagesAppended++
609 return nil
610}
611
612// AppendCompaction writes a checkpoint that replaces all earlier
613// transcript rows when the session is resumed. The old rows remain in
614// the JSONL file for audit/export, while loaders use the latest
615// compaction row as the effective transcript.
616func (s *Session) AppendCompaction(messages []provider.Message) error {
617 if s == nil {
618 return nil
619 }
620 if err := s.writeLine(sessionLine{Type: "compaction", Messages: messages}); err != nil {
621 return err
622 }
623 s.messagesAppended = len(messages)
624 return nil
625}
626
627// UpdateModel records a provider/model switch in the session file.
628// The reader keeps the most recent meta entry, so the session resumes
629// with the updated model.
630func (s *Session) UpdateModel(providerName, model string) error {
631 if s == nil {
632 return nil
633 }
634 s.Meta.Provider = providerName
635 s.Meta.Model = model
636 return s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta})
637}
638
639// AppendUsage writes a usage row to the session.
640func (s *Session) AppendUsage(u, cum provider.Usage) error {
641 if s == nil {
642 return nil
643 }
644 return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
645}
646
647// Close flushes and closes the session file. If the session was
648// freshly created in this process and never had any messages
649// appended (the user opened zot, looked around, and exited without
650// prompting), the file is deleted on close so the sessions list
651// doesn't fill up with empty meta-only stubs.
652func (s *Session) Close() error {
653 if s == nil {
654 return nil
655 }
656 flushErr := s.buf.Flush()
657 closeErr := s.writer.Close()
658 if s.freshFile && s.messagesAppended == 0 {
659 // Best-effort cleanup. We deliberately don't propagate the
660 // remove error: if it fails (file already gone, perms changed)
661 // the worst case is one stale empty file in the listing.
662 _ = os.Remove(s.Path)
663 }
664 if flushErr != nil {
665 return flushErr
666 }
667 return closeErr
668}
669
670func (s *Session) writeLine(row sessionLine) error {
671 b, err := json.Marshal(row)
672 if err != nil {
673 return err
674 }
675 if _, err := s.buf.Write(b); err != nil {
676 return err
677 }
678 if err := s.buf.WriteByte('\n'); err != nil {
679 return err
680 }
681 return s.buf.Flush()
682}
683
684// ---- content (de)serialization ----
685//
686// provider.Content is an interface; encoding/json drops type information.
687// We persist messages by reading the raw "message" object back and
688// rebuilding Content from discriminated fields.
689
690func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
691 var row struct {
692 Messages []json.RawMessage `json:"messages"`
693 }
694 if err := json.Unmarshal(lineBytes, &row); err != nil {
695 return nil, err
696 }
697 messages := make([]provider.Message, 0, len(row.Messages))
698 for _, raw := range row.Messages {
699 msg, err := hydrateMessageObject(raw)
700 if err == nil && len(msg.Content) > 0 {
701 messages = append(messages, msg)
702 }
703 }
704 return messages, nil
705}
706
707func hydrateMessage(lineBytes []byte) (provider.Message, error) {
708 var row struct {
709 Message json.RawMessage `json:"message"`
710 }
711 if err := json.Unmarshal(lineBytes, &row); err != nil {
712 return provider.Message{}, err
713 }
714 return hydrateMessageObject(row.Message)
715}
716
717func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
718 var row struct {
719 Role provider.Role `json:"role"`
720 Content []json.RawMessage `json:"content"`
721 Time time.Time `json:"time"`
722 Meta map[string]string `json:"meta,omitempty"`
723 }
724 if err := json.Unmarshal(rawMessage, &row); err != nil {
725 return provider.Message{}, err
726 }
727 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta}
728 for _, raw := range row.Content {
729 var head struct {
730 Text string `json:"text"`
731 MimeType string `json:"mime_type"`
732 Data []byte `json:"data"`
733 ID string `json:"id"`
734 Name string `json:"name"`
735 CallID string `json:"call_id"`
736 ReasoningID string `json:"reasoning_id"`
737 Summary string `json:"summary"`
738 Encrypted string `json:"encrypted_content"`
739 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
740 }
741 if err := json.Unmarshal(raw, &head); err != nil {
742 continue
743 }
744 // Discriminate by presence of fields.
745 switch {
746 case head.ReasoningID != "" || head.Encrypted != "":
747 msg.Content = append(msg.Content, provider.ReasoningBlock{
748 ID: head.ReasoningID,
749 Summary: head.Summary,
750 Encrypted: head.Encrypted,
751 })
752 case head.Name != "" && head.ID != "":
753 var tc struct {
754 ID string `json:"id"`
755 Name string `json:"name"`
756 Arguments json.RawMessage `json:"arguments"`
757 }
758 _ = json.Unmarshal(raw, &tc)
759 msg.Content = append(msg.Content, provider.ToolCallBlock{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments})
760 case head.CallID != "":
761 var tr struct {
762 CallID string `json:"call_id"`
763 Content []json.RawMessage `json:"content"`
764 IsError bool `json:"is_error"`
765 }
766 _ = json.Unmarshal(raw, &tr)
767 block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}
768 for _, c := range tr.Content {
769 var inner struct {
770 Text string `json:"text"`
771 MimeType string `json:"mime_type"`
772 Data []byte `json:"data"`
773 }
774 _ = json.Unmarshal(c, &inner)
775 if inner.MimeType != "" {
776 block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})
777 } else {
778 block.Content = append(block.Content, provider.TextBlock{Text: inner.Text})
779 }
780 }
781 msg.Content = append(msg.Content, block)
782 case head.MimeType != "":
783 msg.Content = append(msg.Content, provider.ImageBlock{MimeType: head.MimeType, Data: head.Data})
784 default:
785 msg.Content = append(msg.Content, provider.TextBlock{Text: head.Text})
786 }
787 }
788 return msg, nil
789}