1package core
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "os"
14 "path/filepath"
15 "sort"
16 "strings"
17 "time"
18
19 "github.com/bnema/zut/packages/provider"
20 "github.com/google/uuid"
21)
22
23// Session is a JSONL-backed conversation transcript tied to a cwd.
24type Session struct {
25 ID string
26 Path string
27 Meta SessionMeta
28 // Title is the effective title after applying append-only rename rows.
29 // Meta.Title remains the original meta-line value for compatibility.
30 Title string
31 // ExtensionState contains the latest opaque state snapshot recorded by
32 // each extension for this session/branch. It is never sent to providers.
33 ExtensionState map[string]json.RawMessage
34 writer *os.File
35 buf *bufio.Writer
36
37 // freshFile is true when the file was created by NewSession (this
38 // process owns it) and false when OpenSession reopened an existing
39 // transcript. Used by Close() to delete the file if the run never
40 // appended any messages — prevents a flood of empty session files
41 // from sessions the user opens then exits without prompting.
42 freshFile bool
43
44 // messagesAppended counts AppendMessage calls. Combined with
45 // freshFile it tells Close() whether the session left any content
46 // worth keeping.
47 messagesAppended int
48}
49
50// SessionMeta is written as the first line of every session file.
51type SessionMeta struct {
52 ID string `json:"id"`
53 CWD string `json:"cwd"`
54 Model string `json:"model"`
55 Provider string `json:"provider"`
56 Started time.Time `json:"started"`
57 Version string `json:"version"`
58 Title string `json:"title,omitempty"`
59
60 // Parent is the ID of the session this one was forked from, or
61 // empty for top-level sessions. The tree picker walks parents
62 // upward and sibling files (same cwd dir, same parent ID)
63 // laterally to render the branch topology.
64 Parent string `json:"parent,omitempty"`
65 // ForkPoint is the 0-indexed message position within the parent
66 // transcript where this branch diverges. Messages 0..ForkPoint-1
67 // are copied from the parent verbatim; the user's next turn on
68 // the child session continues from there.
69 ForkPoint int `json:"fork_point,omitempty"`
70 // HideFromSessions hides internal tree-navigation branches from the
71 // flat /sessions picker while keeping them available to /session tree.
72 HideFromSessions bool `json:"hide_from_sessions,omitempty"`
73}
74
75// sessionLine is the on-disk row type. Message is kept as a raw
76// JSON message on reads (because Content is an interface slice that
77// the default unmarshaler cannot reconstruct); it is written with a
78// regular provider.Message value.
79type sessionLine struct {
80 Type string `json:"type"`
81 Title string `json:"title,omitempty"`
82 Generated bool `json:"generated,omitempty"`
83 Meta *SessionMeta `json:"meta,omitempty"`
84 Message *provider.Message `json:"message,omitempty"`
85 Messages *[]provider.Message `json:"messages,omitempty"`
86 Usage *provider.Usage `json:"usage,omitempty"`
87 Cumulative *provider.Usage `json:"cumulative,omitempty"`
88 Extension string `json:"extension,omitempty"`
89 State json.RawMessage `json:"state,omitempty"`
90}
91
92type sessionLineHead struct {
93 Type string `json:"type"`
94}
95
96// SessionUsageCheckpoint records the cumulative usage known when a usage
97// row was written. MessageCount uses the effective transcript (after the
98// latest compaction and tool-pair repair), not the number of raw JSONL rows.
99type SessionUsageCheckpoint struct {
100 MessageCount int
101 Cumulative provider.Usage
102 CompactionGeneration int
103}
104
105// SessionSnapshot is the effective, in-memory view of a session file.
106// Messages are the transcript that a resumed agent should use. The raw
107// JSONL audit history is deliberately not exposed here because message
108// indices used by tree navigation and branching must agree.
109type SessionSnapshot struct {
110 Meta SessionMeta
111 Title string
112 Messages []provider.Message
113 ExtensionState map[string]json.RawMessage
114 UsageCheckpoints []SessionUsageCheckpoint
115 CompactionGeneration int
116}
117
118// SessionHistorySegment is one provider-valid transcript era. A compaction
119// starts a new segment containing the replacement summary and preserved tail;
120// earlier segments remain available for session-tree checkout without being
121// used to resume the live agent.
122type SessionHistorySegment struct {
123 Compacted bool
124 Messages []provider.Message
125 UsageCheckpoints []SessionUsageCheckpoint
126}
127
128// SessionHistory contains every persisted transcript era in chronological
129// order. Unlike SessionSnapshot, it intentionally retains segments replaced by
130// compaction so navigation can fork from older user and assistant messages.
131type SessionHistory struct {
132 Meta SessionMeta
133 Segments []SessionHistorySegment
134}
135
136// SessionsDir returns the per-cwd sessions directory under root.
137func SessionsDir(root, cwd string) string {
138 sum := sha256.Sum256([]byte(cwd))
139 short := hex.EncodeToString(sum[:8])
140 return filepath.Join(root, "sessions", short)
141}
142
143// NewSession creates and opens a new session file under
144// SessionsDir(root, cwd) with an autogenerated, time-stamped name.
145func NewSession(root, cwd, providerName, model, version string) (*Session, error) {
146 dir := SessionsDir(root, cwd)
147 if err := os.MkdirAll(dir, 0o755); err != nil {
148 return nil, err
149 }
150 id := uuid.NewString()
151 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), id[:8])
152 p := filepath.Join(dir, name)
153 return newSessionAt(p, cwd, providerName, model, version)
154}
155
156// NewSessionAtPath creates a session at an explicit file path. Used
157// by callers (notably the subagent worker) that need the session
158// file to live at a path chosen by their parent rather than under
159// SessionsDir. Returns an error if the file already exists — use
160// OpenSession for that case.
161func NewSessionAtPath(path, cwd, providerName, model, version string) (*Session, error) {
162 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
163 return nil, err
164 }
165 return newSessionAt(path, cwd, providerName, model, version)
166}
167
168// newSessionAt is the shared implementation. Both NewSession and
169// NewSessionAtPath funnel through here so the meta-line layout,
170// freshFile bookkeeping, and id format stay identical.
171func newSessionAt(p, cwd, providerName, model, version string) (*Session, error) {
172 id := uuid.NewString()
173 f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
174 if err != nil {
175 return nil, err
176 }
177 s := &Session{
178 ID: id,
179 Path: p,
180 Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
181 writer: f,
182 buf: bufio.NewWriter(f),
183 freshFile: true,
184 ExtensionState: map[string]json.RawMessage{},
185 }
186 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
187 f.Close()
188 return nil, err
189 }
190 return s, nil
191}
192
193func forEachJSONLLine(r io.Reader, fn func([]byte) error) error {
194 return forEachJSONLLineContext(context.Background(), r, fn)
195}
196
197func forEachJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte) error) error {
198 br := bufio.NewReader(r)
199 for {
200 if err := contextErr(ctx); err != nil {
201 return err
202 }
203 line, err := br.ReadBytes('\n')
204 if len(line) > 0 {
205 line = bytes.TrimRight(line, "\r\n")
206 if len(line) > 0 {
207 if ferr := fn(line); ferr != nil {
208 return ferr
209 }
210 }
211 }
212 if err == io.EOF {
213 return nil
214 }
215 if err != nil {
216 return err
217 }
218 }
219}
220
221func contextErr(ctx context.Context) error {
222 if ctx == nil {
223 return nil
224 }
225 select {
226 case <-ctx.Done():
227 return ctx.Err()
228 default:
229 return nil
230 }
231}
232
233// SessionUsage returns the most recent cumulative usage row stored in
234// a session file. Sessions append one usage row per completed turn; the
235// latest row's cumulative field is the session total. Missing usage rows
236// are valid for old/empty sessions and return the zero value.
237func SessionUsage(path string) (provider.Usage, error) {
238 cum, _, err := SessionUsageDetail(path)
239 return cum, err
240}
241
242// SessionUsageDetail returns the latest cumulative usage and the
243// per-turn usage of the final completed turn. It reads through the same
244// strict snapshot path as OpenSession, so a truncated or malformed session
245// cannot silently reset the usage display.
246func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err error) {
247 snapshot, err := ReadSessionSnapshot(path)
248 if err != nil {
249 return provider.Usage{}, provider.Usage{}, err
250 }
251 if len(snapshot.UsageCheckpoints) == 0 {
252 return provider.Usage{}, provider.Usage{}, nil
253 }
254 latestCheckpoint := snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1]
255 if latestCheckpoint.CompactionGeneration < snapshot.CompactionGeneration {
256 // The latest usage row belongs to the pre-compaction context. Keep
257 // cumulative cost and totals, but reset the live context gauge until
258 // a usage row from the rebuilt transcript is persisted.
259 return latestCheckpoint.Cumulative, provider.Usage{}, nil
260 }
261 last := latestCheckpoint.Cumulative
262 var prev provider.Usage
263 if len(snapshot.UsageCheckpoints) > 1 {
264 prev = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-2].Cumulative
265 }
266 lastTurn.InputTokens = nonNegDelta(last.InputTokens, prev.InputTokens)
267 lastTurn.OutputTokens = nonNegDelta(last.OutputTokens, prev.OutputTokens)
268 lastTurn.ReasoningTokens = nonNegDelta(last.ReasoningTokens, prev.ReasoningTokens)
269 lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown
270 lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens)
271 lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens)
272 lastTurn.CostUSD = last.CostUSD - prev.CostUSD
273 if lastTurn.CostUSD < 0 {
274 lastTurn.CostUSD = 0
275 }
276 return last, lastTurn, nil
277}
278
279func nonNegDelta(cur, prev int) int {
280 if cur < prev {
281 return cur
282 }
283 return cur - prev
284}
285
286// ReadSessionSnapshot reads a complete session file and reconstructs the
287// effective transcript. It is intentionally stricter than the lightweight
288// listing helpers: a caller that is about to resume, render, or branch a
289// session must not receive a plausible-looking partial transcript.
290func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
291 return readSessionSnapshot(context.Background(), path)
292}
293
294func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, error) {
295 if err := contextErr(ctx); err != nil {
296 return SessionSnapshot{}, err
297 }
298 f, err := os.Open(path)
299 if err != nil {
300 return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)
301 }
302 defer f.Close()
303
304 var snapshot SessionSnapshot
305 var sawMeta bool
306 generation := 0
307 type rawCheckpoint struct {
308 messageCount int
309 cumulative provider.Usage
310 generation int
311 }
312 var rawCheckpoints []rawCheckpoint
313 titleFromRename := false
314 extensionState := map[string]json.RawMessage{}
315
316 err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
317 var head sessionLineHead
318 if err := json.Unmarshal(line, &head); err != nil {
319 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
320 }
321 if head.Type == "" {
322 return fmt.Errorf("line %d: missing row type", lineNo)
323 }
324 if !sawMeta && head.Type != "meta" {
325 return fmt.Errorf("line %d: first row is not meta", lineNo)
326 }
327
328 switch head.Type {
329 case "meta":
330 var row sessionLine
331 if err := json.Unmarshal(line, &row); err != nil {
332 return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
333 }
334 if row.Meta == nil || row.Meta.ID == "" {
335 return fmt.Errorf("line %d: meta row has no session id", lineNo)
336 }
337 snapshot.Meta = *row.Meta
338 if !titleFromRename && row.Meta.Title != "" {
339 snapshot.Title = row.Meta.Title
340 }
341 sawMeta = true
342
343 case "message":
344 msg, err := hydrateMessage(line)
345 if err != nil {
346 return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
347 }
348 snapshot.Messages = append(snapshot.Messages, msg)
349
350 case "compaction":
351 compacted, err := hydrateCompaction(line)
352 if err != nil {
353 return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
354 }
355 snapshot.Messages = compacted
356 generation++
357
358 case "usage":
359 var row struct {
360 Usage *provider.Usage `json:"usage"`
361 Cumulative *provider.Usage `json:"cumulative"`
362 }
363 if err := json.Unmarshal(line, &row); err != nil {
364 return fmt.Errorf("line %d: invalid usage row: %w", lineNo, err)
365 }
366 if row.Cumulative == nil {
367 return fmt.Errorf("line %d: usage row has no cumulative usage", lineNo)
368 }
369 rawCheckpoints = append(rawCheckpoints, rawCheckpoint{
370 messageCount: len(snapshot.Messages),
371 cumulative: *row.Cumulative,
372 generation: generation,
373 })
374
375 case "rename":
376 var row struct {
377 Title *string `json:"title"`
378 }
379 if err := json.Unmarshal(line, &row); err != nil {
380 return fmt.Errorf("line %d: invalid rename row: %w", lineNo, err)
381 }
382 if row.Title == nil {
383 return fmt.Errorf("line %d: rename row has no title", lineNo)
384 }
385 titleFromRename = true
386 snapshot.Title = *row.Title
387
388 case "extension_state":
389 var row sessionLine
390 if err := json.Unmarshal(line, &row); err != nil {
391 return fmt.Errorf("line %d: invalid extension state row: %w", lineNo, err)
392 }
393 if row.Extension != "" && len(row.State) <= maxExtensionStateBytes {
394 if len(row.State) == 0 || strings.TrimSpace(string(row.State)) == "null" {
395 delete(extensionState, row.Extension)
396 } else if json.Valid(row.State) {
397 extensionState[row.Extension] = append(json.RawMessage(nil), row.State...)
398 }
399 }
400
401 default:
402 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
403 }
404 return nil
405 })
406 if err != nil {
407 return SessionSnapshot{}, fmt.Errorf("session snapshot %q: %w", path, err)
408 }
409 if !sawMeta {
410 return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path)
411 }
412
413 snapshot.CompactionGeneration = generation
414 snapshot.ExtensionState = extensionState
415
416 // Repair only after compaction replacement. This makes synthetic tool
417 // results and the removal of orphaned results part of the same indexed
418 // stream that OpenSession returns and that BranchSession materializes.
419 rawMessages := snapshot.Messages
420 repaired := repairSessionMessages(rawMessages)
421 snapshot.Messages = repaired.messages
422 for _, checkpoint := range rawCheckpoints {
423 count := checkpoint.messageCount
424 if checkpoint.generation == generation {
425 count = repaired.prefixCount(count)
426 } else if count > len(rawMessages) {
427 // A compaction replaces the effective stream. Preserve the
428 // cumulative cost represented by an older checkpoint at the
429 // end of the replacement rather than making every post-summary
430 // branch appear to have zero usage.
431 count = len(rawMessages)
432 }
433 snapshot.UsageCheckpoints = append(snapshot.UsageCheckpoints, SessionUsageCheckpoint{
434 MessageCount: count,
435 Cumulative: checkpoint.cumulative,
436 CompactionGeneration: checkpoint.generation,
437 })
438 }
439 return snapshot, nil
440}
441
442// ReadSessionHistory reads every provider-valid transcript era in a session.
443// The effective snapshot remains the source of truth for resume and ordinary
444// branching; this audit projection exists for session-tree navigation so a
445// compaction cannot make earlier fork points disappear.
446func ReadSessionHistory(path string) (SessionHistory, error) {
447 f, err := os.Open(path)
448 if err != nil {
449 return SessionHistory{}, fmt.Errorf("session history: open %q: %w", path, err)
450 }
451 defer f.Close()
452
453 var history SessionHistory
454 var sawMeta bool
455 type rawCheckpoint struct {
456 messageCount int
457 cumulative provider.Usage
458 }
459 type rawSegment struct {
460 compacted bool
461 messages []provider.Message
462 checkpoints []rawCheckpoint
463 }
464 current := rawSegment{}
465
466 appendCurrent := func() {
467 repaired := repairSessionMessages(current.messages)
468 segment := SessionHistorySegment{
469 Compacted: current.compacted,
470 Messages: repaired.messages,
471 }
472 for _, checkpoint := range current.checkpoints {
473 segment.UsageCheckpoints = append(segment.UsageCheckpoints, SessionUsageCheckpoint{
474 MessageCount: repaired.prefixCount(checkpoint.messageCount),
475 Cumulative: checkpoint.cumulative,
476 })
477 }
478 if segment.Compacted || len(segment.Messages) > 0 || len(segment.UsageCheckpoints) > 0 {
479 history.Segments = append(history.Segments, segment)
480 }
481 current = rawSegment{}
482 }
483
484 err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
485 var head sessionLineHead
486 if err := json.Unmarshal(line, &head); err != nil {
487 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
488 }
489 if head.Type == "" {
490 return fmt.Errorf("line %d: missing row type", lineNo)
491 }
492 if !sawMeta && head.Type != "meta" {
493 return fmt.Errorf("line %d: first row is not meta", lineNo)
494 }
495
496 switch head.Type {
497 case "meta":
498 var row sessionLine
499 if err := json.Unmarshal(line, &row); err != nil {
500 return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
501 }
502 if row.Meta == nil || row.Meta.ID == "" {
503 return fmt.Errorf("line %d: meta row has no session id", lineNo)
504 }
505 history.Meta = *row.Meta
506 sawMeta = true
507
508 case "message":
509 message, err := hydrateMessage(line)
510 if err != nil {
511 return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
512 }
513 current.messages = append(current.messages, message)
514
515 case "compaction":
516 compacted, err := hydrateCompaction(line)
517 if err != nil {
518 return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
519 }
520 appendCurrent()
521 current = rawSegment{compacted: true, messages: compacted}
522
523 case "usage":
524 var row struct {
525 Cumulative *provider.Usage `json:"cumulative"`
526 }
527 if err := json.Unmarshal(line, &row); err != nil {
528 return fmt.Errorf("line %d: invalid usage row: %w", lineNo, err)
529 }
530 if row.Cumulative == nil {
531 return fmt.Errorf("line %d: usage row has no cumulative usage", lineNo)
532 }
533 current.checkpoints = append(current.checkpoints, rawCheckpoint{
534 messageCount: len(current.messages),
535 cumulative: *row.Cumulative,
536 })
537
538 case "rename":
539 var row struct {
540 Title *string `json:"title"`
541 }
542 if err := json.Unmarshal(line, &row); err != nil {
543 return fmt.Errorf("line %d: invalid rename row: %w", lineNo, err)
544 }
545 if row.Title == nil {
546 return fmt.Errorf("line %d: rename row has no title", lineNo)
547 }
548
549 default:
550 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
551 }
552 return nil
553 })
554 if err != nil {
555 return SessionHistory{}, fmt.Errorf("session history %q: %w", path, err)
556 }
557 if !sawMeta {
558 return SessionHistory{}, fmt.Errorf("session history %q: file is empty", path)
559 }
560 appendCurrent()
561 return history, nil
562}
563
564// forEachStrictJSONLLine is the complete-read counterpart to
565// forEachJSONLLine. It rejects malformed and blank rows and reports the
566// line number so callers cannot accidentally proceed with a partial file.
567func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error {
568 return forEachStrictJSONLLineContext(context.Background(), r, fn)
569}
570
571func forEachStrictJSONLLineContext(ctx context.Context, r io.Reader, fn func([]byte, int) error) error {
572 br := bufio.NewReader(r)
573 lineNo := 0
574 for {
575 if err := contextErr(ctx); err != nil {
576 return err
577 }
578 line, err := br.ReadBytes('\n')
579 if len(line) > 0 {
580 lineNo++
581 line = bytes.TrimRight(line, "\r\n")
582 if len(bytes.TrimSpace(line)) == 0 {
583 return fmt.Errorf("line %d: blank row", lineNo)
584 }
585 if ferr := fn(line, lineNo); ferr != nil {
586 return ferr
587 }
588 }
589 if err == io.EOF {
590 return nil
591 }
592 if err != nil {
593 return err
594 }
595 }
596}
597
598// repairedSessionMessage keeps the raw message index that produced an
599// effective message. Synthetic tool results use the assistant's raw index;
600// this lets usage checkpoints continue to refer to the same effective
601// message prefix after repair removes or inserts rows.
602type repairedSessionMessage struct {
603 message provider.Message
604 rawIndex int
605}
606
607type repairedSessionMessages struct {
608 messages []provider.Message
609 counts []int
610}
611
612func (r repairedSessionMessages) prefixCount(rawCount int) int {
613 if rawCount < 0 {
614 return 0
615 }
616 if rawCount >= len(r.counts) {
617 return r.counts[len(r.counts)-1]
618 }
619 return r.counts[rawCount]
620}
621
622// repairSessionMessages repairs tool-call pairs and removes orphaned result
623// blocks while retaining a mapping from raw effective prefixes to repaired
624// prefixes. A tool result is valid only in the immediately following tool
625// message for the assistant tool calls; keeping an unrelated result there
626// would still make a resumed or branched transcript provider-invalid.
627func repairSessionMessages(msgs []provider.Message) repairedSessionMessages {
628 if len(msgs) == 0 {
629 return repairedSessionMessages{counts: []int{0}}
630 }
631
632 repaired := make([]repairedSessionMessage, 0, len(msgs)+2)
633 for i := 0; i < len(msgs); i++ {
634 m := msgs[i]
635 if m.Role == provider.RoleAssistant && messageHasToolCalls(m) {
636 ids := make(map[string]bool)
637 for _, c := range m.Content {
638 if tc, ok := c.(provider.ToolCallBlock); ok {
639 ids[tc.ID] = true
640 }
641 }
642 repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
643
644 if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
645 tool := msgs[i+1]
646 seen := make(map[string]bool)
647 filtered := make([]provider.Content, 0, len(tool.Content)+len(ids))
648 for _, c := range tool.Content {
649 tr, ok := c.(provider.ToolResultBlock)
650 if !ok {
651 filtered = append(filtered, c)
652 continue
653 }
654 if !ids[tr.CallID] || seen[tr.CallID] {
655 continue
656 }
657 seen[tr.CallID] = true
658 filtered = append(filtered, tr)
659 }
660 for _, c := range m.Content {
661 if tc, ok := c.(provider.ToolCallBlock); ok && !seen[tc.ID] {
662 filtered = append(filtered, provider.ToolResultBlock{
663 CallID: tc.ID,
664 Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
665 IsError: true,
666 })
667 }
668 }
669 if len(filtered) > 0 {
670 tool.Content = filtered
671 repaired = append(repaired, repairedSessionMessage{message: tool, rawIndex: i + 1})
672 }
673 i++
674 continue
675 }
676
677 stubs := make([]provider.Content, 0, len(ids))
678 for _, c := range m.Content {
679 if tc, ok := c.(provider.ToolCallBlock); ok {
680 stubs = append(stubs, provider.ToolResultBlock{
681 CallID: tc.ID,
682 Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
683 IsError: true,
684 })
685 }
686 }
687 repaired = append(repaired, repairedSessionMessage{message: provider.Message{
688 Role: provider.RoleTool,
689 Content: stubs,
690 Time: m.Time,
691 }, rawIndex: i})
692 continue
693 }
694
695 if m.Role == provider.RoleTool {
696 // Deferred-tool activation rows intentionally carry a result
697 // without a preceding assistant call. Preserve those rows and
698 // their AddedToolNames marker; they are consumed as local
699 // transcript state rather than sent as a provider tool pair.
700 if len(m.AddedToolNames) > 0 {
701 repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
702 continue
703 }
704 // This tool row was not immediately paired with an assistant
705 // tool-call row. Drop its result blocks; a bare tool row is an
706 // orphan and is not safe to send to a provider.
707 filtered := make([]provider.Content, 0, len(m.Content))
708 for _, c := range m.Content {
709 if _, ok := c.(provider.ToolResultBlock); !ok {
710 filtered = append(filtered, c)
711 }
712 }
713 if len(filtered) > 0 {
714 m.Content = filtered
715 repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
716 }
717 continue
718 }
719 repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i})
720 }
721
722 out := repairedSessionMessages{
723 messages: make([]provider.Message, 0, len(repaired)),
724 counts: make([]int, len(msgs)+1),
725 }
726 for _, item := range repaired {
727 out.messages = append(out.messages, item.message)
728 }
729 // rawIndex values are produced in source order. Accumulate the number of
730 // repaired messages before each raw prefix in one forward pass instead of
731 // revisiting every remaining prefix for every repaired message.
732 repairedIndex := 0
733 for rawCount := range out.counts {
734 for repairedIndex < len(repaired) && repaired[repairedIndex].rawIndex < rawCount {
735 repairedIndex++
736 }
737 out.counts[rawCount] = repairedIndex
738 }
739 return out
740}
741
742func messageHasToolCalls(msg provider.Message) bool {
743 for _, content := range msg.Content {
744 if _, ok := content.(provider.ToolCallBlock); ok {
745 return true
746 }
747 }
748 return false
749}
750
751// OpenSession opens an existing session for appending.
752func OpenSession(path string) (*Session, []provider.Message, error) {
753 snapshot, err := ReadSessionSnapshot(path)
754 if err != nil {
755 return nil, nil, err
756 }
757 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
758 if err != nil {
759 return nil, nil, err
760 }
761 s := &Session{
762 ID: snapshot.Meta.ID,
763 Path: path,
764 Meta: snapshot.Meta,
765 Title: NormalizeSessionTitle(snapshot.Title),
766 ExtensionState: snapshot.ExtensionState,
767 writer: out,
768 buf: bufio.NewWriter(out),
769 }
770 return s, snapshot.Messages, nil
771}
772
773// repairToolUseResultPairs walks a restored transcript and
774// synthesises stub tool_result blocks for any assistant
775// tool_use blocks that aren't paired with a matching result in
776// the next message. Anthropic (and OpenAI via the responses API)
777// reject any request whose transcript leaves a tool_use without
778// its matching tool_result immediately after, with errors like:
779//
780// messages.8: `tool_use` ids were found without `tool_result`
781// blocks immediately after
782//
783// Corruption gets into the transcript two ways we know of:
784//
785// - Older zut builds that persisted the assistant tool_use row
786// before the tool_result row, then crashed between the two.
787// - Abort paths in older builds that didn't drop the mid-turn
788// assistant message cleanly.
789//
790// Rather than change runtime semantics (which would risk hiding a
791// real bug), we scrub on load: any unmatched tool_use gets a stub
792// tool_result injected as a RoleTool message so the next
793// outbound request passes the provider's validity check. The stub
794// reads "tool call was aborted; no result recorded." so the
795// model can see what happened and decide whether to retry.
796//
797// Runs once per OpenSession call. No cost on the hot path.
798func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {
799 return repairSessionMessages(msgs).messages
800}
801
802// LatestSession returns the most recent session file for cwd, or "".
803func LatestSession(root, cwd string) string {
804 paths := ListSessions(root, cwd)
805 if len(paths) == 0 {
806 return ""
807 }
808 return paths[0]
809}
810
811// SessionSummary describes one on-disk session at a glance for UI pickers.
812type SessionSummary struct {
813 Path string
814 Started time.Time
815 Model string
816 Provider string
817 MessageCount int
818 FirstUserText string
819 TotalCost float64
820 Title string
821 BranchDepth int
822 // HideFromSessions marks internal branches omitted from the flat picker.
823 HideFromSessions bool
824}
825
826// RenameSession appends a sanitized rename line to the session file. This is
827// safe even for the currently active session because it opens the file
828// independently and appends (doesn't rewrite).
829func RenameSession(path, title string) error {
830 title = NormalizeSessionTitle(title)
831 if title == "" {
832 return errors.New("session title is empty")
833 }
834 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
835 if err != nil {
836 return err
837 }
838 defer f.Close()
839 line, _ := json.Marshal(map[string]string{"type": "rename", "title": title})
840 line = append(line, '\n')
841 _, err = f.Write(line)
842 return err
843}
844
845// DeleteSession removes a session file. It refuses an empty path so
846// callers do not accidentally delete their cwd or another implicit target.
847func DeleteSession(path string) error {
848 if strings.TrimSpace(path) == "" {
849 return fmt.Errorf("session path is empty")
850 }
851 return os.Remove(path)
852}
853
854// DescribeSession returns a lightweight summary for one on-disk session.
855// It uses the effective snapshot so message counts, metadata, and usage agree
856// with resume and branching behavior. The summary retains HideFromSessions so
857// callers that load entries independently can apply the same picker filter as
858// DescribeSessions.
859func DescribeSession(path string) SessionSummary {
860 return DescribeSessionContext(context.Background(), path)
861}
862
863// DescribeSessionContext is the cancellation-aware form of DescribeSession.
864// Cancellation is checked between JSONL records so a closed picker does not
865// keep parsing a large transcript unnecessarily.
866func DescribeSessionContext(ctx context.Context, path string) SessionSummary {
867 return describeSessionContext(ctx, path)
868}
869
870// DescribeSessions returns lightweight summaries for every session in
871// cwd, newest first. It uses the effective snapshot so message counts,
872// metadata, and usage agree with resume and branching behavior.
873func DescribeSessions(root, cwd string) []SessionSummary {
874 paths := ListSessions(root, cwd)
875 summaries := make([]SessionSummary, 0, len(paths))
876 metas := make(map[string]SessionMeta, len(paths))
877 idToPath := make(map[string]string, len(paths))
878 for _, p := range paths {
879 if meta, err := readSessionMeta(p); err == nil && meta.ID != "" {
880 metas[p] = meta
881 idToPath[meta.ID] = p
882 }
883 }
884 for _, p := range paths {
885 if meta := metas[p]; meta.HideFromSessions {
886 continue
887 }
888 summaries = append(summaries, describeSession(p))
889 }
890 for idx := range summaries {
891 summaries[idx].BranchDepth = sessionBranchDepth(summaries[idx].Path, metas, idToPath)
892 }
893 return summaries
894}
895
896func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[string]string) int {
897 depth := 0
898 seen := map[string]bool{}
899 for {
900 meta, ok := metas[path]
901 if !ok || meta.Parent == "" || seen[meta.ID] {
902 return depth
903 }
904 seen[meta.ID] = true
905 parentPath := idToPath[meta.Parent]
906 if parentPath == "" {
907 return depth
908 }
909 depth++
910 path = parentPath
911 }
912}
913
914func describeSession(path string) SessionSummary {
915 return describeSessionContext(context.Background(), path)
916}
917
918func describeSessionContext(ctx context.Context, path string) SessionSummary {
919 s := SessionSummary{Path: path}
920 snapshot, err := readSessionSnapshot(ctx, path)
921 if err != nil {
922 return s
923 }
924 s.Started = snapshot.Meta.Started
925 s.Model = snapshot.Meta.Model
926 s.Provider = snapshot.Meta.Provider
927 s.HideFromSessions = snapshot.Meta.HideFromSessions
928 s.Title = NormalizeSessionTitle(snapshot.Title)
929 s.MessageCount = len(snapshot.Messages)
930 s.FirstUserText = firstUserTextFromMessages(snapshot.Messages)
931 lastUser := lastUserText(snapshot.Messages)
932 isBranch := snapshot.Meta.Parent != ""
933 forkPoint := snapshot.Meta.ForkPoint
934 branchPrompt := firstUserTextFromMessagesAfter(snapshot.Messages, forkPoint)
935 if len(snapshot.UsageCheckpoints) > 0 {
936 s.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
937 }
938
939 // Rename rows are append-only and are intentionally kept out of the
940 // effective transcript. Scan only their placement so generated branch
941 // titles can still be replaced by the first post-fork prompt while an
942 // explicit rename after divergence keeps priority.
943 messageIdx := 0
944 renameMessageIdx := -1
945 renameGenerated := false
946 _ = func() error {
947 f, err := os.Open(path)
948 if err != nil {
949 return err
950 }
951 defer f.Close()
952 return forEachJSONLLineContext(ctx, f, func(line []byte) error {
953 var head sessionLineHead
954 if err := json.Unmarshal(line, &head); err != nil {
955 return nil
956 }
957 switch head.Type {
958 case "message":
959 messageIdx++
960 case "compaction":
961 if compacted, err := hydrateCompaction(line); err == nil {
962 messageIdx = len(compacted)
963 }
964 case "rename":
965 var row struct {
966 Title string `json:"title"`
967 Generated bool `json:"generated"`
968 }
969 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
970 s.Title = NormalizeSessionTitle(row.Title)
971 renameMessageIdx = messageIdx
972 renameGenerated = row.Generated
973 }
974 }
975 return nil
976 })
977 }()
978 if isBranch {
979 // A rename written after the branch has diverged is user intent
980 // and must win. Older generated branch titles were written at
981 // fork creation time, before any post-fork prompt, so those are
982 // allowed to be replaced by the branch prompt fallback. Current
983 // generated titles are marked so a fast hidden request that writes
984 // before the first post-fork message still wins.
985 explicitRename := !renameGenerated && renameMessageIdx > forkPoint
986 if !renameGenerated && renameMessageIdx == forkPoint && s.Title != lastUser {
987 explicitRename = true
988 }
989 if !renameGenerated && !explicitRename && branchPrompt != "" {
990 s.Title = branchPrompt
991 } else if s.Title == "" && lastUser != "" {
992 s.Title = lastUser
993 }
994 }
995 return s
996}
997
998func firstUserTextFromMessages(msgs []provider.Message) string {
999 return firstUserTextFromMessagesAfter(msgs, 0)
1000}
1001
1002func firstUserTextFromMessagesAfter(msgs []provider.Message, start int) string {
1003 if start < 0 {
1004 start = 0
1005 }
1006 for idx, msg := range msgs {
1007 if idx < start || msg.Role != provider.RoleUser {
1008 continue
1009 }
1010 if text := firstTextFromMessage(msg); text != "" {
1011 return text
1012 }
1013 }
1014 return ""
1015}
1016
1017func firstTextFromMessage(msg provider.Message) string {
1018 for _, c := range msg.Content {
1019 if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
1020 return tb.Text
1021 }
1022 }
1023 return ""
1024}
1025
1026// PruneEmptySessions deletes session files in cwd's session directory
1027// that contain only a meta line (no messages were ever appended).
1028// Cleans up the backlog of empty stubs created by old zut versions
1029// that wrote a meta line at NewSession time and never followed up.
1030// Errors are swallowed; the caller treats this as best-effort.
1031func PruneEmptySessions(root, cwd string) {
1032 dir := SessionsDir(root, cwd)
1033 entries, err := os.ReadDir(dir)
1034 if err != nil {
1035 return
1036 }
1037 for _, e := range entries {
1038 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
1039 continue
1040 }
1041 p := filepath.Join(dir, e.Name())
1042 if sessionHasNoMessages(p) {
1043 _ = os.Remove(p)
1044 }
1045 }
1046}
1047
1048// sessionHasNoMessages returns true when the file at path contains
1049// no lines of type "message". Meta-only / usage-only files count as
1050// empty. Used by PruneEmptySessions and the Describe path.
1051func sessionHasNoMessages(path string) bool {
1052 f, err := os.Open(path)
1053 if err != nil {
1054 return false
1055 }
1056 defer f.Close()
1057 hasMessage := false
1058 _ = forEachJSONLLine(f, func(line []byte) error {
1059 var head sessionLineHead
1060 if err := json.Unmarshal(line, &head); err != nil {
1061 return nil
1062 }
1063 if head.Type == "message" {
1064 hasMessage = true
1065 return io.EOF
1066 }
1067 return nil
1068 })
1069 return !hasMessage
1070}
1071
1072// ListSessions returns session file paths for cwd, most-recently-
1073// modified first. Sorting on filesystem ModTime instead of the
1074// timestamp embedded in the filename means a long-running session
1075// the user actually returned to recently floats to the top of
1076// /sessions, /continue, and the resume picker, even when it was
1077// originally created days earlier than newer but idle sessions.
1078// Files with identical ModTime fall back to filename desc so the
1079// order stays stable across calls.
1080func ListSessions(root, cwd string) []string {
1081 return ListSessionsContext(context.Background(), root, cwd)
1082}
1083
1084// ListSessionsContext is the cancellation-aware form of ListSessions. It
1085// checks cancellation while walking directory entries so a closed picker does
1086// not continue preparing a large result set.
1087func ListSessionsContext(ctx context.Context, root, cwd string) []string {
1088 if err := contextErr(ctx); err != nil {
1089 return nil
1090 }
1091 dir := SessionsDir(root, cwd)
1092 dirFile, err := os.Open(dir)
1093 if err != nil {
1094 return nil
1095 }
1096 defer dirFile.Close()
1097 type rec struct {
1098 path string
1099 mod time.Time
1100 }
1101 var files []rec
1102 for {
1103 if err := contextErr(ctx); err != nil {
1104 return nil
1105 }
1106 entries, readErr := dirFile.ReadDir(128)
1107 if readErr != nil && readErr != io.EOF {
1108 return nil
1109 }
1110 for _, e := range entries {
1111 if err := contextErr(ctx); err != nil {
1112 return nil
1113 }
1114 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
1115 continue
1116 }
1117 p := filepath.Join(dir, e.Name())
1118 info, err := e.Info()
1119 if err != nil {
1120 continue
1121 }
1122 files = append(files, rec{path: p, mod: info.ModTime()})
1123 }
1124 if readErr == io.EOF {
1125 break
1126 }
1127 }
1128 sort.Slice(files, func(i, j int) bool {
1129 if !files[i].mod.Equal(files[j].mod) {
1130 return files[i].mod.After(files[j].mod)
1131 }
1132 return files[i].path > files[j].path
1133 })
1134 out := make([]string, 0, len(files))
1135 for _, r := range files {
1136 if err := contextErr(ctx); err != nil {
1137 return nil
1138 }
1139 out = append(out, r.path)
1140 }
1141 return out
1142}
1143
1144// AppendMessage writes a message to the session.
1145func (s *Session) AppendMessage(m provider.Message) error {
1146 if s == nil {
1147 return nil
1148 }
1149 if len(m.Content) == 0 {
1150 return errors.New("message has no content")
1151 }
1152 if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
1153 return err
1154 }
1155 s.messagesAppended++
1156 return nil
1157}
1158
1159// AppendCompaction writes a checkpoint that replaces all earlier
1160// transcript rows when the session is resumed. The old rows remain in
1161// the JSONL file for audit/export, while loaders use the latest
1162// compaction row as the effective transcript.
1163func (s *Session) AppendCompaction(messages []provider.Message) error {
1164 if s == nil {
1165 return nil
1166 }
1167 compactionMessages := messages
1168 if err := s.writeLine(sessionLine{Type: "compaction", Messages: &compactionMessages}); err != nil {
1169 return err
1170 }
1171 // The compaction row itself is meaningful even when it replaces the
1172 // transcript with an empty or nil slice. Count the append operation so a
1173 // fresh session containing an empty checkpoint is not deleted on Close.
1174 s.messagesAppended++
1175 return nil
1176}
1177
1178// UpdateModel records a provider/model switch in the session file.
1179// The reader keeps the most recent meta entry, so the session resumes
1180// with the updated model.
1181func (s *Session) UpdateModel(providerName, model string) error {
1182 if s == nil {
1183 return nil
1184 }
1185 oldProvider, oldModel := s.Meta.Provider, s.Meta.Model
1186 s.Meta.Provider = providerName
1187 s.Meta.Model = model
1188 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1189 s.Meta.Provider = oldProvider
1190 s.Meta.Model = oldModel
1191 return err
1192 }
1193 return nil
1194}
1195
1196// UpdateTitle records a session title without adding anything to the
1197// conversation transcript. The title is kept in memory as well so the live
1198// session can restore it when the user switches sessions in the TUI.
1199func (s *Session) UpdateTitle(title string) error {
1200 if s == nil {
1201 return nil
1202 }
1203 title = NormalizeSessionTitle(title)
1204 if title == "" {
1205 return nil
1206 }
1207 if err := s.writeLine(sessionLine{Type: "rename", Title: title, Generated: true}); err != nil {
1208 return fmt.Errorf("update session title: %w", err)
1209 }
1210 s.Title = title
1211 return nil
1212}
1213
1214// AppendUsage writes a usage row to the session.
1215func (s *Session) AppendUsage(u, cum provider.Usage) error {
1216 if s == nil {
1217 return nil
1218 }
1219 return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
1220}
1221
1222const maxExtensionStateBytes = 256 * 1024
1223
1224// AppendExtensionState records the latest opaque extension snapshot for the
1225// active session/branch. State is persisted in the session file but is never
1226// included in provider requests. A nil or JSON null state clears the key.
1227func (s *Session) AppendExtensionState(extension string, state json.RawMessage) error {
1228 if s == nil {
1229 return nil
1230 }
1231 extension = strings.TrimSpace(extension)
1232 if extension == "" {
1233 return fmt.Errorf("extension state: extension name is empty")
1234 }
1235 if len(state) > maxExtensionStateBytes {
1236 return fmt.Errorf("extension state %q exceeds %d bytes", extension, maxExtensionStateBytes)
1237 }
1238 if len(state) > 0 && !json.Valid(state) {
1239 return fmt.Errorf("extension state %q is not valid JSON", extension)
1240 }
1241 copyState := append(json.RawMessage(nil), state...)
1242 if err := s.writeLine(sessionLine{Type: "extension_state", Extension: extension, State: copyState}); err != nil {
1243 return fmt.Errorf("append extension state: %w", err)
1244 }
1245 if s.ExtensionState == nil {
1246 s.ExtensionState = map[string]json.RawMessage{}
1247 }
1248 if len(copyState) == 0 || strings.TrimSpace(string(copyState)) == "null" {
1249 delete(s.ExtensionState, extension)
1250 } else {
1251 s.ExtensionState[extension] = copyState
1252 }
1253 return nil
1254}
1255
1256// Close flushes and closes the session file. If the session was
1257// freshly created in this process and never had any messages
1258// appended (the user opened zut, looked around, and exited without
1259// prompting), the file is deleted on close so the sessions list
1260// doesn't fill up with empty meta-only stubs.
1261func (s *Session) Close() error {
1262 if s == nil {
1263 return nil
1264 }
1265 flushErr := s.buf.Flush()
1266 closeErr := s.writer.Close()
1267 if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 {
1268 // Best-effort cleanup. We deliberately don't propagate the
1269 // remove error: if it fails (file already gone, perms changed)
1270 // the worst case is one stale empty file in the listing.
1271 _ = os.Remove(s.Path)
1272 }
1273 if flushErr != nil {
1274 return flushErr
1275 }
1276 return closeErr
1277}
1278
1279func (s *Session) writeLine(row sessionLine) error {
1280 b, err := json.Marshal(row)
1281 if err != nil {
1282 return err
1283 }
1284 if _, err := s.buf.Write(b); err != nil {
1285 return err
1286 }
1287 if err := s.buf.WriteByte('\n'); err != nil {
1288 return err
1289 }
1290 return s.buf.Flush()
1291}
1292
1293// ---- content (de)serialization ----
1294//
1295// provider.Content is an interface; encoding/json drops type information.
1296// We persist messages by reading the raw "message" object back and
1297// rebuilding Content from discriminated fields.
1298
1299func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
1300 var row struct {
1301 Messages json.RawMessage `json:"messages"`
1302 }
1303 if err := json.Unmarshal(lineBytes, &row); err != nil {
1304 return nil, err
1305 }
1306 if len(row.Messages) == 0 || bytes.Equal(bytes.TrimSpace(row.Messages), []byte("null")) {
1307 // Older writers omitted the field for an empty compaction and some
1308 // wrote it as null. Both represent a valid empty replacement.
1309 return []provider.Message{}, nil
1310 }
1311 var rawMessages []json.RawMessage
1312 if err := json.Unmarshal(row.Messages, &rawMessages); err != nil {
1313 return nil, fmt.Errorf("invalid messages: %w", err)
1314 }
1315 messages := make([]provider.Message, 0, len(rawMessages))
1316 for idx, raw := range rawMessages {
1317 msg, err := hydrateMessageObject(raw)
1318 if err != nil {
1319 return nil, fmt.Errorf("message %d: %w", idx, err)
1320 }
1321 messages = append(messages, msg)
1322 }
1323 return messages, nil
1324}
1325
1326func hydrateMessage(lineBytes []byte) (provider.Message, error) {
1327 var row struct {
1328 Message json.RawMessage `json:"message"`
1329 }
1330 if err := json.Unmarshal(lineBytes, &row); err != nil {
1331 return provider.Message{}, err
1332 }
1333 if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) {
1334 return provider.Message{}, fmt.Errorf("message row has no message")
1335 }
1336 return hydrateMessageObject(row.Message)
1337}
1338
1339func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
1340 var row struct {
1341 Role provider.Role `json:"role"`
1342 Content []json.RawMessage `json:"content"`
1343 Time time.Time `json:"time"`
1344 Meta map[string]string `json:"meta,omitempty"`
1345 AddedToolNames []string `json:"added_tool_names,omitempty"`
1346 }
1347 if err := json.Unmarshal(rawMessage, &row); err != nil {
1348 return provider.Message{}, err
1349 }
1350 if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool {
1351 return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role)
1352 }
1353 if len(row.Content) == 0 {
1354 return provider.Message{}, fmt.Errorf("message has no content")
1355 }
1356 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames}
1357 for idx, raw := range row.Content {
1358 if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
1359 return provider.Message{}, fmt.Errorf("content block %d is empty", idx)
1360 }
1361 var head struct {
1362 Text string `json:"text"`
1363 MimeType string `json:"mime_type"`
1364 Data []byte `json:"data"`
1365 ID string `json:"id"`
1366 Name string `json:"name"`
1367 CallID string `json:"call_id"`
1368 ReasoningID string `json:"reasoning_id"`
1369 Summary string `json:"summary"`
1370 Encrypted string `json:"encrypted_content"`
1371 ThoughtSignature string `json:"thought_signature"`
1372 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
1373 }
1374 if err := json.Unmarshal(raw, &head); err != nil {
1375 return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1376 }
1377 var fields map[string]json.RawMessage
1378 if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
1379 if err == nil {
1380 err = fmt.Errorf("content block is not an object")
1381 }
1382 return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1383 }
1384 // Discriminate by presence of fields.
1385 switch {
1386 case head.ReasoningID != "" || head.Summary != "" || head.Encrypted != "":
1387 msg.Content = append(msg.Content, provider.ReasoningBlock{
1388 ID: head.ReasoningID,
1389 Summary: head.Summary,
1390 Encrypted: head.Encrypted,
1391 })
1392 case head.Name != "" || head.ID != "" || fields["arguments"] != nil:
1393 if head.Name == "" || head.ID == "" {
1394 return provider.Message{}, fmt.Errorf("content block %d: incomplete tool call", idx)
1395 }
1396 var tc struct {
1397 ID string `json:"id"`
1398 Name string `json:"name"`
1399 Arguments json.RawMessage `json:"arguments"`
1400 ThoughtSignature string `json:"thought_signature"`
1401 }
1402 _ = json.Unmarshal(raw, &tc)
1403 msg.Content = append(msg.Content, provider.ToolCallBlock{
1404 ID: tc.ID,
1405 Name: tc.Name,
1406 Arguments: tc.Arguments,
1407 ThoughtSignature: tc.ThoughtSignature,
1408 })
1409 case head.CallID != "":
1410 var tr struct {
1411 CallID string `json:"call_id"`
1412 Content []json.RawMessage `json:"content"`
1413 IsError bool `json:"is_error"`
1414 }
1415 if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil {
1416 if err == nil {
1417 err = fmt.Errorf("tool result has no content")
1418 }
1419 return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1420 }
1421 block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}
1422 for innerIdx, c := range tr.Content {
1423 var inner struct {
1424 Text string `json:"text"`
1425 MimeType string `json:"mime_type"`
1426 Data []byte `json:"data"`
1427 }
1428 if err := json.Unmarshal(c, &inner); err != nil {
1429 return provider.Message{}, fmt.Errorf("content block %d result %d: %w", idx, innerIdx, err)
1430 }
1431 var innerFields map[string]json.RawMessage
1432 if err := json.Unmarshal(c, &innerFields); err != nil || innerFields == nil {
1433 if err == nil {
1434 err = fmt.Errorf("result content is not an object")
1435 }
1436 return provider.Message{}, fmt.Errorf("content block %d result %d: %w", idx, innerIdx, err)
1437 }
1438 if inner.MimeType != "" {
1439 block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})
1440 } else if _, ok := innerFields["text"]; ok {
1441 block.Content = append(block.Content, provider.TextBlock{Text: inner.Text})
1442 } else {
1443 return provider.Message{}, fmt.Errorf("content block %d result %d: unknown content", idx, innerIdx)
1444 }
1445 }
1446 if len(block.Content) == 0 {
1447 return provider.Message{}, fmt.Errorf("content block %d tool result has no content", idx)
1448 }
1449 msg.Content = append(msg.Content, block)
1450 case head.MimeType != "":
1451 msg.Content = append(msg.Content, provider.ImageBlock{
1452 MimeType: head.MimeType,
1453 Data: head.Data,
1454 ThoughtSignature: head.ThoughtSignature,
1455 })
1456 default:
1457 if _, ok := fields["text"]; !ok {
1458 return provider.Message{}, fmt.Errorf("content block %d: unknown content", idx)
1459 }
1460 msg.Content = append(msg.Content, provider.TextBlock{
1461 Text: head.Text,
1462 ThoughtSignature: head.ThoughtSignature,
1463 })
1464 }
1465 }
1466 return msg, nil
1467}