返回 DeepSeek-Reasonix
history_slice.go
根目录 / desktop / history_slice.go
1 package main
2
3 import (
4 "bufio"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "os"
13 "path/filepath"
14 "reasonix/internal/agent"
15 "reasonix/internal/control"
16 "reasonix/internal/historywork"
17 "reasonix/internal/provider"
18 "reasonix/internal/session"
19 "reasonix/internal/store"
20 "slices"
21 "sort"
22 "strings"
23 "unicode/utf8"
24 )
25
26 // This file implements the windowed history paging API (Phase B1 of the
27 // history-pipeline refactor). HistoryPageForTab copies and converts the whole
28 // transcript on every request; HistorySliceForTab pages toward older history
29 // using the per-session display index sidecar (internal/agent
30 // SessionDisplayIndex) so only the returned window is read from disk and
31 // converted. The legacy API stays untouched for one compatibility cycle.
32 //
33 // Entry ID scheme: s<sessionFileID>:r<rewriteEpoch>:m<messageIndex>:o<subOrder>.
34 // - sessionFileID is the transcript basename minus .jsonl — stable for the
35 // life of the session file.
36 // - rewriteEpoch is the persisted rewrite version (live) or the index
37 // revision (cold, 0 when unknown). Append-only saves keep it, so entry IDs
38 // of an unchanged prefix survive appends; rewrites (compaction, rewind)
39 // bump it, and cursors go stale on rewrites anyway.
40 // - messageIndex is the absolute provider-message index; subOrder is the
41 // row number within that message's conversion (one message maps to 0..n
42 // history rows: notices, planner turns, …).
43 //
44 // Cursor format: base64url(JSON{v, revision, revKnown, digest, before}).
45 // The cursor binds the page to the session's persisted revision + content
46 // digest; any save bumps the revision, so continuing with a pre-save cursor
47 // returns HistorySlice{Stale: true} and the frontend reloads the latest page.
48 // "before" is the absolute provider-message index the next page ends at
49 // (exclusive), which carries the intra-turn position for oversized turns:
50 // pages always cut at message boundaries, so concatenating pages reproduces
51 // the full conversion exactly — no duplication, no omission.
52 //
53 // Visible-turn mapping: the display index's AuthoredTurn is counted with
54 // IsUserAuthoredTurn semantics, which already excludes synthetic and steer
55 // user messages — exactly the desktop visible-turn rule — so an entry's
56 // visible turn is its AuthoredTurn (1-based; 0 = before the first turn). The
57 // unsaved in-memory tail of a live session is classified with the real
58 // resolver-based rule (isVisibleHistoryUser), matching today's behavior.
59
60 const (
61 defaultHistorySliceTurns = 12
62 defaultHistorySliceEntries = 120
63 defaultHistorySliceBytes = 512 << 10
64 maxHistorySliceTurns = 500
65 maxHistorySliceEntries = 1000
66 maxHistorySliceBytes = 8 << 20
67
68 // historyInlineRefThreshold is the field size above which a string field
69 // is replaced inline by a preview + HistoryContentRef.
70 historyInlineRefThreshold = 64 << 10
71 // historyFieldPreviewBytes is the rune-safe inline preview kept for a
72 // ref-replaced field. The full value stays retrievable via
73 // HistoryContentForTab.
74 historyFieldPreviewBytes = 4 << 10
75 // historyContentChunkBytes is the HistoryContentForTab chunk size. Chunks
76 // split on UTF-8 rune boundaries, never mid-rune.
77 historyContentChunkBytes = 256 << 10
78
79 // historySliceColdWindowBytes caps the raw transcript span one cold-path
80 // page reads from disk. Inline output is still bounded by the byte budget;
81 // this cap only keeps windows dense with multi-megabyte image lines from
82 // reading unbounded file spans.
83 historySliceColdWindowBytes = 32 << 20
84 // historyLookupChunkMessages bounds the number of decoded messages retained
85 // while deriving cross-page planner state.
86 historyLookupChunkMessages = 128
87 )
88
89 // HistorySliceRequest is one page request. Cursor empty = latest page.
90 type HistorySliceRequest struct {
91 Cursor string `json:"cursor"`
92 Turns int `json:"turns"` // default 12
93 Entries int `json:"entries"` // default 120
94 Bytes int `json:"bytes"` // inline byte budget, default 512KiB
95 Newer bool `json:"newer,omitempty"`
96 // Bound native readers resolve anchors within their existing projection.
97 Anchor string `json:"anchor,omitempty"`
98 Turn int `json:"turn,omitempty"`
99 MessageID string `json:"messageId,omitempty"`
100 Generation string `json:"generation,omitempty"`
101 SnapshotSequence *uint64 `json:"snapshotSequence,omitempty"`
102 }
103
104 // HistoryContentRef marks a string field that exceeded the inline threshold.
105 // The field carries a rune-safe preview prefix; the full value is retrievable
106 // in chunks via HistoryContentForTab.
107 type HistoryContentRef struct {
108 // Bound native windows keep content reads on the same cancellable owner.
109 ReadHandleID string `json:"readHandleId,omitempty"`
110 EntryID string `json:"entryId"`
111 Field string `json:"field"` // "content", "reasoning", "submitText", "detail", "code", "summary", "archive", "toolResultError", "toolArguments", "toolSubject", "toolSummary", "toolDiff"
112 Size int `json:"size"`
113 Chunks int `json:"chunks"`
114 // ToolCallID identifies the tool call for tool* fields.
115 ToolCallID string `json:"toolCallId,omitempty"`
116 // Revision/RevKnown/Digest bind the ref to the session state it was cut
117 // from; a mismatch on fetch resolves to Stale.
118 Revision int64 `json:"revision"`
119 RevKnown bool `json:"revKnown,omitempty"`
120 Digest string `json:"digest"`
121 }
122
123 // HistoryEntry is one display row in a history page.
124 type HistoryEntry struct {
125 EntryID string `json:"entryId"`
126 // Turn is the absolute visible turn the row belongs to (1-based; 0 =
127 // before the first visible turn).
128 Turn int `json:"turn"`
129 // Order is the absolute provider-message index the row was converted
130 // from; combined with the sub-order in EntryID it is strictly increasing
131 // in display order.
132 Order int `json:"order"`
133 Message HistoryMessage `json:"message"`
134 // Refs lists the message fields replaced by previews. Always initialized
135 // so JSON encodes [] rather than null.
136 Refs []HistoryContentRef `json:"refs"`
137 }
138
139 // HistorySlice is one page of history toward older messages.
140 type HistorySlice struct {
141 Entries []HistoryEntry `json:"entries"`
142 NextCursor string `json:"nextCursor"` // toward older; empty when none
143 HasOlder bool `json:"hasOlder"`
144 HasNewer bool `json:"hasNewer"`
145 NewerCursor string `json:"newerCursor,omitempty"`
146 TotalTurns int `json:"totalTurns"`
147 StartTurn int `json:"startTurn"` // oldest visible turn in the page (0 when none)
148 EndTurn int `json:"endTurn"` // newest visible turn in the page (0 when none)
149 Stale bool `json:"stale"` // cursor bound to an older session revision
150 Revision int64 `json:"revision"` // session revision the page was cut from (0 when unknown)
151 // RevisionKnown and Digest expose the complete canonical identity already
152 // carried by cursors. They let same-path resident frontend projections be
153 // invalidated after another process advances or rewrites the session.
154 RevisionKnown bool `json:"revisionKnown,omitempty"`
155 Digest string `json:"digest,omitempty"`
156 // Source: index|scan|live-index|live-fallback. Error marks a failed read
157 // (empty Entries alone means a genuinely empty session).
158 Source string `json:"source,omitempty"`
159 Error string `json:"error,omitempty"`
160 }
161
162 // HistoryContentChunk is one chunk of a ref-replaced field's full value.
163 type HistoryContentChunk struct {
164 EntryID string `json:"entryId"`
165 Field string `json:"field"`
166 Chunk int `json:"chunk"`
167 Chunks int `json:"chunks"`
168 Data string `json:"data"`
169 Done bool `json:"done"`
170 Stale bool `json:"stale"`
171 }
172
173 // MarshalJSON enforces the Wails contract even for zero values: entries is
174 // always [], never null.
175 func (s HistorySlice) MarshalJSON() ([]byte, error) {
176 type alias HistorySlice
177 if s.Entries == nil {
178 s.Entries = []HistoryEntry{}
179 }
180 return json.Marshal(alias(s))
181 }
182
183 // MarshalJSON keeps refs [] on zero values, matching the entries contract.
184 func (e HistoryEntry) MarshalJSON() ([]byte, error) {
185 type alias HistoryEntry
186 if e.Refs == nil {
187 e.Refs = []HistoryContentRef{}
188 }
189 return json.Marshal(alias(e))
190 }
191
192 func emptyHistorySlice() HistorySlice { return HistorySlice{Entries: []HistoryEntry{}} }
193
194 func failedHistorySlice(message string) HistorySlice {
195 return HistorySlice{Entries: []HistoryEntry{}, Error: strings.TrimSpace(message)}
196 }
197
198 func staleHistorySlice(revision int64, revisionKnown bool, digest string) HistorySlice {
199 return HistorySlice{
200 Entries: []HistoryEntry{},
201 Stale: true,
202 Revision: revision,
203 RevisionKnown: revisionKnown,
204 Digest: digest,
205 }
206 }
207
208 func normalizeHistorySliceRequest(req HistorySliceRequest) HistorySliceRequest {
209 if req.Turns <= 0 {
210 req.Turns = defaultHistorySliceTurns
211 }
212 if req.Turns > maxHistorySliceTurns {
213 req.Turns = maxHistorySliceTurns
214 }
215 if req.Entries <= 0 {
216 req.Entries = defaultHistorySliceEntries
217 }
218 if req.Entries > maxHistorySliceEntries {
219 req.Entries = maxHistorySliceEntries
220 }
221 if req.Bytes <= 0 {
222 req.Bytes = defaultHistorySliceBytes
223 }
224 if req.Bytes > maxHistorySliceBytes {
225 req.Bytes = maxHistorySliceBytes
226 }
227 return req
228 }
229
230 // historySliceCursor is the opaque page position toward older history.
231 type historySliceCursor struct {
232 V int `json:"v"`
233 Revision int64 `json:"revision"`
234 RevKnown bool `json:"revKnown"`
235 Digest string `json:"digest"`
236 Before int `json:"before"` // next page covers messages/rows with index < Before
237 Source string `json:"source,omitempty"`
238 }
239
240 func encodeHistorySliceCursor(c historySliceCursor) string {
241 b, err := json.Marshal(c)
242 if err != nil {
243 return ""
244 }
245 return base64.RawURLEncoding.EncodeToString(b)
246 }
247
248 func decodeHistorySliceCursor(s string) (historySliceCursor, error) {
249 s = strings.TrimSpace(s)
250 if s == "" {
251 return historySliceCursor{}, nil
252 }
253 b, err := base64.RawURLEncoding.DecodeString(s)
254 if err != nil {
255 return historySliceCursor{}, err
256 }
257 var c historySliceCursor
258 if err := json.Unmarshal(b, &c); err != nil {
259 return historySliceCursor{}, err
260 }
261 if c.V != 1 || c.Before < 0 {
262 return historySliceCursor{}, fmt.Errorf("unsupported history cursor")
263 }
264 return c, nil
265 }
266
267 // historySliceSource is the windowed read view over one session used to cut a
268 // page: per-message visible turns and roles plus bounded message fetches.
269 type historySliceSource struct {
270 sessionID string // transcript basename minus .jsonl
271 sourceID string // storage root and selected branch identity for cold cursors
272 total int // total provider messages
273 turns []int // turns[i] = visible turn of message i (1-based; 0 = before first turn)
274 roles []provider.Role
275 totalTurns int
276 revision int64
277 revKnown bool
278 digest string
279 epoch int
280 // fetch returns messages [lo, hi). Implementations must copy or freshly
281 // decode; callers never mutate but may retain across budget checks. Decode
282 // errors are propagated all the way to the cold read instead of being
283 // mistaken for an empty window and indexing past its end.
284 fetch func(lo, hi int) ([]provider.Message, error)
285 // windowBytes estimates the raw transcript span of [lo, hi); 0 means
286 // unbounded-but-cheap (in-memory). Used to cap cold-path reads.
287 windowBytes func(lo, hi int) int64
288 position func(int) (agent.DisplayIndexEntry, error)
289 usersBefore func(int) (int, error)
290 readErr error
291 maxFetch int
292 // Disk projections already preserve the available per-record timestamps.
293 // Missing optional display timestamps must never trigger whole-log replay.
294 windowOnly bool
295 }
296
297 func (src *historySliceSource) turnAt(index int) int {
298 if src.position == nil {
299 return src.turns[index]
300 }
301 entry, err := src.position(index)
302 if err != nil {
303 src.readErr = err
304 }
305 return entry.AuthoredTurn
306 }
307
308 func (src *historySliceSource) roleAt(index int) provider.Role {
309 if src.position == nil {
310 return src.roles[index]
311 }
312 entry, err := src.position(index)
313 if err != nil {
314 src.readErr = err
315 }
316 return historyPersistedUserRole(entry.Role, entry.PinnedContextRevision)
317 }
318
319 func (src *historySliceSource) userCountBefore(index int) int {
320 if src.usersBefore == nil {
321 return countRoleBefore(src.roles, index, provider.RoleUser)
322 }
323 count, err := src.usersBefore(index)
324 if err != nil {
325 src.readErr = err
326 }
327 return count
328 }
329
330 // identityMatches reports whether the cursor/ref identity describes the same
331 // session state as the source. Revision is normalized to 0 when unknown on
332 // both sides, so the comparison is exact.
333 func (src *historySliceSource) identityMatches(revision int64, revKnown bool, digest string) bool {
334 if !revKnown {
335 revision = 0
336 }
337 return src.revKnown == revKnown && src.revision == revision && src.digest == digest
338 }
339
340 // historyWindowController is the slice of *control.Controller the windowed
341 // live path needs. Kept as an interface assertion (like
342 // sessionTempFromController) so test fakes implementing control.SessionAPI
343 // keep working via the full-snapshot fallback.
344 type historyWindowController interface {
345 HistoryLen() int
346 HistoryWindow(start, end int) []provider.Message
347 SessionPersistedState() (agent.PersistedState, bool)
348 }
349
350 // HistorySliceForTab returns one page of the tab's history toward older
351 // messages, converting only the returned window.
352 func (a *App) HistorySliceForTab(tabID string, req HistorySliceRequest) HistorySlice {
353 req = normalizeHistorySliceRequest(req)
354 a.mu.RLock()
355 tab := a.tabByIDLocked(tabID)
356 var ctrl control.SessionAPI
357 var sessionDir, sessionPath, sessionID string
358 if tab != nil {
359 ctrl = tab.Ctrl
360 sessionDir = tabSessionDir(tab)
361 sessionPath = tab.currentSessionPath()
362 sessionID = strings.TrimSpace(tab.SessionID)
363 }
364 a.mu.RUnlock()
365
366 if ctrl == nil {
367 return a.historySliceBeforeController(tabID, sessionDir, sessionPath, sessionID, req)
368 }
369 if identity, ok := ctrl.(control.IdentityLifecycle); ok && identity.UsesExclusiveSession() {
370 ref, bound := identity.SessionRef()
371 service := identity.SessionService()
372 if !bound || service == nil || service.Query() == nil {
373 return failedHistorySlice("canonical session identity is unavailable")
374 }
375 slice, err := a.canonicalHistorySlice(service.Query(), ref, sessionDir, sessionPath, req)
376 if err != nil {
377 slog.Debug("desktop: canonical history slice failed", "session", ref.SessionID, "err", err)
378 return failedHistorySlice(err.Error())
379 }
380 return slice
381 }
382 if p := ctrl.SessionPath(); strings.TrimSpace(p) != "" {
383 sessionPath = p
384 sessionDir = controllerSessionDir(ctrl)
385 }
386 return a.liveHistorySlice(ctrl, sessionDir, sessionPath, req)
387 }
388
389 func (a *App) canonicalHistorySlice(query *session.Query, ref session.SessionRef, sessionDir, sessionPath string, req HistorySliceRequest) (HistorySlice, error) {
390 src, err := canonicalHistorySliceSource(query, ref)
391 if err != nil {
392 return emptyHistorySlice(), err
393 }
394 resolver := sessionDisplayResolver(sessionDir, sessionPath)
395 slice, err := a.pageHistorySliceSource(src, req, resolver, nil, nil, "")
396 if err != nil {
397 return emptyHistorySlice(), err
398 }
399 slice.Source = "canonical-index"
400 return slice, nil
401 }
402
403 func canonicalHistorySliceSource(query *session.Query, ref session.SessionRef) (*historySliceSource, error) {
404 shape, err := query.HistoryShape(context.Background(), ref)
405 if err != nil {
406 return nil, err
407 }
408 turns := make([]int, len(shape.Positions))
409 roles := make([]provider.Role, len(shape.Positions))
410 for i, position := range shape.Positions {
411 if position.Position != int64(i+1) {
412 return nil, fmt.Errorf("canonical history position %d, want %d", position.Position, i+1)
413 }
414 turns[i] = position.VisibleTurn
415 roles[i] = position.Role
416 }
417 snapshot := shape.SnapshotSequence
418 src := &historySliceSource{
419 sessionID: ref.SessionID,
420 total: len(shape.Positions),
421 turns: turns,
422 roles: roles,
423 totalTurns: shape.TotalTurns,
424 revision: int64(snapshot),
425 revKnown: true,
426 digest: canonicalHistoryDigest(ref.SessionID, snapshot),
427 epoch: session.StorageRevision,
428 fetch: func(lo, hi int) ([]provider.Message, error) {
429 return query.HistoryWindow(context.Background(), ref, snapshot, lo, hi)
430 },
431 }
432 return src, nil
433 }
434
435 func canonicalHistoryDigest(sessionID string, snapshot uint64) string {
436 return fmt.Sprintf("v4:%s:%d", sessionID, snapshot)
437 }
438
439 // liveHistorySlice pages a tab with a running controller. The display index
440 // supplies turn boundaries when it validates against the session's persisted
441 // state; otherwise a single in-memory snapshot walk classifies turns (still
442 // converting only the window) and a background rebuild is kicked.
443 func (a *App) liveHistorySlice(ctrl control.SessionAPI, sessionDir, sessionPath string, req HistorySliceRequest) HistorySlice {
444 resolver := sessionDisplayResolver(sessionDir, sessionPath)
445 src, indexUsed := a.liveHistorySliceSource(ctrl, sessionPath, resolver)
446 if src == nil {
447 return emptyHistorySlice()
448 }
449 if !indexUsed {
450 a.kickHistoryIndexRebuild(sessionPath)
451 }
452 slice, err := a.pageHistorySliceSource(src, req, resolver, sessionPlannerDisplayTurns(sessionDir, sessionPath), ctrl.CheckpointTurnsByMessageIndex(), sessionPath)
453 if err != nil {
454 slog.Debug("desktop: live history slice failed", "path", sessionPath, "err", err)
455 return failedHistorySlice(err.Error())
456 }
457 if indexUsed {
458 slice.Source = "live-index"
459 } else {
460 slice.Source = "live-fallback"
461 }
462 return slice
463 }
464
465 func (a *App) liveHistorySliceSource(ctrl control.SessionAPI, sessionPath string, resolver func(string) string) (*historySliceSource, bool) {
466 sessionID := strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl")
467 wc, ok := ctrl.(historyWindowController)
468 if !ok {
469 // Compat for fakes: full snapshot, windowed conversion.
470 msgs := ctrl.History()
471 src := newInMemoryHistorySliceSource(sessionID, msgs, resolver, agent.PersistedState{}, false)
472 return src, false
473 }
474 n := wc.HistoryLen()
475 ps, psOK := wc.SessionPersistedState()
476 if psOK && ps.AppendOnlyTail && n > 0 {
477 if idx, err := agent.LoadSessionDisplayIndex(store.SessionDisplayIndex(sessionPath)); err == nil &&
478 idx.RevisionKnown == ps.RevisionKnown &&
479 (!ps.RevisionKnown || idx.Revision == ps.Revision) &&
480 idx.ContentDigest == ps.DigestHex &&
481 idx.MessageCount <= n {
482 turns := make([]int, n)
483 roles := make([]provider.Role, n)
484 for i, e := range idx.Entries {
485 turns[i] = e.AuthoredTurn
486 roles[i] = historyPersistedUserRole(e.Role, e.PinnedContextRevision)
487 }
488 turn := idx.AuthoredTurns
489 if idx.MessageCount < n {
490 tail := wc.HistoryWindow(idx.MessageCount, n)
491 for j, m := range tail {
492 if isVisibleHistoryUser(m, resolver) {
493 turn++
494 }
495 turns[idx.MessageCount+j] = turn
496 roles[idx.MessageCount+j] = historyPersistedUserRole(m.Role, agent.IsPinnedContextRevision(m))
497 }
498 }
499 src := &historySliceSource{
500 sessionID: sessionID,
501 total: n,
502 turns: turns,
503 roles: roles,
504 totalTurns: turn,
505 revision: ps.Revision,
506 revKnown: ps.RevisionKnown,
507 digest: ps.DigestHex,
508 epoch: ps.RewriteEpoch,
509 fetch: func(lo, hi int) ([]provider.Message, error) {
510 return wc.HistoryWindow(lo, hi), nil
511 },
512 }
513 return src, true
514 }
515 }
516 // Fallback: one full snapshot for classification; conversion stays
517 // windowed. The background rebuild republishes the index when the
518 // in-memory log is exactly the persisted transcript.
519 msgs := ctrl.History()
520 var state agent.PersistedState
521 if psOK {
522 state = ps
523 }
524 src := newInMemoryHistorySliceSource(sessionID, msgs, resolver, state, psOK)
525 return src, false
526 }
527
528 // newInMemoryHistorySliceSource builds a source by classifying a full
529 // in-memory snapshot with the resolver-based visible-turn rule — the same
530 // semantics the legacy history path uses.
531 func newInMemoryHistorySliceSource(sessionID string, msgs []provider.Message, resolver func(string) string, ps agent.PersistedState, psOK bool) *historySliceSource {
532 turns := make([]int, len(msgs))
533 roles := make([]provider.Role, len(msgs))
534 turn := 0
535 for i, m := range msgs {
536 if isVisibleHistoryUser(m, resolver) {
537 turn++
538 }
539 turns[i] = turn
540 roles[i] = historyPersistedUserRole(m.Role, agent.IsPinnedContextRevision(m))
541 }
542 src := &historySliceSource{
543 sessionID: sessionID,
544 total: len(msgs),
545 turns: turns,
546 roles: roles,
547 totalTurns: turn,
548 fetch: func(lo, hi int) ([]provider.Message, error) {
549 if lo < 0 {
550 lo = 0
551 }
552 if hi > len(msgs) {
553 hi = len(msgs)
554 }
555 if lo >= hi {
556 return []provider.Message{}, nil
557 }
558 return msgs[lo:hi], nil
559 },
560 }
561 if psOK {
562 src.revision = ps.Revision
563 src.revKnown = ps.RevisionKnown
564 src.digest = ps.DigestHex
565 src.epoch = ps.RewriteEpoch
566 }
567 return src
568 }
569
570 // historyIndexTimestampValid is the cheap file-generation guard for
571 // cold offset reads. Save/scan publish the index atomically after the transcript
572 // is complete. Equal timestamps are ambiguous on coarse filesystems, so cold
573 // readers verify the streamed digest before trusting offsets. The migration
574 // probe may accept equality because it never reads indexed content.
575 func historyIndexTimestampValid(indexPath, sessionPath string, transcriptInfo os.FileInfo, idx *agent.SessionDisplayIndex, verifyEqual bool) bool {
576 indexInfo, err := os.Stat(indexPath)
577 if err != nil || indexInfo.IsDir() || idx == nil {
578 return false
579 }
580 if indexInfo.ModTime().After(transcriptInfo.ModTime()) {
581 return true
582 }
583 if !indexInfo.ModTime().Equal(transcriptInfo.ModTime()) {
584 return false
585 }
586 if !verifyEqual {
587 return true
588 }
589 scanned, err := agent.ScanSessionDisplayIndex(sessionPath)
590 matches := err == nil && scanned.TranscriptSize == idx.TranscriptSize && scanned.MessageCount == idx.MessageCount && scanned.ContentDigest == idx.ContentDigest
591 if matches {
592 if err := agent.WriteSessionDisplayIndex(indexPath, idx); err != nil {
593 slog.Debug("desktop: history display index tie republish failed", "path", sessionPath, "err", err)
594 }
595 }
596 return matches
597 }
598
599 func coldHistorySliceSource(sessionPath string, idx *agent.SessionDisplayIndex) *historySliceSource {
600 n := idx.MessageCount
601 turns := make([]int, n)
602 roles := make([]provider.Role, n)
603 for i, e := range idx.Entries {
604 turns[i] = e.AuthoredTurn
605 roles[i] = historyPersistedUserRole(e.Role, e.PinnedContextRevision)
606 }
607 revision := idx.Revision
608 if !idx.RevisionKnown {
609 revision = 0
610 }
611 epoch := 0
612 if idx.RevisionKnown {
613 epoch = int(idx.Revision)
614 }
615 src := &historySliceSource{
616 sessionID: strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl"),
617 total: n,
618 turns: turns,
619 roles: roles,
620 totalTurns: idx.AuthoredTurns,
621 revision: revision,
622 revKnown: idx.RevisionKnown,
623 digest: idx.ContentDigest,
624 epoch: epoch,
625 fetch: func(lo, hi int) ([]provider.Message, error) {
626 if lo < 0 || hi < lo || hi > len(idx.Entries) {
627 return nil, fmt.Errorf("history display index window [%d,%d) is out of range", lo, hi)
628 }
629 return readSessionMessagesAtOffsets(sessionPath, idx.Entries[lo:hi])
630 },
631 windowBytes: func(lo, hi int) int64 {
632 if lo >= hi || hi > len(idx.Entries) {
633 return 0
634 }
635 last := idx.Entries[hi-1]
636 return last.Offset + last.Length - idx.Entries[lo].Offset
637 },
638 }
639 return src
640 }
641
642 // readSessionMessagesAtOffsets decodes the message lines for entries, whose
643 // byte ranges are contiguous in the transcript, with one read.
644 func readSessionMessagesAtOffsets(sessionPath string, entries []agent.DisplayIndexEntry) ([]provider.Message, error) {
645 return readSessionMessagesAtOffsetsContext(context.Background(), sessionPath, entries)
646 }
647
648 func readSessionMessagesAtOffsetsContext(ctx context.Context, sessionPath string, entries []agent.DisplayIndexEntry) ([]provider.Message, error) {
649 out := make([]provider.Message, 0, len(entries))
650 if len(entries) == 0 {
651 return out, nil
652 }
653 f, err := os.Open(sessionPath)
654 if err != nil {
655 return nil, err
656 }
657 defer f.Close()
658 spanStart := entries[0].Offset
659 spanEnd := entries[len(entries)-1].Offset + entries[len(entries)-1].Length
660 spanLength := spanEnd - spanStart
661 if spanLength < 0 {
662 return nil, fmt.Errorf("invalid history display index span")
663 }
664 if spanLength <= historySliceColdWindowBytes {
665 buf := make([]byte, int(spanLength))
666 if _, err := io.ReadFull(&historywork.Reader{Context: ctx, Source: io.NewSectionReader(f, spanStart, spanLength)}, buf); err != nil {
667 return nil, err
668 }
669 for _, e := range entries {
670 if err := ctx.Err(); err != nil {
671 return nil, err
672 }
673 start := e.Offset - spanStart
674 end := start + e.Length
675 if start < 0 || end < start || end > int64(len(buf)) {
676 return nil, fmt.Errorf("history display index line %d escapes fetched span", e.Index)
677 }
678 var m provider.Message
679 if err := json.Unmarshal(buf[int(start):int(end)], &m); err != nil {
680 return nil, fmt.Errorf("decode session transcript line %d: %w", e.Index, err)
681 }
682 out = append(out, m)
683 }
684 return out, nil
685 }
686 // A legitimate historical record may exceed the normal 32MiB page span.
687 // Decode it directly from a bounded section so the read path does not first
688 // allocate and copy a second full record-sized byte slice.
689 for _, e := range entries {
690 var m provider.Message
691 dec := json.NewDecoder(&historywork.Reader{Context: ctx, Source: io.NewSectionReader(f, e.Offset, e.Length)})
692 if err := dec.Decode(&m); err != nil {
693 return nil, fmt.Errorf("decode oversized session transcript line %d: %w", e.Index, err)
694 }
695 out = append(out, m)
696 }
697 return out, nil
698 }
699
700 // historySessionLooksEventFormat reports whether the transcript is a legacy
701 // event-record log rather than a provider-message transcript: event records
702 // carry kind/type and no role.
703 func historySessionLooksEventFormat(path string) bool {
704 f, err := os.Open(path)
705 if err != nil {
706 return false
707 }
708 defer f.Close()
709 line, err := bufio.NewReaderSize(f, 1<<20).ReadSlice('\n')
710 if errors.Is(err, bufio.ErrBufferFull) {
711 // Legacy event headers are tiny. A megabyte first record is a provider
712 // message or malformed input, neither of which needs event probing.
713 return false
714 }
715 if len(line) == 0 || err != nil && len(line) == 0 {
716 return false
717 }
718 var probe struct {
719 Role provider.Role `json:"role"`
720 Kind string `json:"kind"`
721 Type string `json:"type"`
722 }
723 if err := json.Unmarshal(line, &probe); err != nil {
724 return false
725 }
726 return probe.Role == "" && (probe.Kind != "" || probe.Type != "")
727 }
728
729 // pageHistorySliceSource cuts one page from src. Pages are suffixes of the
730 // candidate window: the turn budget picks the oldest message that may be
731 // included, conversion runs forward (its cross-message state flows forward),
732 // and the entry/byte budgets drop the oldest whole-message groups — so cuts
733 // always land on message boundaries.
734 func (a *App) pageHistorySliceSource(src *historySliceSource, req HistorySliceRequest, resolver func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int, sessionPath string) (HistorySlice, error) {
735 cursor, err := decodeHistorySliceCursor(req.Cursor)
736 // An undecodable cursor is treated like a request for the latest page.
737 hasCursor := req.Cursor != "" && err == nil
738 sourceID := src.sourceID
739 if sourceID == "" {
740 sourceID = src.sessionID
741 }
742 if src.windowOnly && req.Cursor != "" && (err != nil || cursor.Before < 0 || cursor.Before > src.total || cursor.Source != sourceID) {
743 return staleHistorySlice(src.revision, src.revKnown, src.digest), nil
744 }
745 if hasCursor && !src.identityMatches(cursor.Revision, cursor.RevKnown, cursor.Digest) {
746 return staleHistorySlice(src.revision, src.revKnown, src.digest), nil
747 }
748 hi := src.total
749 if hasCursor && cursor.Before < hi {
750 hi = cursor.Before
751 }
752 forward := req.Newer && hasCursor
753 if forward {
754 hi = min(src.total, cursor.Before+min(req.Entries, 500))
755 }
756 page := HistorySlice{
757 Entries: []HistoryEntry{},
758 TotalTurns: src.totalTurns,
759 Revision: src.revision,
760 RevisionKnown: src.revKnown,
761 Digest: src.digest,
762 }
763 if hi <= 0 || src.total == 0 {
764 return page, nil
765 }
766
767 candidateLo, hi, err := historySliceCandidateRange(src, req, cursor, hi, forward)
768 if err != nil {
769 return emptyHistorySlice(), err
770 }
771
772 window, fetchErr := src.fetch(candidateLo, hi)
773 if fetchErr != nil {
774 return emptyHistorySlice(), fetchErr
775 }
776 if len(window) != hi-candidateLo {
777 return emptyHistorySlice(), fmt.Errorf("history window length %d, want %d", len(window), hi-candidateLo)
778 }
779 if !src.windowOnly {
780 window = historyWindowWithPersistedTimes(window, sessionPath, src.userCountBefore(candidateLo))
781 }
782 toolResults := historyToolResultsByID(window)
783 if err := extendHistoryToolResults(src, window, hi, toolResults); err != nil {
784 return emptyHistorySlice(), err
785 }
786
787 type entryGroup struct {
788 msgIndex int
789 entries []HistoryEntry
790 bytes int
791 }
792 groups := []entryGroup{}
793 entryCount, byteCount := 0, 0
794 state := newHistoryMessageConvertState(plannerTurns)
795 if err := primeHistoryPlannerState(src, state, candidateLo, resolver); err != nil {
796 return emptyHistorySlice(), err
797 }
798 for i := candidateLo; i < hi; i++ {
799 m := window[i-candidateLo]
800 rows := state.convertHistoryMessage(i, m, resolver, checkpointTurns, toolResults)
801 if len(rows) == 0 {
802 continue
803 }
804 g := entryGroup{msgIndex: i, entries: make([]HistoryEntry, 0, len(rows))}
805 for sub, row := range rows {
806 entry := newHistoryEntry(src, fmt.Sprintf("s%s:r%d:m%d:o%d", src.sessionID, src.epoch, i, sub), i, sub, row)
807 g.bytes += entry.inlineBytes()
808 g.entries = append(g.entries, entry)
809 }
810 groups = append(groups, g)
811 entryCount += len(g.entries)
812 byteCount += g.bytes
813 if forward && len(groups) > 1 && (entryCount > req.Entries || byteCount > req.Bytes) {
814 groups = groups[:len(groups)-1]
815 hi = i
816 break
817 }
818 // Keep the newest suffix within budget; always keep the newest group
819 // so a single oversized message still makes progress.
820 for len(groups) > 1 && (entryCount > req.Entries || byteCount > req.Bytes) {
821 entryCount -= len(groups[0].entries)
822 byteCount -= groups[0].bytes
823 groups = groups[1:]
824 }
825 }
826
827 pageStart := candidateLo
828 if len(groups) > 0 {
829 pageStart = groups[0].msgIndex
830 }
831 for _, g := range groups {
832 page.Entries = append(page.Entries, g.entries...)
833 }
834 page = completeHistorySlicePage(page, src, pageStart, hi, sourceID)
835 return page, nil
836 }
837
838 // countRoleBefore counts messages with role in [0, lo).
839 func countRoleBefore(roles []provider.Role, lo int, role provider.Role) int {
840 if lo > len(roles) {
841 lo = len(roles)
842 }
843 count := 0
844 for i := range lo {
845 if roles[i] == role {
846 count++
847 }
848 }
849 return count
850 }
851
852 // extendHistoryToolResults fills in tool results for window tool calls whose
853 // result message lies past the window's newer edge (an intra-turn page cut),
854 // so tool-call summaries match the full-conversion output. It keeps no result
855 // body except one whose call is actually visible, but deliberately scans past
856 // arbitrary non-tool traffic: correctness cannot depend on a result arriving
857 // within a guessed distance.
858 func extendHistoryToolResults(src *historySliceSource, window []provider.Message, hi int, toolResults map[string]provider.Message) error {
859 var want map[string]bool
860 for _, m := range window {
861 for _, tc := range m.ToolCalls {
862 if tc.ID == "" {
863 continue
864 }
865 if _, ok := toolResults[tc.ID]; ok {
866 continue
867 }
868 if want == nil {
869 want = map[string]bool{}
870 }
871 want[tc.ID] = true
872 }
873 }
874 if len(want) == 0 {
875 return nil
876 }
877 for i := hi; i < src.total && len(want) > 0; i++ {
878 if src.roleAt(i) != provider.RoleTool {
879 continue
880 }
881 msgs, err := src.fetch(i, i+1)
882 if err != nil {
883 return err
884 }
885 if len(msgs) != 1 {
886 return fmt.Errorf("tool result window length %d, want 1", len(msgs))
887 }
888 m := msgs[0]
889 if m.ToolCallID != "" && want[m.ToolCallID] {
890 toolResults[m.ToolCallID] = m
891 delete(want, m.ToolCallID)
892 }
893 }
894 return nil
895 }
896
897 // forEachHistorySourceChunk decodes a bounded contiguous message window at a
898 // time. It is the common primitive for the cross-page lookups below; callers
899 // retain only their derived state, never the full transcript.
900 func forEachHistorySourceChunk(src *historySliceSource, end int, visit func([]provider.Message) error) error {
901 if end > src.total {
902 end = src.total
903 }
904 for lo := 0; lo < end; lo += historyLookupChunkMessages {
905 hi := min(lo+historyLookupChunkMessages, end)
906 msgs, err := src.fetch(lo, hi)
907 if err != nil {
908 return err
909 }
910 if len(msgs) != hi-lo {
911 return fmt.Errorf("history lookup window length %d, want %d", len(msgs), hi-lo)
912 }
913 if err := visit(msgs); err != nil {
914 return err
915 }
916 }
917 return nil
918 }
919
920 // primeHistoryPlannerState consumes the non-rendered prefix so planner
921 // displays remain FIFO per duplicated user text and an interrupt's canonical
922 // suppression crosses page boundaries exactly as in a full conversion.
923 func primeHistoryPlannerState(src *historySliceSource, state *historyMessageConvertState, end int, resolver func(string) string) error {
924 if end <= 0 || len(state.plannerByUserHash) == 0 {
925 return nil
926 }
927 return forEachHistorySourceChunk(src, end, func(msgs []provider.Message) error {
928 for _, msg := range msgs {
929 state.consumeHistoryPlannerState(msg, resolver)
930 }
931 return nil
932 })
933 }
934
935 // newHistoryEntry builds one entry, replacing oversized string fields with
936 // preview + ref. entryID is the fully-built entry ID (message- or row-form).
937 func newHistoryEntry(src *historySliceSource, entryID string, msgIndex, sub int, row HistoryMessage) HistoryEntry {
938 entry := HistoryEntry{
939 EntryID: entryID,
940 Turn: src.turnAt(msgIndex),
941 Order: msgIndex,
942 Message: row,
943 Refs: []HistoryContentRef{},
944 }
945 addRef := func(field, toolCallID string, size, chunks int) {
946 entry.Refs = append(entry.Refs, HistoryContentRef{
947 EntryID: entryID,
948 Field: field,
949 Size: size,
950 Chunks: chunks,
951 ToolCallID: toolCallID,
952 Revision: src.revision,
953 RevKnown: src.revKnown,
954 Digest: src.digest,
955 })
956 }
957 m := &entry.Message
958 m.Content = truncateHistoryField(m.Content, "content", "", addRef)
959 m.Reasoning = truncateHistoryField(m.Reasoning, "reasoning", "", addRef)
960 m.SubmitText = truncateHistoryField(m.SubmitText, "submitText", "", addRef)
961 m.Detail = truncateHistoryField(m.Detail, "detail", "", addRef)
962 m.Code = truncateHistoryField(m.Code, "code", "", addRef)
963 m.Summary = truncateHistoryField(m.Summary, "summary", "", addRef)
964 m.Archive = truncateHistoryField(m.Archive, "archive", "", addRef)
965 m.ToolResultError = truncateHistoryField(m.ToolResultError, "toolResultError", "", addRef)
966 for i := range m.ToolCalls {
967 tc := &m.ToolCalls[i]
968 tc.Arguments = truncateHistoryField(tc.Arguments, "toolArguments", tc.ID, addRef)
969 tc.Subject = truncateHistoryField(tc.Subject, "toolSubject", tc.ID, addRef)
970 tc.Summary = truncateHistoryField(tc.Summary, "toolSummary", tc.ID, addRef)
971 tc.Diff = truncateHistoryField(tc.Diff, "toolDiff", tc.ID, addRef)
972 }
973 return entry
974 }
975
976 // truncateHistoryField replaces value with a rune-safe preview and registers
977 // a content ref when it exceeds the inline threshold.
978 func truncateHistoryField(value, field, toolCallID string, addRef func(field, toolCallID string, size, chunks int)) string {
979 if len(value) <= historyInlineRefThreshold {
980 return value
981 }
982 addRef(field, toolCallID, len(value), historyContentChunkCount(value))
983 return clipStringBytes(value, historyFieldPreviewBytes)
984 }
985
986 // inlineBytes approximates the JSON payload contributed by the entry's inline
987 // string fields (post-truncation), for the byte budget.
988 func (e HistoryEntry) inlineBytes() int {
989 m := e.Message
990 n := len(m.Content) + len(m.Detail) + len(m.Code) + len(m.SubmitText) +
991 len(m.Reasoning) + len(m.Summary) + len(m.Archive) + len(m.ToolResultError) +
992 len(m.ToolCallID) + len(m.ToolName) + len(m.Role)
993 for _, tc := range m.ToolCalls {
994 n += len(tc.Arguments) + len(tc.Subject) + len(tc.Summary) + len(tc.Diff) + len(tc.ID) + len(tc.Name)
995 }
996 return n
997 }
998
999 // historyWindowWithPersistedTimes is the window-scoped form of
1000 // historyProviderMessagesWithPersistedTimes: userOffset is the number of
1001 // user-role messages before the window, keeping the ordinal alignment with
1002 // the persisted user-message records.
1003 func historyWindowWithPersistedTimes(msgs []provider.Message, sessionPath string, userOffset int) []provider.Message {
1004 if len(msgs) == 0 || strings.TrimSpace(sessionPath) == "" {
1005 return msgs
1006 }
1007 needsPersistedTime := false
1008 for _, msg := range msgs {
1009 if msg.CreatedAt <= 0 && agent.IsUserAuthoredTurnMessage(msg) {
1010 needsPersistedTime = true
1011 break
1012 }
1013 }
1014 if !needsPersistedTime {
1015 return msgs
1016 }
1017 users, err := agent.LoadSessionUserMessages(sessionPath)
1018 if err != nil || len(users) <= userOffset {
1019 return msgs
1020 }
1021 out := append([]provider.Message(nil), msgs...)
1022 userIndex := userOffset
1023 for i := range out {
1024 if out[i].Role != provider.RoleUser || agent.IsPinnedContextRevision(out[i]) {
1025 continue
1026 }
1027 if userIndex >= len(users) {
1028 break
1029 }
1030 user := users[userIndex]
1031 userIndex++
1032 if out[i].CreatedAt <= 0 && !user.At.IsZero() {
1033 out[i].CreatedAt = user.At.UnixMilli()
1034 }
1035 }
1036 return out
1037 }
1038
1039 // historyContentChunkCount returns the number of rune-aligned ≤256KiB chunks
1040 // for s. The empty string is one empty chunk.
1041 func historyContentChunkCount(s string) int {
1042 if len(s) == 0 {
1043 return 1
1044 }
1045 chunks := 0
1046 for off := 0; off < len(s); chunks++ {
1047 off = historyContentChunkEnd(s, off)
1048 }
1049 return chunks
1050 }
1051
1052 // historyContentChunkEnd returns the end offset of the chunk starting at off:
1053 // off+256KiB backed off to a rune boundary.
1054 func historyContentChunkEnd(s string, off int) int {
1055 end := off + historyContentChunkBytes
1056 if end >= len(s) {
1057 return len(s)
1058 }
1059 for end > off && !utf8.RuneStart(s[end]) {
1060 end--
1061 }
1062 return end
1063 }
1064
1065 // historyContentChunkAt returns chunk index (0-based) of s and the total
1066 // chunk count, splitting on rune boundaries.
1067 func historyContentChunkAt(s string, index int) (string, int) {
1068 chunks := historyContentChunkCount(s)
1069 if index < 0 {
1070 index = 0
1071 }
1072 off := 0
1073 for i := 0; i < index && off < len(s); i++ {
1074 off = historyContentChunkEnd(s, off)
1075 }
1076 if off >= len(s) {
1077 return "", chunks
1078 }
1079 return s[off:historyContentChunkEnd(s, off)], chunks
1080 }
1081
1082 // HistoryContentForTab returns one chunk of a ref-replaced field's full
1083 // value. The entry is re-resolved through the same window machinery; when the
1084 // session's revision/digest moved past the ref, Stale is set so the frontend
1085 // reloads.
1086 func (a *App) HistoryContentForTab(tabID string, ref HistoryContentRef, chunkIndex int) HistoryContentChunk {
1087 if ref.ReadHandleID != "" {
1088 return a.boundNativeHistoryContent(tabID, ref, chunkIndex)
1089 }
1090 out := HistoryContentChunk{EntryID: ref.EntryID, Field: ref.Field, Chunk: max(chunkIndex, 0)}
1091 msgIndex, sub, legacyRow, ok := parseHistoryEntryID(ref.EntryID)
1092 if !ok {
1093 out.Done = true
1094 return out
1095 }
1096 a.mu.RLock()
1097 tab := a.tabByIDLocked(tabID)
1098 var ctrl control.SessionAPI
1099 var sessionDir, sessionPath, sessionID string
1100 if tab != nil {
1101 ctrl = tab.Ctrl
1102 sessionDir = tabSessionDir(tab)
1103 sessionPath = tab.currentSessionPath()
1104 sessionID = strings.TrimSpace(tab.SessionID)
1105 }
1106 a.mu.RUnlock()
1107 if ctrl == nil && sessionID != "" {
1108 return a.canonicalHistoryContentBeforeController(tabID, sessionDir, sessionPath, sessionID, msgIndex, sub, ref, chunkIndex, out)
1109 }
1110 if ctrl != nil {
1111 if identity, ok := ctrl.(control.IdentityLifecycle); ok && identity.UsesExclusiveSession() {
1112 sessionRef, bound := identity.SessionRef()
1113 service := identity.SessionService()
1114 if !bound || service == nil || service.Query() == nil || entryIDSession(ref.EntryID) != sessionRef.SessionID {
1115 out.Stale = true
1116 return out
1117 }
1118 src, err := canonicalHistorySliceSource(service.Query(), sessionRef)
1119 if err != nil || !src.identityMatches(ref.Revision, ref.RevKnown, ref.Digest) {
1120 out.Stale = true
1121 return out
1122 }
1123 value, found, stale := a.historyFieldValueForSource(src, msgIndex, sub, ref, sessionDisplayResolver(sessionDir, sessionPath), nil, nil)
1124 if stale || !found || len(value) != ref.Size {
1125 out.Stale = true
1126 return out
1127 }
1128 data, chunks := historyContentChunkAt(value, chunkIndex)
1129 out.Chunks = chunks
1130 out.Data = data
1131 out.Done = chunkIndex >= chunks-1
1132 return out
1133 }
1134 }
1135 if ctrl != nil {
1136 if p := ctrl.SessionPath(); strings.TrimSpace(p) != "" {
1137 sessionPath = p
1138 sessionDir = controllerSessionDir(ctrl)
1139 }
1140 }
1141 if strings.TrimSpace(sessionPath) == "" {
1142 out.Done = true
1143 return out
1144 }
1145 resolvedSessionID := strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl")
1146 if entryIDSession(ref.EntryID) != resolvedSessionID {
1147 out.Stale = true
1148 return out
1149 }
1150
1151 var value string
1152 var found bool
1153 if legacyRow >= 0 {
1154 value, found = a.legacyHistoryFieldValue(sessionPath, sessionDir, legacyRow, ref)
1155 } else if ctrl != nil {
1156 var stale bool
1157 value, found, stale = a.liveHistoryFieldValue(ctrl, sessionDir, sessionPath, msgIndex, sub, ref)
1158 if stale {
1159 out.Stale = true
1160 return out
1161 }
1162 } else {
1163 var stale bool
1164 value, found, stale = a.coldHistoryFieldValue(sessionDir, sessionPath, msgIndex, sub, ref)
1165 if stale {
1166 out.Stale = true
1167 return out
1168 }
1169 }
1170 if !found {
1171 // The entry or field no longer resolves — content changed underneath.
1172 out.Stale = true
1173 return out
1174 }
1175 if len(value) != ref.Size {
1176 out.Stale = true
1177 return out
1178 }
1179 data, chunks := historyContentChunkAt(value, chunkIndex)
1180 out.Chunks = chunks
1181 out.Data = data
1182 out.Done = chunkIndex >= chunks-1
1183 return out
1184 }
1185
1186 // HistoryContentForTarget re-resolves one compatibility-history content
1187 // capability against the explicit durable target. It never consults the
1188 // selected tab or creates a controller.
1189 func (a *App) HistoryContentForTarget(selector SessionSelector, ref HistoryContentRef, chunkIndex int) (HistoryContentChunk, error) {
1190 out := HistoryContentChunk{EntryID: ref.EntryID, Field: ref.Field, Chunk: max(chunkIndex, 0)}
1191 if ref.ReadHandleID != "" {
1192 // This ref belongs to a navigation reader, not a management target.
1193 out.Stale = true
1194 return out, nil
1195 }
1196 target, err := a.resolveSessionTargetWithArchived(selector, true)
1197 if err != nil {
1198 return out, err
1199 }
1200 msgIndex, sub, legacyRow, ok := parseHistoryEntryID(ref.EntryID)
1201 if !ok {
1202 out.Done = true
1203 return out, nil
1204 }
1205 if target.SessionRef.SessionID != "" {
1206 if entryIDSession(ref.EntryID) != target.SessionRef.SessionID {
1207 out.Stale = true
1208 return out, nil
1209 }
1210 src, sourceErr := canonicalHistorySliceSource(a.desktopSessionService("").Query(), target.SessionRef)
1211 if sourceErr != nil || !src.identityMatches(ref.Revision, ref.RevKnown, ref.Digest) {
1212 out.Stale = true
1213 return out, nil
1214 }
1215 value, found, stale := a.historyFieldValueForSource(
1216 src,
1217 msgIndex,
1218 sub,
1219 ref,
1220 sessionDisplayResolver("", target.SessionPath),
1221 nil,
1222 nil,
1223 )
1224 if stale || !found || len(value) != ref.Size {
1225 out.Stale = true
1226 return out, nil
1227 }
1228 out.Data, out.Chunks = historyContentChunkAt(value, chunkIndex)
1229 out.Done = chunkIndex >= out.Chunks-1
1230 return out, nil
1231 }
1232 sessionDir, sessionPath, pathErr := a.sessionDirForPath(target.SessionPath)
1233 if pathErr != nil {
1234 return out, newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
1235 }
1236 if entryIDSession(ref.EntryID) != strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl") {
1237 out.Stale = true
1238 return out, nil
1239 }
1240 var (
1241 value string
1242 found bool
1243 stale bool
1244 )
1245 if legacyRow >= 0 {
1246 value, found = a.legacyHistoryFieldValue(sessionPath, sessionDir, legacyRow, ref)
1247 } else {
1248 value, found, stale = a.coldHistoryFieldValue(sessionDir, sessionPath, msgIndex, sub, ref)
1249 }
1250 if stale || !found || len(value) != ref.Size {
1251 out.Stale = true
1252 return out, nil
1253 }
1254 out.Data, out.Chunks = historyContentChunkAt(value, chunkIndex)
1255 out.Done = chunkIndex >= out.Chunks-1
1256 return out, nil
1257 }
1258
1259 // parseHistoryEntryID parses s<id>:r<epoch>:m<msgIndex>:o<sub> and the legacy
1260 // event-format s<id>:r<epoch>:e<row>:o0 form.
1261 func parseHistoryEntryID(entryID string) (msgIndex, sub, legacyRow int, ok bool) {
1262 legacyRow = -1
1263 parts := strings.Split(entryID, ":")
1264 if len(parts) != 4 {
1265 return 0, 0, -1, false
1266 }
1267 if _, err := fmt.Sscanf(parts[2], "m%d", &msgIndex); err == nil {
1268 if _, err := fmt.Sscanf(parts[3], "o%d", &sub); err != nil {
1269 return 0, 0, -1, false
1270 }
1271 return msgIndex, sub, -1, true
1272 }
1273 if _, err := fmt.Sscanf(parts[2], "e%d", &legacyRow); err == nil {
1274 return 0, 0, legacyRow, true
1275 }
1276 return 0, 0, -1, false
1277 }
1278
1279 func entryIDSession(entryID string) string {
1280 rest := strings.SplitN(entryID, ":", 2)
1281 if len(rest) != 2 {
1282 return ""
1283 }
1284 return strings.TrimPrefix(rest[0], "s")
1285 }
1286
1287 // liveHistoryFieldValue re-resolves one entry's field from the live session.
1288 func (a *App) liveHistoryFieldValue(ctrl control.SessionAPI, sessionDir, sessionPath string, msgIndex, sub int, ref HistoryContentRef) (string, bool, bool) {
1289 wc, ok := ctrl.(historyWindowController)
1290 if !ok {
1291 return "", false, true
1292 }
1293 ps, psOK := wc.SessionPersistedState()
1294 revKnown := psOK && ps.RevisionKnown
1295 revision := int64(0)
1296 digest := ""
1297 if psOK {
1298 revision = ps.Revision
1299 digest = ps.DigestHex
1300 }
1301 if !psOK || revKnown != ref.RevKnown || revision != ref.Revision || digest != ref.Digest {
1302 return "", false, true
1303 }
1304 resolver := sessionDisplayResolver(sessionDir, sessionPath)
1305 src, _ := a.liveHistorySliceSource(ctrl, sessionPath, resolver)
1306 if src == nil {
1307 return "", false, true
1308 }
1309 return a.historyFieldValueForSource(src, msgIndex, sub, ref, resolver, sessionPlannerDisplayTurns(sessionDir, sessionPath), ctrl.CheckpointTurnsByMessageIndex())
1310 }
1311
1312 // coldHistoryFieldValue re-resolves one entry's field through the same
1313 // authoritative source selection as HistorySliceForTab. It never trusts a
1314 // stale checkpoint merely because the requested message's old offset exists.
1315 func (a *App) coldHistoryFieldValue(sessionDir, sessionPath string, msgIndex, sub int, ref HistoryContentRef) (string, bool, bool) {
1316 sessionDir, absPath, err := a.historyReadSource(sessionDir, sessionPath)
1317 if err != nil {
1318 return "", false, true
1319 }
1320 var value string
1321 var found, stale, handled bool
1322 err = a.withNativeHistoryPager(a.bootContext(), absPath, "", func(ctx context.Context, pager *agent.DisplayPager, sourceID string) error {
1323 handled = true
1324 src := historySourceFromPager(ctx, pager, absPath, sourceID)
1325 value, found, stale = a.historyFieldValueForSource(src, msgIndex, sub, ref,
1326 sessionDisplayResolver(sessionDir, absPath), sessionPlannerDisplayTurns(sessionDir, absPath), nil)
1327 return src.readErr
1328 })
1329 if handled || nativeHistoryPreparationFailure(err) {
1330 return value, found, stale || err != nil
1331 }
1332 // Formats not handled by the native pager retain their compatibility
1333 // reader. A failed read on an admitted native source never falls back.
1334 info, err := os.Stat(absPath)
1335 if err != nil {
1336 return "", false, true
1337 }
1338 resolver := sessionDisplayResolver(sessionDir, absPath)
1339 idx, idxErr := agent.LoadSessionDisplayIndex(store.SessionDisplayIndex(absPath))
1340 identity, identityKnown, identityErr := agent.SessionContentIdentity(absPath)
1341 if identityErr != nil {
1342 return "", false, true
1343 }
1344 valid := idxErr == nil && idx != nil && idx.TranscriptSize == info.Size() && historyIndexTimestampValid(store.SessionDisplayIndex(absPath), absPath, info, idx, true)
1345 if valid && identityKnown {
1346 valid = agent.ValidateSessionDisplayIndex(idx, identity.Revision, identity.RevisionKnown, identity.Digest, info.Size())
1347 } else if valid {
1348 valid = !idx.RevisionKnown
1349 }
1350 var src *historySliceSource
1351 if valid {
1352 src = coldHistorySliceSource(absPath, idx)
1353 } else if scanned, scanErr := agent.ScanSessionDisplayIndex(absPath); scanErr == nil && (!identityKnown || scanned.ContentDigest == identity.DigestHex) {
1354 if identityKnown {
1355 scanned.Revision = identity.Revision
1356 scanned.RevisionKnown = identity.RevisionKnown
1357 }
1358 _ = agent.WriteSessionDisplayIndex(store.SessionDisplayIndex(absPath), scanned)
1359 src = coldHistorySliceSource(absPath, scanned)
1360 } else {
1361 messages, state, repairable, loadErr := agent.LoadSessionDisplayMessages(absPath)
1362 if loadErr != nil || !repairable {
1363 return "", false, true
1364 }
1365 src = newInMemoryHistorySliceSource(strings.TrimSuffix(filepath.Base(absPath), ".jsonl"), messages, resolver, state, true)
1366 }
1367 return a.historyFieldValueForSource(src, msgIndex, sub, ref, resolver, sessionPlannerDisplayTurns(sessionDir, absPath), nil)
1368 }
1369
1370 func (a *App) historyFieldValueForSource(src *historySliceSource, msgIndex, sub int, ref HistoryContentRef, resolver func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int) (string, bool, bool) {
1371 if src == nil || !src.identityMatches(ref.Revision, ref.RevKnown, ref.Digest) || msgIndex < 0 || msgIndex >= src.total {
1372 return "", false, true
1373 }
1374 msgs, err := src.fetch(msgIndex, msgIndex+1)
1375 if err != nil || len(msgs) != 1 {
1376 return "", false, true
1377 }
1378 toolResults := historyToolResultsByID(msgs)
1379 if err := extendHistoryToolResults(src, msgs, msgIndex+1, toolResults); err != nil {
1380 return "", false, true
1381 }
1382 state := newHistoryMessageConvertState(plannerTurns)
1383 if err := primeHistoryPlannerState(src, state, msgIndex, resolver); err != nil {
1384 return "", false, true
1385 }
1386 rows := state.convertHistoryMessage(msgIndex, msgs[0], resolver, checkpointTurns, toolResults)
1387 if sub < 0 || sub >= len(rows) {
1388 return "", false, true
1389 }
1390 value, found := historyEntryFieldValue(&rows[sub], ref.Field, ref.ToolCallID)
1391 return value, found, false
1392 }
1393
1394 // historyEntryFieldValue reads one field of a converted row by ref field name.
1395 func historyEntryFieldValue(m *HistoryMessage, field, toolCallID string) (string, bool) {
1396 switch field {
1397 case "content":
1398 return m.Content, true
1399 case "reasoning":
1400 return m.Reasoning, true
1401 case "submitText":
1402 return m.SubmitText, true
1403 case "detail":
1404 return m.Detail, true
1405 case "code":
1406 return m.Code, true
1407 case "summary":
1408 return m.Summary, true
1409 case "archive":
1410 return m.Archive, true
1411 case "toolResultError":
1412 return m.ToolResultError, true
1413 case "toolArguments", "toolSubject", "toolSummary", "toolDiff":
1414 for i := range m.ToolCalls {
1415 if m.ToolCalls[i].ID != toolCallID {
1416 continue
1417 }
1418 switch field {
1419 case "toolArguments":
1420 return m.ToolCalls[i].Arguments, true
1421 case "toolSubject":
1422 return m.ToolCalls[i].Subject, true
1423 case "toolSummary":
1424 return m.ToolCalls[i].Summary, true
1425 case "toolDiff":
1426 return m.ToolCalls[i].Diff, true
1427 }
1428 }
1429 return "", false
1430 }
1431 return "", false
1432 }
1433
1434 // --- Legacy event-format paging -------------------------------------------
1435
1436 // coldEventHistorySlice pages a legacy event-record session. The decode
1437 // streams the file once per request (constant memory); only ancient sessions
1438 // take this path.
1439 func coldEventHistorySlice(sessionPath string, info os.FileInfo, req HistorySliceRequest) (HistorySlice, error) {
1440 messages, ok, err := previewEventSessionMessages(sessionPath)
1441 if err != nil || !ok {
1442 return emptyHistorySlice(), err
1443 }
1444 digest := fmt.Sprintf("event:%d:%d", info.Size(), info.ModTime().UnixNano())
1445 src := &historySliceSource{
1446 sessionID: strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl"),
1447 digest: digest,
1448 }
1449 return pageHistoryEventRows(src, messages, req), nil
1450 }
1451
1452 // pageHistoryEventRows cuts a page from already-converted rows (legacy event
1453 // format). Row indexes play the role of message indexes; every row is its own
1454 // group. Turns count user rows, 1-based.
1455 func pageHistoryEventRows(src *historySliceSource, rows []HistoryMessage, req HistorySliceRequest) HistorySlice {
1456 cursor, err := decodeHistorySliceCursor(req.Cursor)
1457 hasCursor := req.Cursor != "" && err == nil
1458 if hasCursor && src.digest != cursor.Digest {
1459 return staleHistorySlice(0, false, src.digest)
1460 }
1461 hi := len(rows)
1462 if hasCursor && cursor.Before < hi {
1463 hi = cursor.Before
1464 }
1465 // Visible turn per row.
1466 turns := make([]int, len(rows))
1467 turn := 0
1468 for i, r := range rows {
1469 if r.Role == "user" {
1470 turn++
1471 }
1472 turns[i] = turn
1473 }
1474 src.turns = turns
1475 src.totalTurns = turn
1476 src.total = len(rows)
1477 page := HistorySlice{Entries: []HistoryEntry{}, TotalTurns: turn, Digest: src.digest}
1478 if hi <= 0 {
1479 return page
1480 }
1481 newestTurn := turns[hi-1]
1482 oldestTurn := 0
1483 if newestTurn > 0 {
1484 oldestTurn = max(newestTurn-req.Turns+1, 1)
1485 }
1486 candidateLo := sort.Search(hi, func(i int) bool { return turns[i] >= oldestTurn })
1487 if oldestTurn <= 1 {
1488 candidateLo = 0
1489 }
1490 // Suffix cut: walk backward from the newest row, keeping whole rows until
1491 // a budget is reached; always keep the newest row so a single oversized
1492 // row still makes progress.
1493 kept := make([]HistoryEntry, 0, req.Entries)
1494 entryCount, byteCount := 0, 0
1495 lo := hi
1496 for i := hi - 1; i >= candidateLo; i-- {
1497 entry := newHistoryEntry(src, fmt.Sprintf("s%s:r0:e%d:o0", src.sessionID, i), i, 0, rows[i])
1498 b := entry.inlineBytes()
1499 if len(kept) > 0 && (entryCount+1 > req.Entries || byteCount+b > req.Bytes) {
1500 break
1501 }
1502 kept = append(kept, entry)
1503 entryCount++
1504 byteCount += b
1505 lo = i
1506 }
1507 for _, e := range slices.Backward(kept) {
1508 page.Entries = append(page.Entries, e)
1509 }
1510 for _, e := range page.Entries {
1511 if e.Turn <= 0 {
1512 continue
1513 }
1514 if page.StartTurn == 0 || e.Turn < page.StartTurn {
1515 page.StartTurn = e.Turn
1516 }
1517 if e.Turn > page.EndTurn {
1518 page.EndTurn = e.Turn
1519 }
1520 }
1521 page.HasOlder = lo > 0
1522 if page.HasOlder {
1523 page.NextCursor = encodeHistorySliceCursor(historySliceCursor{V: 1, Digest: src.digest, Before: lo})
1524 }
1525 return page
1526 }
1527
1528 // legacyHistoryFieldValue re-resolves a field of a legacy event-format row.
1529 func (a *App) legacyHistoryFieldValue(sessionPath, sessionDir string, row int, ref HistoryContentRef) (string, bool) {
1530 absPath, _, err := validateSessionPath(sessionDir, sessionPath)
1531 if err != nil {
1532 return "", false
1533 }
1534 messages, ok, err := previewEventSessionMessages(absPath)
1535 if err != nil || !ok || row < 0 || row >= len(messages) {
1536 return "", false
1537 }
1538 return historyEntryFieldValue(&messages[row], ref.Field, ref.ToolCallID)
1539 }
1540
1540 lines GO