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) {