You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/04 09:07:21 UTC

(skywalking-ai-sessionizer) branch main updated: Define the Conversation View and serve it as one document

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-ai-sessionizer.git


The following commit(s) were added to refs/heads/main by this push:
     new 33616b5  Define the Conversation View and serve it as one document
33616b5 is described below

commit 33616b5e5abf17d7946462135f6192461a5feb13
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Sep 4 17:07:14 2026 +0800

    Define the Conversation View and serve it as one document
    
    A reader of a conversation wants one thing: every talk as a tree with the
    text its records carry, plus the streams, the segments, the relations,
    and what it was all built from. The page assembled that across many
    requests, and a server holding the same files, such as the SkyWalking
    OAP, would have had to invent its own shape for the same answer.
    
    The Conversation View, cv/1, is that shape, defined once in
    pkg/sessionview and documented with the other two formats. It is never
    a file: asz view builds it in memory once per fold and serves it at
    /api/c/{id}/view, and another server builds the same document from the
    same files. The page's own tree, stream and segment types are now the
    view's, so there is one definition. Measured on the largest real
    conversation: 19.0 MB, built in 0.7 s, 11 MB of it the text and results
    on the trees.
    
    The Session Data page now describes each file kind, what its records
    carry and what refers to it, and the Session Flow page says a round is
    the one kind of .sf file.
