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