v0.3.1 5c1a9ec

session.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close