---
 CLAUDE.md                                   |   2 +
 docs/en/changes/changes-0.2.0.md            |   6 +
 docs/en/formats/conversation-view.md        |  88 ++++++++++
 docs/en/formats/session-data.md             |  17 ++
 docs/en/formats/session-flow.md             |   2 +
 docs/menu.yml                               |   2 +
 internal/view/api.go                        | 103 +++++++-----
 internal/view/conversationview.go           | 241 +++++++++++++++++++++++++++
 internal/view/step.go                       |  77 ++-------
 internal/view/view.go                       |   6 +
 pkg/sessionview/sessionview.go              | 242 ++++++++++++++++++++++++++++
 tests/adapter/claudecode/local/view_test.go | 124 ++++++++++++++
 tests/boundary/boundary_test.go             |   2 +-
 13 files changed, 811 insertions(+), 101 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 78d7f05..b699e1f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,6 +18,7 @@ Apache-2.0. A subproject of Apache SkyWalking: `github.com/apache/skywalking-ai-
 | `docs/en/concepts-and-designs/unified-conversation-model.md` | The model. Runtime-agnostic — no Claude Code vocabulary belongs here. |
 | `docs/en/concepts-and-designs/conversation-assembly.md` | The eight-stage pipeline and the round chain. |
 | `docs/en/concepts-and-designs/glossary.md` | Every word with a fixed meaning, and what each is **not**. |
+| `docs/en/formats/` | The three shapes: Session Data (`.sd`), Session Flow (`.sf`), Conversation View (`cv/1`, never a file). asz defines and owns all three; a server that mirrors them, such as the OAP, follows these pages. |
 | `docs/en/adapters/claude-code-glossary.md` | What Claude Code calls each of them. Generated by `asz glossary`. |
 | `docs/en/adapters/claude-code.md` | How Claude Code maps onto the model, and what it cannot supply. |
 | `design-notes/` | **Working notes, not documentation.** Measurements, corrections, open questions. Unpolished by intent. |
@@ -54,6 +55,7 @@ cmd/asz/                        CLI: sources · collect · index · show · pars
 pkg/sessiondata/                Session Data: the landed record, `.sd` — public, adapters produce these
 pkg/model/                      the conversation vocabulary: node kinds, relations, qualification
 pkg/sessionflow/                Session Flow: the round chain of conversation structure, `.sf`
+pkg/sessionview/                Conversation View: one conversation as one document, `cv/1`; never a file
 internal/index/                 derived lookup structure the assembler resolves against
 internal/assemble/              the eight-stage pipeline, index in and structure out
 internal/parse/                 one round: assemble, compare against the chain, publish the delta
diff --git a/docs/en/changes/changes-0.2.0.md b/docs/en/changes/changes-0.2.0.md
index 4264338..301dc89 100644
--- a/docs/en/changes/changes-0.2.0.md
+++ b/docs/en/changes/changes-0.2.0.md
@@ -44,6 +44,12 @@
 
 ## Read
 
+- The Conversation View, `cv/1`, is one conversation rebuilt from its rounds and its landed files as
+  one document: every talk as a tree with the text its records carry, the streams, the segments,
+  the relations, the rounds and the files. Package `pkg/sessionview` defines and owns the shape and
+  `asz view` serves it at `/api/c/{id}/view`. It is never a file. A server holding the same files,
+  such as the SkyWalking OAP, builds the same document. See the format page.
+
 - The page reads Session Data and Session Flow and nothing else. It took every record's time from
   the index; it now takes it from the record, so a root that arrives with only its landed files
   and its rounds renders in full, and the index is assembly's alone.
diff --git a/docs/en/formats/conversation-view.md b/docs/en/formats/conversation-view.md
new file mode 100644
index 0000000..a3ae68b
--- /dev/null
+++ b/docs/en/formats/conversation-view.md
@@ -0,0 +1,88 @@
+# Conversation View
+
+The Conversation View is one conversation, rebuilt from its Session Flow and its Session Data,
+as one document. It is what a reader wants: every talk as a tree of runs and steps, with the text
+the referenced records carry, plus the streams, the segments, the relations, and what the
+conversation was built from.
+
+It is never a file. `asz view` builds it in memory from the `.sf` rounds and the `.sd` files and
+serves it as one response at `/api/c/{id}/view`. A server that holds the same files, such as the
+SkyWalking OAP, builds the same document and answers a conversation query with it. Package
+`pkg/sessionview` defines and owns the shape. Every reader shares it, and a change to it is a
+change to the schema, `cv/1`.
+
+Times in a view are unix milliseconds, because a view is read and never digested. Session Data
+and Session Flow carry RFC 3339 strings, because their bytes are.
+
+## The document
+
+```json
+{"schema":"cv/1","conversation":"0438c73b-…","session":"0438c73b-…",
+ "title":"Check otel-rules support for meter mal","from":1786033245531,"to":1786502191749,
+ "head":{"round":4,"digest":"2e77…","through_seq":311,"input_digest":"…","parser":"v1","policy":"v1+idle=10m0s"},
+ "rounds":[{"round":1,"digest":"6d2d…","from_seq":1,"through_seq":5,"input_digest":"…",
+            "from":1786033245531,"to":1786322168715,"session_from":1786033245531,"session_to":1786322168715,
+            "lines":3575,"bytes":1026788,"file_digest":"f3fe…","file":"_conversations/0438c73b-…/rounds/r000001-6d2d93b21f79.sf"}, …],
+ "files":[{"file":"0438c73b-…/streams/main/transcript-…-000002.sd","format":"sd","version":"sd/1","kind":"transcript",
+           "seq":2,"stream":"main","lines":1017,"bytes":2097000,"digest":"9a39…","from":1786033290556,"to":1786036000000}, …],
+ "counts":{"nodes":16129,"relations":703,"unresolved":0,"talks":357,"steps":16129,"streams":132,"segments":50},
+ "kinds":{"llm.call":4656,"tool":5606,…},"relation_types":{"in_segment":346,…},"quality":{"exact_unique":338,…},
+ "streams":[{"id":"stream/main","name":"main","role":"main","label":"","named_by":"","records":10728,
+             "parent":"","talk":"talk/main/95a1…","steps":7310,"opened_by":[]}, …],
+ "segments":[{"id":"segment/at_2_7","state":"candidate","talks":24,"from":1786033290556,"to":1786039329174,"committable":true}, …],
+ "talks":[{"id":"talk/main/95a1…","stream":"main","label":"for meter mal, …","runs":1,"steps":132,"tools":51,
+           "from":1786033290556,"to":1786033717089,"child":false,"segment":"segment/at_2_7","reply":"## Short answer…",
+           "tree":{"id":"talk/main/95a1…","kind":"talk","at":1786033290556,"ref":{"seq":2,"row":7},"children":[…]}}, …],
+ "relations":[{"id":"rel/starts/…","type":"starts","from":"tool/toolu_…","to":"stream/a1…","quality":"exact_unique",
+               "via":"parent tool result","evidence":[{"seq":1,"row":12}]}, …],
+ "unresolved":[]}
+```
+
+| Field | Meaning |
+| --- | --- |
+| `schema` | `cv/1` |
+| `conversation`, `session`, `title` | the conversation, the session it was assembled from, and the last name the runtime gave it |
+| `from`, `to` | when the session began and its last activity, from the session node |
+| `head` | the fold the view was built from: the head round, its digest, how far the landed evidence was consumed, the parser and the policy |
+| `rounds` | every round of the chain in order, from its header and its file. `from` and `to` are the record time range of the files the round consumed; `session_from` and `session_to` the session's range as of that round. A reader can verify the chain from this list alone: each `previous` names the digest before it. |
+| `files` | every landed file of the session, with its kind, digest, size and record time range. Together with `rounds`, this is exactly what a rebuild needs. |
+| `counts`, `kinds`, `relation_types`, `quality` | how large the fold is, by node kind, by relation type, and by how well each relation is known |
+| `streams` | every execution stream, with the step that started it where the assembler found one. `opened_by` lists every candidate and never chooses. |
+| `segments` | every activity window, with the span of the talks placed in it |
+| `talks` | every talk in time order: its summary row, and `tree`, the talk as a tree of runs, steps and what a call contains |
+| `relations` | every relation of the fold, with the records it stands on |
+| `unresolved` | every reference the assembler could not resolve, open or since resolved |
+
+## A node in a tree
+
+```json
+{"id":"tool/toolu_01KH…","kind":"tool","parent":"call/msg_011C…","stream":"main","at":1786033295667,
+ "ref":{"seq":2,"row":13,"block":0},"refs":[{"seq":2,"row":13,"block":0},{"seq":2,"row":14,"block":0}],
+ "attrs":{"name":"Bash","result":"available","result_join":"exact_unique","timing":"unavailable"},
+ "name":"Bash","text":"{\"command\":\"ls …\"}","state":"available","bytes":145,
+ "result":"…","result_state":"available","result_bytes":2048,
+ "request_to_result_ms":1312,"request_to_result_join":"exact_unique",
+ "edges":[{"type":"starts","other":"stream/a1…","dir":"out","quality":"exact_unique","via":"parent tool result"}],
+ "children":[…]}
+```
+
+| Field | Meaning |
+| --- | --- |
+| `id`, `kind`, `parent`, `stream`, `attrs` | the node as the fold holds it |
+| `at` | when its record happened, from the record; `0` when nothing observed it |
+| `ref`, `refs` | the record it stands on and every record it covers, as `{seq, row, block}` |
+| `text`, `name`, `state`, `bytes`, `failed` | what the part it stands on carries: the readable text, clipped to 2,000 bytes, the name of a call, whether the content is here, the full size, and whether the runtime reported a failure. A reader wanting the whole record reads it by address. |
+| `result`, `result_state`, `result_bytes` | for a call, what came back, clipped the same way |
+| `duration_ms`, `duration_measured_by` | for a `turn.duration` step, what the runtime measured and how |
+| `request_to_result_ms`, `request_to_result_join` | for a tool, the time from the request record to the result record, only where the assembler joined them exactly |
+| `edges` | every relation touching the node, in both directions |
+| `children` | containment, in record order |
+
+Nothing in a view is inferred beyond what the fold and the records say. Where the fold says
+`unavailable`, the view says it too.
+
+## Reading it
+
+`asz view` serves a view at `/api/c/{id}/view` and builds it once per fold, so a second reader pays
+nothing until a new round arrives. The largest conversation measured, 357 talks and 16,129 steps,
+is about 19 MB as one document, 4.6 MB of it text.
diff --git a/docs/en/formats/session-data.md b/docs/en/formats/session-data.md
index b340e3b..2c44c77 100644
--- a/docs/en/formats/session-data.md
+++ b/docs/en/formats/session-data.md
@@ -33,6 +33,23 @@ The schema is `sd/1`.
 | `src` | the source, relative to the adapter's root |
 | `session`, `stream`, `batch` | the session, the execution stream (`main` or an agent id), and the group of children a workflow run started |
 
+## File kinds
+
+One `.sd` file holds one cut of one source. The header's `kind` says which, and the records of
+each kind carry a characteristic set of fields.
+
+| `kind` | Collected from | Its records | What refers to it |
+| --- | --- | --- | --- |
+| `transcript` | one execution stream: the main stream or a child agent's own | the conversation itself: inputs, calls in fragments, results, injections, resets. `stream` on the header is `main` or the agent id. | almost every node of a round |
+| `agent_meta` | what the runtime recorded about a child agent when it started it | one record, `from: runtime`, with `child` and `label`, and a `data` part | the child's `stream` node, for its label |
+| `journal` | a workflow run's journal | one record per event, `from: runtime`, with `child` and `batch`; a `child_result` flag on the record that returns a child's value | `agent.output` nodes and `ends_with` relations |
+| `workflow_manifest` | a workflow run's manifest | one record with `batch` and `label`, and a `data` part | the run's name |
+| `workflow_script` | the program a workflow ran | one record whose part is `unknown`: the source is a program, not data | nothing; kept because it is part of the session |
+
+Files of every kind are bound by the rounds' `input_digest`, so a session travels or archives as
+all of them. A session's `agent_meta`, `workflow_manifest` and `workflow_script` records carry
+no time, so those files have no record time range.
+
 ## Record
 
 ```json
diff --git a/docs/en/formats/session-flow.md b/docs/en/formats/session-flow.md
index b313b31..a6fbd6b 100644
--- a/docs/en/formats/session-flow.md
+++ b/docs/en/formats/session-flow.md
@@ -8,6 +8,8 @@ the conversation is the fold of every round. There is no other state.
 The format is public. Package `pkg/sessionflow` defines it, and a consumer reads it. The schema is
 `sf/1`.
 
+There is one kind of `.sf` file, a round; on the wire and in a receiver it is `round`.
+
 ## A round
 
 One round is one file of JSON lines. Every line carries `t`, its frame type, and the frames come in
diff --git a/docs/menu.yml b/docs/menu.yml
index 00fdec6..6e0a992 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -48,6 +48,8 @@ catalog:
           path: /en/formats/session-data
         - name: Session Flow
           path: /en/formats/session-flow
+        - name: Conversation View
+          path: /en/formats/conversation-view
     - name: Adapters
       catalog:
         - name: Claude Code
diff --git a/internal/view/api.go b/internal/view/api.go
index 1d2e32e..e93a269 100644
--- a/internal/view/api.go
+++ b/internal/view/api.go
@@ -31,6 +31,7 @@ import (
 	"github.com/apache/skywalking-ai-sessionizer/pkg/model"
 	"github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
 	"github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
+	"github.com/apache/skywalking-ai-sessionizer/pkg/sessionview"
 )
 
 // Handler serves the page and the four questions a reader asks: which
@@ -154,6 +155,7 @@ func (s *Server) apiGlossary(w http.ResponseWriter, _ *http.Request) {
 }
 
 var flowPath = regexp.MustCompile(`^/api/c/([^/]+)/flow$`)
+var viewPath = regexp.MustCompile(`^/api/c/([^/]+)/view$`)
 var recordPath = regexp.MustCompile(`^/api/c/([^/]+)/record/(\d+)/(\d+)$`)
 var talkPath = regexp.MustCompile(`^/api/c/([^/]+)/talk/(.+)$`)
 
@@ -168,6 +170,10 @@ func (s *Server) apiConversation(w http.ResponseWriter, r *http.Request) {
 		s.apiFlow(w, m[1])
 		return
 	}
+	if m := viewPath.FindStringSubmatch(r.URL.Path); m != nil {
+		s.apiView(w, m[1])
+		return
+	}
 	if m := talkPath.FindStringSubmatch(r.URL.Path); m != nil {
 		s.apiTalk(w, m[1], m[2])
 		return
@@ -202,12 +208,39 @@ type talkRow struct {
 	replyAt *sessionflow.Ref
 }
 
+// overview is what the page's conversation summary and the Conversation
+// View share: computed once per fold, from the fold alone plus the text of
+// the records that name and answer each talk.
+type overview struct {
+	title                string
+	kinds, rels, quality map[string]int
+	talks                []talkRow
+	streams              []sessionview.Stream
+	segments             []sessionview.Segment
+	open                 []map[string]string
+}
+
 func (s *Server) apiOverview(w http.ResponseWriter, id string) {
 	c, err := s.Load(id)
 	if err != nil {
 		fail(w, err, http.StatusNotFound)
 		return
 	}
+	o := c.overview()
+	writeJSON(w, map[string]any{
+		"id": id, "title": o.title, "session": c.Session,
+		"rounds": c.View.Round, "digest": c.View.Digest,
+		"parser": c.View.Parser, "policy": c.View.Policy,
+		"through_seq": c.View.ThroughSeq,
+		"nodes":       len(c.View.Nodes), "relations": len(c.View.Relations),
+		"kinds": o.kinds, "relation_types": o.rels, "quality": o.quality,
+		"talks": o.talks, "unresolved": o.open,
+		"streams":  o.streams,
+		"segments": o.segments,
+	})
+}
+
+func (c *Conversation) overview() *overview {
 	var title string
 	kinds := map[string]int{}
 	for _, n := range c.View.Nodes {
@@ -296,17 +329,16 @@ func (s *Server) apiOverview(w http.ResponseWriter, id string) {
 		open = append(open, map[string]string{"kind": u.Kind, "ref": u.RefID, "reason": u.Reason})
 	}
 
-	writeJSON(w, map[string]any{
-		"id": id, "title": title, "session": c.Session,
-		"rounds": c.View.Round, "digest": c.View.Digest,
-		"parser": c.View.Parser, "policy": c.View.Policy,
-		"through_seq": c.View.ThroughSeq,
-		"nodes":       len(c.View.Nodes), "relations": len(c.View.Relations),
-		"kinds": kinds, "relation_types": rels, "quality": quality,
-		"talks": talks, "unresolved": open,
-		"streams":  streamRows(c, talks),
-		"segments": segmentRows(c, talks),
-	})
+	return &overview{
+		title: title, kinds: kinds, rels: rels, quality: quality,
+		talks: talks, streams: streamRows(c, talks), segments: segmentRows(c, talks), open: open,
+	}
+}
+
+// view converts a talk row to the view's talk, without its tree.
+func (t talkRow) view() sessionview.Talk {
+	return sessionview.Talk{ID: t.ID, Stream: t.Stream, Label: t.Label, Runs: t.Runs, Steps: t.Steps, Tools: t.Tools,
+		From: t.From, To: t.To, Child: t.Child, Segment: t.Segment, Reply: t.Reply}
 }
 
 // segmentRows lists the conversation's segments, each with the span of the
@@ -314,7 +346,7 @@ func (s *Server) apiOverview(w http.ResponseWriter, id string) {
 //
 // A segment's own time is not recomputed here. Its bounds are the bounds of its
 // talks, which is what "in_segment" already decided.
-func segmentRows(c *Conversation, talks []talkRow) []map[string]any {
+func segmentRows(c *Conversation, talks []talkRow) []sessionview.Segment {
 	span := map[string][2]int64{}
 	count := map[string]int{}
 	for _, t := range talks {
@@ -337,13 +369,13 @@ func segmentRows(c *Conversation, talks []talkRow) []map[string]any {
 			segs = append(segs, n)
 		}
 	}
-	out := []map[string]any{}
+	out := []sessionview.Segment{}
 	for _, n := range sessionflow.InOrder(segs) {
 		s := span[n.ID]
-		out = append(out, map[string]any{
-			"id": n.ID, "state": attrString(n, "state"),
-			"talks": count[n.ID], "from": s[0], "to": s[1],
-			"committable": attrBool(n, "committable"),
+		out = append(out, sessionview.Segment{
+			ID: n.ID, State: attrString(n, "state"),
+			Talks: count[n.ID], From: s[0], To: s[1],
+			Committable: attrBool(n, "committable"),
 		})
 	}
 	return out
@@ -355,7 +387,7 @@ func segmentRows(c *Conversation, talks []talkRow) []map[string]any {
 // The parent is read from the "starts" relation, never from the stream name. A
 // call that could have started several streams reports each of them, because
 // the assembler does not choose one and neither may this.
-func streamRows(c *Conversation, talks []talkRow) []map[string]any {
+func streamRows(c *Conversation, talks []talkRow) []sessionview.Stream {
 	firstTalk := map[string]string{}
 	steps := map[string]int{}
 	for i := range talks {
@@ -382,7 +414,7 @@ func streamRows(c *Conversation, talks []talkRow) []map[string]any {
 		}
 	}
 	names := c.journalNames()
-	out := []map[string]any{}
+	out := []sessionview.Stream{}
 	for _, st := range c.Streams() {
 		label, namedBy := attrString(st, "label"), ""
 		if label == "" {
@@ -390,25 +422,20 @@ func streamRows(c *Conversation, talks []talkRow) []map[string]any {
 				label, namedBy = n, "journal"
 			}
 		}
-		out = append(out, map[string]any{
-			"id": st.ID, "name": st.Stream,
-			"role":     attrString(st, "role"),
-			"label":    label,
-			"named_by": namedBy,
-			"records":  attrNumber(st, "records"),
-			"parent":   parent[st.ID],
-			"talk":     firstTalk[st.Stream],
-			"steps":    steps[st.Stream],
-			"opened_by": func() []map[string]string {
-				out := []map[string]string{}
-				for _, o := range from[st.ID] {
-					out = append(out, map[string]string{
-						"step": o.Step, "stream": o.Stream,
-						"quality": o.Quality, "talk": o.Talk,
-					})
-				}
-				return out
-			}(),
+		opened := []sessionview.Origin{}
+		for _, o := range from[st.ID] {
+			opened = append(opened, sessionview.Origin{Step: o.Step, Stream: o.Stream, Quality: o.Quality, Talk: o.Talk})
+		}
+		out = append(out, sessionview.Stream{
+			ID: st.ID, Name: st.Stream,
+			Role:     attrString(st, "role"),
+			Label:    label,
+			NamedBy:  namedBy,
+			Records:  int(attrNumber(st, "records")),
+			Parent:   parent[st.ID],
+			Talk:     firstTalk[st.Stream],
+			Steps:    steps[st.Stream],
+			OpenedBy: opened,
 		})
 	}
 	return out
diff --git a/internal/view/conversationview.go b/internal/view/conversationview.go
new file mode 100644
index 0000000..0e77469
--- /dev/null
+++ b/internal/view/conversationview.go
@@ -0,0 +1,241 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package view
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"net/http"
+	"os"
+	"path/filepath"
+	"sort"
+	"time"
+
+	"github.com/apache/skywalking-ai-sessionizer/internal/storage"
+	"github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+	"github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
+	"github.com/apache/skywalking-ai-sessionizer/pkg/sessionview"
+)
+
+// apiView serves the whole conversation as one document.
+func (s *Server) apiView(w http.ResponseWriter, id string) {
+	c, err := s.Load(id)
+	if err != nil {
+		fail(w, err, http.StatusNotFound)
+		return
+	}
+	v, err := c.Build()
+	if err != nil {
+		fail(w, err, http.StatusInternalServerError)
+		return
+	}
+	writeJSON(w, v)
+}
+
+// Build makes the Conversation View: the fold, every talk as a tree with
+// the text its records carry, and the rounds and files it was built from.
+// It is made once per fold. The records every tree needs are read in one
+// pass per landed file rather than one per talk.
+func (c *Conversation) Build() (*sessionview.Conversation, error) {
+	c.builtMu.Lock()
+	defer c.builtMu.Unlock()
+	if c.built != nil {
+		return c.built, nil
+	}
+	o := c.overview()
+	v := &sessionview.Conversation{
+		Schema: sessionview.Schema, ID: c.ID, Session: c.Session, Title: o.title,
+		Head: sessionview.Head{
+			Round: c.View.Round, Digest: c.View.Digest, ThroughSeq: c.View.ThroughSeq,
+			InputDigest: c.View.InputDigest, Parser: c.View.Parser, Policy: c.View.Policy,
+		},
+		Kinds: o.kinds, RelationTypes: o.rels, Quality: o.quality,
+		Streams: o.streams, Segments: o.segments,
+		Rounds: []sessionview.Round{}, Files: []sessionview.File{}, Talks: []sessionview.Talk{},
+		Relations: []sessionview.Relation{}, Unresolved: []sessionview.Unresolved{},
+	}
+	if sn := c.View.Nodes[sessionflow.NodeID("session", c.Session)]; sn != nil {
+		v.From, v.To = millisOf(attrString(sn, "from_time")), millisOf(attrString(sn, "through_time"))
+	}
+	rounds, err := c.rounds()
+	if err != nil {
+		return nil, err
+	}
+	v.Rounds = rounds
+	files, err := c.files()
+	if err != nil {
+		return nil, err
+	}
+	v.Files = files
+
+	talks := c.Talks()
+	var refs []*sessionflow.Ref
+	for _, t := range talks {
+		refs = append(refs, c.refsUnder(t)...)
+	}
+	recs := c.records(refs)
+	byID := map[string]*sessionflow.Node{}
+	for _, t := range talks {
+		byID[t.ID] = t
+	}
+	steps := 0
+	for _, row := range o.talks {
+		t := row.view()
+		if n := byID[row.ID]; n != nil {
+			tree := c.step(n, 0, recs)
+			t.Tree = &tree
+		}
+		steps += row.Steps
+		v.Talks = append(v.Talks, t)
+	}
+
+	for _, r := range c.View.Relations {
+		v.Relations = append(v.Relations, sessionview.Relation{
+			ID: r.ID, Type: r.Type, From: r.From, To: r.To, Quality: r.Quality, Via: r.Via, Evidence: r.Evidence,
+		})
+	}
+	sort.Slice(v.Relations, func(i, j int) bool { return v.Relations[i].ID < v.Relations[j].ID })
+	for _, u := range c.View.Unresolved {
+		v.Unresolved = append(v.Unresolved, sessionview.Unresolved{ID: u.ID, Kind: u.Kind, Ref: u.RefID, Reason: u.Reason, State: u.State})
+	}
+	sort.Slice(v.Unresolved, func(i, j int) bool { return v.Unresolved[i].ID < v.Unresolved[j].ID })
+	v.Counts = sessionview.Counts{
+		Nodes: len(c.View.Nodes), Relations: len(c.View.Relations), Unresolved: len(o.open),
+		Talks: len(v.Talks), Steps: steps, Streams: len(v.Streams), Segments: len(v.Segments),
+	}
+	c.built = v
+	return v, nil
+}
+
+// rounds lists the chain from each round's header and file.
+func (c *Conversation) rounds() ([]sessionview.Round, error) {
+	chain := sessionflow.OpenChain(c.zone.Root(), c.ID)
+	list, err := chain.List()
+	if err != nil {
+		return nil, err
+	}
+	out := make([]sessionview.Round, 0, len(list))
+	for _, rf := range list {
+		r, err := chain.Open(rf.Path)
+		if err != nil {
+			return nil, err
+		}
+		data, err := os.ReadFile(rf.Path)
+		if err != nil {
+			return nil, err
+		}
+		h := r.Header
+		out = append(out, sessionview.Round{
+			Round: h.Round, Digest: r.Commit.Digest, Previous: h.Previous,
+			FromSeq: h.FromSeq, ThroughSeq: h.ThroughSeq, InputDigest: h.InputDigest,
+			From: millisOf(h.FromTime), To: millisOf(h.ThroughTime),
+			SessionFrom: millisOf(h.SessionFromTime), SessionTo: millisOf(h.SessionThroughTime),
+			Lines: 2 + len(r.Nodes) + len(r.Relations) + len(r.Unresolved), Bytes: int64(len(data)),
+			FileDigest: digestOf(data), File: c.relPath(rf.Path),
+		})
+	}
+	return out, nil
+}
+
+// files lists the session's landed files, each with its digest, its size
+// and the record time range the page already read.
+func (c *Conversation) files() ([]sessionview.File, error) {
+	landed, err := storage.LandedFiles(c.zone, c.Session)
+	if err != nil {
+		return nil, err
+	}
+	type span struct{ from, to int64 }
+	spans := map[uint64]span{}
+	for pos, ns := range c.at {
+		s := spans[pos[0]]
+		if s.from == 0 || ns < s.from {
+			s.from = ns
+		}
+		if ns > s.to {
+			s.to = ns
+		}
+		spans[pos[0]] = s
+	}
+	out := make([]sessionview.File, 0, len(landed))
+	for _, lf := range landed {
+		data, err := os.ReadFile(lf.Path)
+		if err != nil {
+			return nil, err
+		}
+		var hdr sessiondata.Header
+		if i := indexByte(data, '\n'); i >= 0 {
+			_ = json.Unmarshal(data[:i], &hdr)
+		}
+		f := sessionview.File{
+			File: c.relPath(lf.Path), Format: "sd", Version: hdr.Schema, Kind: string(hdr.Kind),
+			Seq: lf.Seq, Stream: lf.Stream, Run: lf.RunID,
+			Lines: countByte(data, '\n'), Bytes: int64(len(data)), Digest: digestOf(data),
+		}
+		if s, ok := spans[lf.Seq]; ok {
+			f.From, f.To = Millis(s.from), Millis(s.to)
+		}
+		out = append(out, f)
+	}
+	return out, nil
+}
+
+func (c *Conversation) relPath(path string) string {
+	rel, err := filepath.Rel(c.zone.Root(), path)
+	if err != nil {
+		return path
+	}
+	return filepath.ToSlash(rel)
+}
+
+func digestOf(data []byte) string {
+	sum := sha256.Sum256(data)
+	return hex.EncodeToString(sum[:])
+}
+
+func indexByte(b []byte, x byte) int {
+	for i := range b {
+		if b[i] == x {
+			return i
+		}
+	}
+	return -1
+}
+
+func countByte(b []byte, x byte) int {
+	n := 0
+	for i := range b {
+		if b[i] == x {
+			n++
+		}
+	}
+	return n
+}
+
+// millisOf renders a record time a round header carries as unix
+// milliseconds, or 0 when it carries none.
+func millisOf(s string) int64 {
+	if s == "" {
+		return 0
+	}
+	t, err := time.Parse(time.RFC3339Nano, s)
+	if err != nil {
+		return 0
+	}
+	return t.UnixMilli()
+}
diff --git a/internal/view/step.go b/internal/view/step.go
index 2a299bc..4a43552 100644
--- a/internal/view/step.go
+++ b/internal/view/step.go
@@ -33,64 +33,17 @@ import (
 	"github.com/apache/skywalking-ai-sessionizer/pkg/model"
 	"github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
 	"github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
+	"github.com/apache/skywalking-ai-sessionizer/pkg/sessionview"
 )
 
 // step is one node as a reader meets it.
-type step struct {
-	ID     string            `json:"id"`
-	Kind   string            `json:"kind"`
-	Parent string            `json:"parent,omitempty"`
-	Stream string            `json:"stream,omitempty"`
-	At     int64             `json:"at"`
-	Ref    *sessionflow.Ref  `json:"ref,omitempty"`
-	Refs   []sessionflow.Ref `json:"refs,omitempty"`
-	Attrs  json.RawMessage   `json:"attrs,omitempty"`
-	Text   string            `json:"text,omitempty"`
-	Name   string            `json:"name,omitempty"`
-	Failed *bool             `json:"failed,omitempty"`
-	State  string            `json:"state,omitempty"`
-	Bytes  int               `json:"bytes,omitempty"`
-
-	// Result is what a tool sent back. A tool use is one step carrying both
-	// halves, so the card that shows the command shows the output too.
-	Result      string `json:"result,omitempty"`
-	ResultState string `json:"result_state,omitempty"`
-	ResultBytes int    `json:"result_bytes,omitempty"`
-
-	// DurationMS is a duration the runtime measured, not one inferred from
-	// the gap between records. Only a step whose source reports one has it.
-	DurationMS  int64  `json:"duration_ms,omitempty"`
-	DurationHow string `json:"duration_measured_by,omitempty"`
-
-	// RequestToResultMS is how long lay between the record carrying the
-	// request and the record carrying the result.
-	//
-	// This is NOT the duration the tool ran, and it is not what "timing"
-	// means. It is an interval between two landed records. It is reported
-	// only where an exact identifier ties those two records to each other,
-	// so the interval belongs to this call and not to whatever happened to
-	// be written next.
-	RequestToResultMS int64  `json:"request_to_result_ms,omitempty"`
-	RequestToResultBy string `json:"request_to_result_join,omitempty"`
-	Children          []step `json:"children,omitempty"`
-	Edges             []edge `json:"edges,omitempty"`
-}
-
-// edge is one typed relation, as it reads from this node's side.
-type edge struct {
-	Type    string `json:"type"`
-	Other   string `json:"other"`
-	Dir     string `json:"dir"` // "out" or "in"
-	Quality string `json:"quality"`
-	Via     string `json:"via,omitempty"`
-}
+// step and edge are the Conversation View's node and edge: the page serves
+// the same shape the view is made of, so there is one definition.
+type (
+	step = sessionview.Node
+	edge = sessionview.Edge
+)
 
-// preview is how much of a step's text is sent with the tree.
-//
-// A tool result reaches a megabyte and a session's results can be four fifths
-// of its bytes, so the tree carries an opening and the reader asks for the rest
-// when it wants it. The state and the true size travel with it, so nothing is
-// shown as complete when it is not.
 const preview = 2000
 
 func (s *Server) apiTalk(w http.ResponseWriter, id, talk string) {
@@ -182,10 +135,10 @@ func (c *Conversation) step(n *sessionflow.Node, depth int, recs map[[2]uint64]*
 		At: Millis(c.Time(n)), Ref: n.Ref, Refs: n.Refs, Attrs: n.Attrs,
 	}
 	for _, r := range c.from[n.ID] {
-		out.Edges = append(out.Edges, edge{r.Type, r.To, "out", r.Quality, r.Via})
+		out.Edges = append(out.Edges, edge{Type: r.Type, Other: r.To, Dir: "out", Quality: r.Quality, Via: r.Via})
 	}
 	for _, r := range c.to[n.ID] {
-		out.Edges = append(out.Edges, edge{r.Type, r.From, "in", r.Quality, r.Via})
+		out.Edges = append(out.Edges, edge{Type: r.Type, Other: r.From, Dir: "in", Quality: r.Quality, Via: r.Via})
 	}
 	// The content a reader sees, taken from the part this node points at.
 	//
@@ -194,15 +147,15 @@ func (c *Conversation) step(n *sessionflow.Node, depth int, recs map[[2]uint64]*
 	// record it starts at would show a tool's command as the call's own words.
 	if n.Ref != nil && carriesContent(n.Kind) {
 		if rec := recs[[2]uint64{n.Ref.Seq, n.Ref.Row}]; rec != nil {
-			out.fill(rec, n.Ref.Block)
-			out.fillDuration(n, rec)
+			fill(&out, rec, n.Ref.Block)
+			fillDuration(&out, n, rec)
 		}
 		// A tool use is one step carrying request and result. The request is
 		// refs[0]; anything after it is what came back.
 		for i := 1; i < len(n.Refs); i++ {
 			r := n.Refs[i]
 			if rec := recs[[2]uint64{r.Seq, r.Row}]; rec != nil && out.Result == "" {
-				out.fillResult(rec, r.Block)
+				fillResult(&out, rec, r.Block)
 			}
 		}
 		c.fillRequestToResult(&out, n)
@@ -216,7 +169,7 @@ func (c *Conversation) step(n *sessionflow.Node, depth int, recs map[[2]uint64]*
 }
 
 // fill takes a step's readable content from the record it points at.
-func (s *step) fill(rec *sessiondata.Record, block *int) {
+func fill(s *step, rec *sessiondata.Record, block *int) {
 	var p *sessiondata.Part
 	if block != nil && *block < len(rec.Parts) {
 		p = &rec.Parts[*block]
@@ -273,7 +226,7 @@ func (c *Conversation) fillRequestToResult(out *step, n *sessionflow.Node) {
 }
 
 // fillResult takes what a tool sent back from the record it points at.
-func (s *step) fillResult(rec *sessiondata.Record, block *int) {
+func fillResult(s *step, rec *sessiondata.Record, block *int) {
 	var p *sessiondata.Part
 	if block != nil && *block < len(rec.Parts) {
 		p = &rec.Parts[*block]
@@ -306,7 +259,7 @@ func (s *step) fillResult(rec *sessiondata.Record, block *int) {
 // Nothing here is inferred from the gap between two records. A step whose
 // source does not report a duration keeps none, and the reader is told so
 // rather than shown a number that was calculated.
-func (s *step) fillDuration(n *sessionflow.Node, rec *sessiondata.Record) {
+func fillDuration(s *step, n *sessionflow.Node, rec *sessiondata.Record) {
 	if n.Kind != model.KindTurnDuration {
 		return
 	}
diff --git a/internal/view/view.go b/internal/view/view.go
index f36b1f5..5a74b28 100644
--- a/internal/view/view.go
+++ b/internal/view/view.go
@@ -41,6 +41,7 @@ import (
 	"github.com/apache/skywalking-ai-sessionizer/pkg/model"
 	"github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
 	"github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
+	"github.com/apache/skywalking-ai-sessionizer/pkg/sessionview"
 )
 
 // Server reads conversations out of a storage zone.
@@ -91,6 +92,11 @@ type Conversation struct {
 	pathsMu   sync.Mutex
 	paths     map[uint64]string
 	pathsScan time.Time
+
+	// built is the Conversation View, made once per fold. Forget drops the
+	// whole Conversation, and the view with it, when a round arrives.
+	builtMu sync.Mutex
+	built   *sessionview.Conversation
 }
 
 // Load folds a conversation and builds its lookups, once.
diff --git a/pkg/sessionview/sessionview.go b/pkg/sessionview/sessionview.go
new file mode 100644
index 0000000..c9a151f
--- /dev/null
+++ b/pkg/sessionview/sessionview.go
@@ -0,0 +1,242 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package sessionview defines the Conversation View: one conversation,
+// rebuilt from its Session Flow and its Session Data, as one document.
+//
+// It is never a file. asz view builds it in memory and serves it as one
+// response, and a server that holds the same landed files and rounds, such
+// as the SkyWalking OAP, builds the same document and answers a conversation
+// query with it. This package defines and owns the shape; every reader
+// shares it, and a change to it is a change to the schema below.
+//
+// Times in a view are unix milliseconds, because a view is read and never
+// digested. Session Data and Session Flow carry RFC 3339 strings, because
+// their bytes are.
+package sessionview
+
+import (
+	"encoding/json"
+
+	"github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
+)
+
+// Schema is the version of the document shape.
+const Schema = "cv/1"
+
+// Conversation is the whole view.
+type Conversation struct {
+	Schema  string `json:"schema"`
+	ID      string `json:"conversation"`
+	Session string `json:"session"`
+	// Title is the last name the runtime gave the session, or empty.
+	Title string `json:"title,omitempty"`
+	// From and To are when the session began and its last activity, from
+	// the session node.
+	From int64 `json:"from"`
+	To   int64 `json:"to"`
+
+	Head   Head    `json:"head"`
+	Rounds []Round `json:"rounds"`
+	Files  []File  `json:"files"`
+
+	Counts        Counts         `json:"counts"`
+	Kinds         map[string]int `json:"kinds"`
+	RelationTypes map[string]int `json:"relation_types"`
+	Quality       map[string]int `json:"quality"`
+
+	Streams    []Stream     `json:"streams"`
+	Segments   []Segment    `json:"segments"`
+	Talks      []Talk       `json:"talks"`
+	Relations  []Relation   `json:"relations"`
+	Unresolved []Unresolved `json:"unresolved"`
+}
+
+// Head identifies the fold the view was built from.
+type Head struct {
+	Round       uint64 `json:"round"`
+	Digest      string `json:"digest"`
+	ThroughSeq  uint64 `json:"through_seq"`
+	InputDigest string `json:"input_digest"`
+	Parser      string `json:"parser"`
+	Policy      string `json:"policy"`
+}
+
+// Round is one round of the chain, from its header and its file.
+type Round struct {
+	Round       uint64 `json:"round"`
+	Digest      string `json:"digest"`
+	Previous    string `json:"previous,omitempty"`
+	FromSeq     uint64 `json:"from_seq"`
+	ThroughSeq  uint64 `json:"through_seq"`
+	InputDigest string `json:"input_digest"`
+	// From and To are the record time range of the files the round
+	// consumed; SessionFrom and SessionTo the session's range as of the
+	// round. Zero when the header carries none.
+	From        int64  `json:"from,omitempty"`
+	To          int64  `json:"to,omitempty"`
+	SessionFrom int64  `json:"session_from,omitempty"`
+	SessionTo   int64  `json:"session_to,omitempty"`
+	Lines       int    `json:"lines"`
+	Bytes       int64  `json:"bytes"`
+	FileDigest  string `json:"file_digest"`
+	File        string `json:"file"`
+}
+
+// File is one landed file of the session.
+type File struct {
+	File    string `json:"file"`
+	Format  string `json:"format"`
+	Version string `json:"version"`
+	Kind    string `json:"kind"`
+	Seq     uint64 `json:"seq"`
+	Stream  string `json:"stream,omitempty"`
+	Run     string `json:"run,omitempty"`
+	Lines   int    `json:"lines"`
+	Bytes   int64  `json:"bytes"`
+	Digest  string `json:"digest"`
+	// From and To are the record time range of the file. Zero when no
+	// record carries a time.
+	From int64 `json:"from,omitempty"`
+	To   int64 `json:"to,omitempty"`
+}
+
+// Counts sizes the fold.
+type Counts struct {
+	Nodes      int `json:"nodes"`
+	Relations  int `json:"relations"`
+	Unresolved int `json:"unresolved"`
+	Talks      int `json:"talks"`
+	Steps      int `json:"steps"`
+	Streams    int `json:"streams"`
+	Segments   int `json:"segments"`
+}
+
+// Stream is one execution stream, with the step that started it.
+type Stream struct {
+	ID      string `json:"id"`
+	Name    string `json:"name"`
+	Role    string `json:"role"`
+	Label   string `json:"label"`
+	NamedBy string `json:"named_by"`
+	Records int    `json:"records"`
+	Parent  string `json:"parent"`
+	Talk    string `json:"talk"`
+	Steps   int    `json:"steps"`
+	// OpenedBy lists every step the assembler could tie to the start of
+	// this stream, with the quality of each. Several means it did not
+	// choose, and neither does a view.
+	OpenedBy []Origin `json:"opened_by"`
+}
+
+// Origin is one step that may have started a stream.
+type Origin struct {
+	Step    string `json:"step"`
+	Stream  string `json:"stream"`
+	Quality string `json:"quality"`
+	Talk    string `json:"talk"`
+}
+
+// Segment is one activity window, with the span of the talks placed in it.
+type Segment struct {
+	ID          string `json:"id"`
+	State       string `json:"state"`
+	Talks       int    `json:"talks"`
+	From        int64  `json:"from"`
+	To          int64  `json:"to"`
+	Committable bool   `json:"committable"`
+}
+
+// Talk is one readable interaction: its summary, and its tree of runs and
+// steps when the view carries trees.
+type Talk struct {
+	ID      string `json:"id"`
+	Stream  string `json:"stream"`
+	Label   string `json:"label"`
+	Runs    int    `json:"runs"`
+	Steps   int    `json:"steps"`
+	Tools   int    `json:"tools"`
+	From    int64  `json:"from"`
+	To      int64  `json:"to"`
+	Child   bool   `json:"child"`
+	Segment string `json:"segment,omitempty"`
+	// Reply is the first 2,000 bytes of the talk's last assistant message.
+	Reply string `json:"reply,omitempty"`
+	Tree  *Node  `json:"tree,omitempty"`
+}
+
+// Node is one entity of the fold with what its record says: the readable
+// text of the part it stands on, clipped to 2,000 bytes, with the full size
+// in Bytes. The whole record is read by address, {seq, row}, from the file.
+type Node struct {
+	ID     string            `json:"id"`
+	Kind   string            `json:"kind"`
+	Parent string            `json:"parent,omitempty"`
+	Stream string            `json:"stream,omitempty"`
+	At     int64             `json:"at"`
+	Ref    *sessionflow.Ref  `json:"ref,omitempty"`
+	Refs   []sessionflow.Ref `json:"refs,omitempty"`
+	Attrs  json.RawMessage   `json:"attrs,omitempty"`
+	Text   string            `json:"text,omitempty"`
+	Name   string            `json:"name,omitempty"`
+	Failed *bool             `json:"failed,omitempty"`
+	State  string            `json:"state,omitempty"`
+	Bytes  int               `json:"bytes,omitempty"`
+
+	Result      string `json:"result,omitempty"`
+	ResultState string `json:"result_state,omitempty"`
+	ResultBytes int    `json:"result_bytes,omitempty"`
+
+	DurationMS  int64  `json:"duration_ms,omitempty"`
+	DurationHow string `json:"duration_measured_by,omitempty"`
+
+	RequestToResultMS int64  `json:"request_to_result_ms,omitempty"`
+	RequestToResultBy string `json:"request_to_result_join,omitempty"`
+
+	Children []Node `json:"children,omitempty"`
+	Edges    []Edge `json:"edges,omitempty"`
+}
+
+// Edge is one relation seen from a node.
+type Edge struct {
+	Type    string `json:"type"`
+	Other   string `json:"other"`
+	Dir     string `json:"dir"` // "out" or "in"
+	Quality string `json:"quality"`
+	Via     string `json:"via,omitempty"`
+}
+
+// Relation is one relation of the fold.
+type Relation struct {
+	ID       string            `json:"id"`
+	Type     string            `json:"type"`
+	From     string            `json:"from"`
+	To       string            `json:"to"`
+	Quality  string            `json:"quality"`
+	Via      string            `json:"via,omitempty"`
+	Evidence []sessionflow.Ref `json:"evidence,omitempty"`
+}
+
+// Unresolved is one reference the assembler could not resolve, open or
+// since resolved.
+type Unresolved struct {
+	ID     string `json:"id"`
+	Kind   string `json:"kind"`
+	Ref    string `json:"ref"`
+	Reason string `json:"reason"`
+	State  string `json:"state"`
+}
diff --git a/tests/adapter/claudecode/local/view_test.go b/tests/adapter/claudecode/local/view_test.go
new file mode 100644
index 0000000..8b5466f
--- /dev/null
+++ b/tests/adapter/claudecode/local/view_test.go
@@ -0,0 +1,124 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package local_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/apache/skywalking-ai-sessionizer/internal/parse"
+	"github.com/apache/skywalking-ai-sessionizer/internal/view"
+	"github.com/apache/skywalking-ai-sessionizer/pkg/sessionview"
+)
+
+// TestConversationViewIsOneDocument reads the fixture session as the one
+// document the page serves and a server holding the same files would build:
+// every talk as a tree with its text, the rounds and the files it came
+// from, and the session's own range. The counts are the fixture's, fixed by
+// its files, and any drift here is a change to the view's schema or to the
+// assembly, both of which this test is meant to catch.
+func TestConversationViewIsOneDocument(t *testing.T) {
+	c := collect(t)
+	for _, id := range []string{case1, case2} {
+		if _, err := parse.Session(c.zone, parse.Options{Conversation: id, Session: id}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	srv := httptest.NewServer(view.New(c.zone, nil).Handler())
+	defer srv.Close()
+
+	res, err := http.Get(srv.URL + "/api/c/" + case1 + "/view")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer res.Body.Close()
+	if res.StatusCode != http.StatusOK {
+		t.Fatalf("status %d", res.StatusCode)
+	}
+	var v sessionview.Conversation
+	if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
+		t.Fatal(err)
+	}
+
+	if v.Schema != sessionview.Schema || v.ID != case1 || v.Session != case1 || v.Title != "build and check" {
+		t.Fatalf("head of the document: schema=%q id=%q session=%q title=%q", v.Schema, v.ID, v.Session, v.Title)
+	}
+	// The session began at the first timed record, 2026-01-01T00:00:00Z, and
+	// was last active at the context reset, thirty seconds later.
+	if v.From != 1767225600000 || v.To != 1767225630000 {
+		t.Fatalf("session range %d..%d", v.From, v.To)
+	}
+	if v.Head.Round != 1 || v.Head.ThroughSeq != 7 || v.Head.Digest == "" || v.Head.Parser != "v1" {
+		t.Fatalf("head: %+v", v.Head)
+	}
+	if len(v.Rounds) != 1 || v.Rounds[0].FromSeq != 1 || v.Rounds[0].ThroughSeq != 7 ||
+		v.Rounds[0].From != v.From || v.Rounds[0].SessionTo != v.To || v.Rounds[0].Digest != v.Head.Digest ||
+		v.Rounds[0].Lines != 50 || v.Rounds[0].FileDigest == "" {
+		t.Fatalf("rounds: %+v", v.Rounds)
+	}
+	if len(v.Files) != 7 {
+		t.Fatalf("files: %d, want the fixture's 7", len(v.Files))
+	}
+	kinds := map[string]int{}
+	for _, f := range v.Files {
+		kinds[f.Kind]++
+		if f.Digest == "" || f.Lines < 3 || f.Bytes == 0 || f.Version != "sd/1" || f.Format != "sd" {
+			t.Fatalf("file: %+v", f)
+		}
+		// Meta, manifest and script records carry no time, so those files
+		// have no range; transcripts and the journal have one.
+		timed := f.From != 0
+		if want := f.Kind == "transcript"; timed != want {
+			t.Fatalf("file %s of kind %s: timed=%v", f.File, f.Kind, timed)
+		}
+	}
+	if kinds["transcript"] != 3 || kinds["agent_meta"] != 1 || kinds["journal"] != 1 || kinds["workflow_manifest"] != 1 || kinds["workflow_script"] != 1 {
+		t.Fatalf("file kinds: %v", kinds)
+	}
+	if v.Counts.Talks != 3 || v.Counts.Streams != 3 || v.Counts.Relations != 8 || v.Counts.Unresolved != 2 || v.Counts.Nodes != 38 {
+		t.Fatalf("counts: %+v", v.Counts)
+	}
+	if len(v.Talks) != 3 || len(v.Relations) != 8 || len(v.Unresolved) != 2 || len(v.Streams) != 3 {
+		t.Fatalf("lists: talks=%d relations=%d unresolved=%d streams=%d", len(v.Talks), len(v.Relations), len(v.Unresolved), len(v.Streams))
+	}
+	// The first talk is the person's input, as a tree down to what the
+	// call contains, with the text the record carries.
+	first := v.Talks[0]
+	if first.Label != "run the build" || first.Runs != 2 || first.Tree == nil || first.Tree.Kind != "talk" {
+		t.Fatalf("first talk: %+v", first)
+	}
+	var input *sessionview.Node
+	var walk func(n *sessionview.Node)
+	walk = func(n *sessionview.Node) {
+		if n.Kind == "message.external" && n.Text == "run the build" {
+			input = n
+		}
+		for i := range n.Children {
+			walk(&n.Children[i])
+		}
+	}
+	walk(first.Tree)
+	if input == nil || input.Ref == nil || input.Ref.Seq != 1 || input.Ref.Row != 3 || input.At != 1767225601000 {
+		t.Fatalf("the input under the first talk: %+v", input)
+	}
+	if first.Reply != "Build passed and tests are green." {
+		t.Fatalf("reply: %q", first.Reply)
+	}
+}
diff --git a/tests/boundary/boundary_test.go b/tests/boundary/boundary_test.go
index f4a03ca..b63f7c4 100644
--- a/tests/boundary/boundary_test.go
+++ b/tests/boundary/boundary_test.go
@@ -40,7 +40,7 @@ var (
 	// The exporter is the collector side's way out: it reads the storage root
 	// and sends, and must not know how anything is assembled or shown.
 	collectorSide = []string{"internal/adapters/", "internal/export/"}
-	serverSide    = []string{"internal/assemble/", "internal/parse/", "internal/view/", "internal/verify/", "pkg/sessionflow/"}
+	serverSide    = []string{"internal/assemble/", "internal/parse/", "internal/view/", "internal/verify/", "pkg/sessionflow/", "pkg/sessionview/"}
 )
 
 func TestSidesMeetOnlyAtTheStorageRoot(t *testing.T) {