Read AI agent conversation files over the OAP's HTTP route (#235)

The OAP drops the getConversationRawFiles GraphQL query. A conversation's
stored Session Data files are read from its HTTP route beside the view,
GET /ai-agent/conversations/{conversation}/v1/files, and the view and the
files route now both require the service and the sender's instance.

- ai-agent files requires --instance-name, --session and --seqs. A file is
  chosen by its session and its landed seq, which the conversation's
  asz.view document lists; there is no read of every file. The seqs are
  asked for 32 at a time, the route's limit, over one HTTP client.
- The body, application/vnd.skywalking.asz.files+ndjson, is read file by
  file: a naming line, then exactly the bytes it names, then the newline
  that follows a non-empty file not ending with one. Each file is checked
  against its sha256, and a size no response carries is never allocated.
- --export writes through os.Root, so a file's name cannot reach outside
  the export directory, through ".." or through a symbolic link.
- ai-agent view requires --instance-name.
diff --git a/CHANGES.md b/CHANGES.md
index 5e1abc2..63d55f8 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -7,7 +7,7 @@
 
 ### Features
 
-* Add the `ai-agent` commands, `list`, `files` and `view`, for the AI agent conversations the AI Sessionizer lands in the OAP (11.1.0+); `view` reads the whole conversation as one `asz.view` document from the OAP's streamed route on the GraphQL host by @wu-sheng in https://github.com/apache/skywalking-cli/pull/234
+* Add the `ai-agent` commands, `list`, `files` and `view`, for the AI agent conversations the AI Sessionizer lands in the OAP (11.1.0+); `view` reads the whole conversation as one `asz.view` document, and `files` lists or exports its stored files by name, both from the OAP's streamed routes on the GraphQL host and both requiring the service and the sender's instance by @wu-sheng in https://github.com/apache/skywalking-cli/pull/234
 * Add the sub-command `profiling async` for async-profiler query API by @zhengziyi0117 in https://github.com/apache/skywalking-cli/pull/203
 * Support the owner in MQE response by using [10.2 MQE query protocol](https://github.com/apache/skywalking-query-protocol/pull/141) by @zhengziyi0117 in https://github.com/apache/skywalking-cli/pull/203
 * Add the sub-command `alarm autocomplete-keys` and `alarm auto-complete-values` for alarm query API by @mrproliu in https://github.com/apache/skywalking-cli/pull/210
diff --git a/assets/graphqls/aiagent/ConversationRawFiles.graphql b/assets/graphqls/aiagent/ConversationRawFiles.graphql
deleted file mode 100644
index 824e2c8..0000000
--- a/assets/graphqls/aiagent/ConversationRawFiles.graphql
+++ /dev/null
@@ -1,34 +0,0 @@
-# Licensed to 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. Apache Software Foundation (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.
-
-# The body is read from storage only when selected; $body is true on the export path.
-query ($condition: ConversationCondition!, $files: [ID!], $body: Boolean!) {
-    result: getConversationRawFiles(condition: $condition, files: $files) {
-        errorReason
-        files {
-            id
-            format
-            session
-            seq
-            round
-            digest
-            bytes
-            timestamp
-            body @include(if: $body)
-        }
-    }
-}
diff --git a/internal/commands/aiagent/aiagent.go b/internal/commands/aiagent/aiagent.go
index 6eec3df..6ad5c3c 100644
--- a/internal/commands/aiagent/aiagent.go
+++ b/internal/commands/aiagent/aiagent.go
@@ -17,8 +17,8 @@
 
 // Package aiagent holds the commands for the conversations of long-lived AI agents
 // that the AI Sessionizer (apache/skywalking-ai-sessionizer) lands in the OAP under
-// the AI_AGENT layer: the list page, the raw-file export, and the conversation itself
-// as one asz.view document.
+// the AI_AGENT layer: the list page, the conversation itself as one asz.view document,
+// and its stored files.
 package aiagent
 
 import (
@@ -29,9 +29,11 @@
 	Name:  "ai-agent",
 	Usage: "AI agent conversations landed by the AI Sessionizer",
 	UsageText: `The AI Sessionizer collects an agent runtime's transcripts and pushes them to the OAP
-under the AI_AGENT layer. "list" and "files" are GraphQL queries on the "--base-url"
-endpoint; "view" reads the whole conversation as one asz.view document from the OAP's
-streamed route on the same host, GET /ai-agent/conversations/{conversation}/v1/view.`,
+under the AI_AGENT layer. "list" is a GraphQL query on the "--base-url" endpoint. "view"
+and "files" read the OAP's streamed routes on the same host: the whole conversation as one
+asz.view document, GET /ai-agent/conversations/{conversation}/v1/view, and its stored files
+by name, GET /ai-agent/conversations/{conversation}/v1/files. Both need the service and the
+sender's instance, as "list" names them.`,
 	Subcommands: []*cli.Command{
 		listCommand,
 		filesCommand,
diff --git a/internal/commands/aiagent/files.go b/internal/commands/aiagent/files.go
index f3cb293..b0ac9b0 100644
--- a/internal/commands/aiagent/files.go
+++ b/internal/commands/aiagent/files.go
@@ -21,37 +21,35 @@
 	"fmt"
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 
-	api "skywalking.apache.org/repo/goapi/query"
-
 	"github.com/urfave/cli/v2"
 
 	"github.com/apache/skywalking-cli/internal/commands/interceptor"
 	"github.com/apache/skywalking-cli/internal/flags"
+	"github.com/apache/skywalking-cli/pkg/aiagent/files"
 	"github.com/apache/skywalking-cli/pkg/display"
 	"github.com/apache/skywalking-cli/pkg/display/displayable"
-	"github.com/apache/skywalking-cli/pkg/graphql/aiagent"
 )
 
 var filesCommand = &cli.Command{
 	Name:  "files",
-	Usage: "List or export the raw files of a conversation, as the OAP stores them",
-	UsageText: `List every landed file and round of a conversation with its digest and size, or
-export them: "--export DIR" reads each body and writes it to its id path under DIR,
-which gives a storage root that "asz verify" and "asz view" read like the original.
+	Usage: "List or export chosen stored files of a conversation, as the OAP stores them",
+	UsageText: `Read chosen Session Data files of a conversation's session from the OAP's route
+GET /ai-agent/conversations/{conversation}/v1/files, on the "--base-url" host, and list each
+with its digest and size, or export them: "--export DIR" writes each file to its name under DIR.
+A file is chosen by "--session" and "--seqs", its landed seq; the asz.view document's files
+list gives both. Each file's bytes are checked against the digest the OAP names.
 
 Examples:
-1. The files of a conversation:
-$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2
+1. Two files of a session, listed:
+$ swctl ai-agent files --service-name "Claude Code" --instance-name laptop --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 \
+    --session 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --seqs 408,409
 
-2. Export them all:
-$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --export ./root
-
-3. Export two named files:
-$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 \
-    --files 7a3c882e-0dc0-46a0-b814-6613d24b7ac2/streams/main/transcript-20260904T152815.774957000Z-000408.sd \
-    --export ./root`,
+2. The same files, exported:
+$ swctl ai-agent files --service-name "Claude Code" --instance-name laptop --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 \
+    --session 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --seqs 408,409 --export ./root`,
 	Flags: flags.Flags(
 		flags.ServiceFlags,
 		flags.InstanceFlags,
@@ -62,80 +60,112 @@
 				Required: true,
 			},
 			&cli.StringFlag{
-				Name:  "files",
-				Usage: "only these file `ids`, comma separated; without it, every file of the conversation",
+				Name:     "session",
+				Usage:    "the `session` the files belong to",
+				Required: true,
+			},
+			&cli.StringFlag{
+				Name:     "seqs",
+				Usage:    "the landed `seqs` of the files, comma separated",
+				Required: true,
 			},
 			&cli.StringFlag{
 				Name:  "export",
-				Usage: "write each file's body to its id path under this `directory`",
+				Usage: "write each file to its name under this `directory`",
 			},
 		},
 	),
 	Before: interceptor.BeforeChain(
 		interceptor.ParseService(true),
-		interceptor.ParseInstance(false),
+		interceptor.ParseInstance(true),
 	),
 	Action: func(ctx *cli.Context) error {
-		condition := &api.ConversationCondition{
-			Service:      &api.ServiceCondition{ServiceName: ctx.String("service-name")},
-			Conversation: ctx.String("conversation"),
-			Instance:     instanceCondition(ctx),
-		}
-		var files []string
-		if arg := strings.TrimSpace(ctx.String("files")); arg != "" {
-			files = strings.Split(arg, ",")
-		}
-		exportDir := ctx.String("export")
-
-		raw, err := aiagent.RawFiles(ctx.Context, condition, files, exportDir != "")
+		seqs, err := numbers(ctx.String("seqs"))
 		if err != nil {
 			return err
 		}
-		if raw.ErrorReason != nil && *raw.ErrorReason != "" {
-			return fmt.Errorf("%s", *raw.ErrorReason)
+		if len(seqs) == 0 {
+			return fmt.Errorf("--seqs needs at least one number")
 		}
-		if exportDir == "" {
-			return display.Display(ctx.Context, &displayable.Displayable{Data: raw, Condition: condition})
+		var root *os.Root
+		if exportDir := ctx.String("export"); exportDir != "" {
+			if mkErr := os.MkdirAll(exportDir, 0o755); mkErr != nil {
+				return mkErr
+			}
+			if root, err = os.OpenRoot(exportDir); err != nil {
+				return err
+			}
+			defer root.Close()
 		}
 
-		written, err := export(exportDir, raw.Files)
+		out := List{Files: []files.File{}}
+		var written []Exported
+		err = files.Read(ctx.Context, ctx.String("conversation"), ctx.String("service-name"), ctx.String("instance-name"),
+			ctx.String("session"), seqs, func(f files.File, content []byte) error {
+				if root == nil {
+					out.Files = append(out.Files, f)
+					return nil
+				}
+				path, exportErr := export(root, f.ID, content)
+				if exportErr != nil {
+					return exportErr
+				}
+				written = append(written, Exported{ID: f.ID, Path: path, Bytes: len(content)})
+				return nil
+			})
 		if err != nil {
 			return err
 		}
-		return display.Display(ctx.Context, &displayable.Displayable{Data: written, Condition: condition})
+		if root == nil {
+			return display.Display(ctx.Context, &displayable.Displayable{Data: out})
+		}
+		return display.Display(ctx.Context, &displayable.Displayable{Data: written})
 	},
 }
 
-// Exported is one file written by "--export": its id path and size, the body left out.
+// List is what "files" prints without "--export": each stored file's naming line.
+type List struct {
+	Files []files.File `json:"files"`
+}
+
+// Exported is one file written by "--export": its name, its path and its size.
 type Exported struct {
-	ID    string `json:"id"`
+	ID    string `json:"file"`
 	Path  string `json:"path"`
 	Bytes int    `json:"bytes"`
 }
 
-// export writes each body to its id path under dir. An id is a relative path inside the
-// Sessionizer's storage root; one that would leave dir is refused.
-func export(dir string, files []*api.ConversationRawFile) ([]Exported, error) {
-	root, err := filepath.Abs(dir)
-	if err != nil {
-		return nil, err
-	}
-	out := make([]Exported, 0, len(files))
-	for _, f := range files {
-		if f.Body == nil {
-			return nil, fmt.Errorf("the OAP returned no body for %s", f.ID)
+// numbers reads a comma separated list of positive whole numbers.
+func numbers(arg string) ([]int64, error) {
+	var out []int64
+	for _, part := range strings.Split(arg, ",") {
+		if part = strings.TrimSpace(part); part == "" {
+			continue
 		}
-		path := filepath.Join(root, filepath.FromSlash(f.ID))
-		if !strings.HasPrefix(path, root+string(filepath.Separator)) {
-			return nil, fmt.Errorf("refusing to write %s outside %s", f.ID, root)
+		n, err := strconv.ParseInt(part, 10, 64)
+		if err != nil || n <= 0 {
+			return nil, fmt.Errorf("%q is not a positive whole number", part)
 		}
-		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
-			return nil, err
-		}
-		if err := os.WriteFile(path, []byte(*f.Body), 0o644); err != nil { // #nosec G306 -- a landed file is readable by design
-			return nil, err
-		}
-		out = append(out, Exported{ID: f.ID, Path: path, Bytes: len(*f.Body)})
+		out = append(out, n)
 	}
 	return out, nil
 }
+
+// export writes one file to its name under root. A name is a relative path inside the Sessionizer's
+// storage root. The root refuses a name that would leave it, through ".." or through a symbolic
+// link, so a file the OAP names can only land inside the export directory.
+func export(root *os.Root, name string, content []byte) (string, error) {
+	rel := filepath.FromSlash(name)
+	if !filepath.IsLocal(rel) {
+		return "", fmt.Errorf("refusing to write %s outside %s", name, root.Name())
+	}
+	if dir := filepath.Dir(rel); dir != "." {
+		if err := root.MkdirAll(dir, 0o755); err != nil {
+			return "", err
+		}
+	}
+	if err := root.WriteFile(rel, content, 0o644); err != nil { // #nosec G306 -- a landed file is readable by design
+		return "", err
+	}
+	return filepath.Join(root.Name(), rel), nil
+}
diff --git a/internal/commands/aiagent/files_test.go b/internal/commands/aiagent/files_test.go
new file mode 100644
index 0000000..1ed6964
--- /dev/null
+++ b/internal/commands/aiagent/files_test.go
@@ -0,0 +1,55 @@
+// Licensed to 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. Apache Software Foundation (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 aiagent
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// An export lands inside its directory whatever a file's name says: a name climbing out is refused,
+// and so is one that would reach outside through a symbolic link.
+func TestExportStaysInsideItsDirectory(t *testing.T) {
+	dir := t.TempDir()
+	outside := t.TempDir()
+	if err := os.Symlink(outside, filepath.Join(dir, "linked")); err != nil {
+		t.Skip("no symbolic links here:", err)
+	}
+	root, err := os.OpenRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer root.Close()
+
+	if _, err := export(root, "s/streams/main/transcript-20260101T000000.000000000Z-000001.sd", []byte("x\n")); err != nil {
+		t.Fatal(err)
+	}
+	written := filepath.Join(dir, "s", "streams", "main", "transcript-20260101T000000.000000000Z-000001.sd")
+	if b, err := os.ReadFile(written); err != nil || string(b) != "x\n" {
+		t.Fatalf("written: %q, %v", b, err)
+	}
+	for _, name := range []string{"../escaped.sd", "linked/escaped.sd", "/abs/escaped.sd"} {
+		if _, err := export(root, name, []byte("x\n")); err == nil {
+			t.Errorf("%s: written", name)
+		}
+	}
+	if entries, _ := os.ReadDir(outside); len(entries) != 0 {
+		t.Errorf("the outside directory holds %d entries", len(entries))
+	}
+}
diff --git a/internal/commands/aiagent/view.go b/internal/commands/aiagent/view.go
index 2bbb385..1eaf882 100644
--- a/internal/commands/aiagent/view.go
+++ b/internal/commands/aiagent/view.go
@@ -38,10 +38,11 @@
 
 Examples:
 1. A conversation as JSON, into a file:
-$ swctl ai-agent view --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --output conversation.json
+$ swctl ai-agent view --service-name "Claude Code" --instance-name laptop \
+    --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --output conversation.json
 
 2. As YAML, on the terminal:
-$ swctl ai-agent view --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --yaml`,
+$ swctl ai-agent view --service-name "Claude Code" --instance-name laptop --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --yaml`,
 	Flags: flags.Flags(
 		flags.ServiceFlags,
 		flags.InstanceFlags,
@@ -63,7 +64,7 @@
 	),
 	Before: interceptor.BeforeChain(
 		interceptor.ParseService(true),
-		interceptor.ParseInstance(false),
+		interceptor.ParseInstance(true),
 	),
 	Action: func(ctx *cli.Context) error {
 		var out io.Writer = os.Stdout
diff --git a/pkg/aiagent/files/files.go b/pkg/aiagent/files/files.go
new file mode 100644
index 0000000..c8a0ae2
--- /dev/null
+++ b/pkg/aiagent/files/files.go
@@ -0,0 +1,161 @@
+// Licensed to 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. Apache Software Foundation (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 files reads chosen Session Data files of an AI agent conversation's session from the
+// OAP's GET /ai-agent/conversations/{conversation}/v1/files route, on the query host beside the view
+// route. A file is chosen by its session and its landed seq; there is no read of every file. The
+// body is application/vnd.skywalking.asz.files+ndjson: for each stored file a line naming it, then
+// exactly as many bytes as it says, the file. Files are read as they arrive, never the whole
+// response at once.
+package files
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+
+	"github.com/apache/skywalking-cli/pkg/aiagent/view"
+	"github.com/apache/skywalking-cli/pkg/contextkey"
+	"github.com/apache/skywalking-cli/pkg/transport"
+)
+
+const (
+	// MediaType is the route's one body format.
+	MediaType = "application/vnd.skywalking.asz.files+ndjson"
+	// MaxSeqs is the most seqs one request carries, the route's own limit: the Sessionizer cuts a
+	// file at 2 MiB, so one response holds about 64 MiB at most.
+	MaxSeqs = 32
+
+	defaultBaseURL = "http://127.0.0.1:12800/graphql"
+)
+
+// File is the line naming one stored file.
+type File struct {
+	ID     string `json:"file"`
+	Seq    int64  `json:"seq"`
+	Lines  int    `json:"lines"`
+	Bytes  int    `json:"bytes"`
+	Digest string `json:"digest"`
+}
+
+// Path is the files route of one conversation.
+func Path(conversation string) string {
+	return "/ai-agent/conversations/" + url.PathEscape(conversation) + "/v1/files"
+}
+
+// Read asks for the chosen files of session, at most MaxSeqs a request, and hands each stored file
+// to fn with its bytes, checked against the digest its naming line gives. A seq no stored file
+// answers is left out. serviceName, instanceName, session and at least one seq are required.
+func Read(ctx context.Context, conversation, serviceName, instanceName, session string, seqs []int64,
+	fn func(File, []byte) error) error {
+	switch {
+	case serviceName == "" || instanceName == "":
+		return errors.New("the service and the instance are both required")
+	case session == "":
+		return errors.New("the session is required")
+	case len(seqs) == 0:
+		return errors.New("at least one seq is required")
+	}
+	// one client for every batch, so a long read reuses its connections instead of opening one per batch
+	client := transport.HTTPClient(ctx)
+	defer client.CloseIdleConnections()
+	for start := 0; start < len(seqs); start += MaxSeqs {
+		end := start + MaxSeqs
+		if end > len(seqs) {
+			end = len(seqs)
+		}
+		query := url.Values{"service": {serviceName}, "instance": {instanceName}, "session": {session}}
+		for _, n := range seqs[start:end] {
+			query.Add("seq", strconv.FormatInt(n, 10))
+		}
+		if err := readBatch(ctx, client, conversation, query, fn); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func readBatch(ctx context.Context, client *http.Client, conversation string, query url.Values, fn func(File, []byte) error) error {
+	full := view.CoreURL(transport.GetValue(ctx, contextkey.BaseURL{}, defaultBaseURL)) + Path(conversation) + "?" + query.Encode()
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, http.NoBody)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Accept", MediaType)
+	if authorization := transport.AuthHeader(ctx); authorization != "" {
+		req.Header.Set("Authorization", authorization)
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return view.ReadError(resp, full)
+	}
+	return Parse(resp.Body, fn)
+}
+
+// Parse reads a files body: a naming line, then exactly as many bytes as it says, the file, then
+// the one newline that follows a file not ending with its own. A file can be as large as the
+// largest stored, so it is read by its size, never line by line with a bounded scanner.
+func Parse(body io.Reader, fn func(File, []byte) error) error {
+	r := bufio.NewReaderSize(body, 256*1024)
+	for {
+		line, err := r.ReadBytes('\n')
+		if errors.Is(err, io.EOF) && len(line) == 0 {
+			return nil
+		}
+		if err != nil {
+			return fmt.Errorf("the files response ends inside a naming line: %w", err)
+		}
+		var f File
+		if err := json.Unmarshal(line, &f); err != nil {
+			return fmt.Errorf("not a naming line: %w", err)
+		}
+		if f.Bytes < 0 {
+			return fmt.Errorf("%s: a naming line with %d bytes", f.ID, f.Bytes)
+		}
+		// the buffer grows with the bytes that actually arrive, so a size no response carries never allocates
+		var buf bytes.Buffer
+		if n, err := io.Copy(&buf, io.LimitReader(r, int64(f.Bytes))); err != nil || n != int64(f.Bytes) {
+			return fmt.Errorf("%s ends after %d of its %d bytes: %v", f.ID, n, f.Bytes, err)
+		}
+		content := buf.Bytes()
+		if f.Bytes > 0 && content[f.Bytes-1] != '\n' {
+			if b, err := r.ReadByte(); err != nil || b != '\n' {
+				return fmt.Errorf("%s: no newline after a file that does not end with one", f.ID)
+			}
+		}
+		sum := sha256.Sum256(content)
+		if got := hex.EncodeToString(sum[:]); got != f.Digest {
+			return fmt.Errorf("%s: its bytes hash to %s, the OAP names %s", f.ID, got, f.Digest)
+		}
+		if err := fn(f, content); err != nil {
+			return err
+		}
+	}
+}
diff --git a/pkg/aiagent/files/files_test.go b/pkg/aiagent/files/files_test.go
new file mode 100644
index 0000000..3b00357
--- /dev/null
+++ b/pkg/aiagent/files/files_test.go
@@ -0,0 +1,200 @@
+// Licensed to 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. Apache Software Foundation (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 files
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/apache/skywalking-cli/pkg/aiagent/view"
+	"github.com/apache/skywalking-cli/pkg/contextkey"
+)
+
+type storedFile struct {
+	name    string
+	content []byte
+}
+
+// stored are the Session Data files of session "s": a small transcript; a provider file whose one
+// record line is past any scanner's default buffer; a file without a final newline; and an empty
+// one.
+var stored = map[int64]storedFile{
+	1: {"s/streams/main/transcript-20260101T000000.000000000Z-000001.sd", []byte("{\"h\":1}\n{\"ord\":1}\n{\"t\":\"end\"}\n")},
+	2: {"s/provider_body/provider_body-20260101T000000.000000000Z-000002.sd", []byte(
+		"{\"h\":1}\n{\"ord\":1,\"parts\":[{\"k\":\"data\",\"data\":\"" + strings.Repeat("x", 3<<20) + "\"}]}\n{\"t\":\"end\"}\n")},
+	3: {"s/unknown-000003.sd", []byte("{\"h\":1}\n{\"t\":\"end\"}")},
+	4: {"s/unknown-000004.sd", nil},
+}
+
+func naming(f storedFile, seq int64) string {
+	sum := sha256.Sum256(f.content)
+	return fmt.Sprintf(`{"file":%q,"seq":%d,"lines":%d,"bytes":%d,"digest":%q}`+"\n",
+		f.name, seq, bytes.Count(f.content, []byte("\n")), len(f.content), hex.EncodeToString(sum[:]))
+}
+
+// frame writes one file the way the OAP does: its naming line, its bytes, and a newline after a
+// non-empty file that does not end with one.
+func frame(f storedFile, seq int64) string {
+	out := naming(f, seq) + string(f.content)
+	if len(f.content) > 0 && f.content[len(f.content)-1] != '\n' {
+		out += "\n"
+	}
+	return out
+}
+
+// server answers the files route the way the OAP does. It records how many seqs each request
+// carried.
+func server(t *testing.T, batches *[]int, mu *sync.Mutex) *httptest.Server {
+	t.Helper()
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		q := r.URL.Query()
+		if q.Get("service") != "agent" || q.Get("instance") != "sender" || q.Get("session") != "s" ||
+			r.Header.Get("Accept") != MediaType {
+			w.WriteHeader(http.StatusTeapot)
+			return
+		}
+		if r.URL.Path != "/ai-agent/conversations/c/v1/files" {
+			w.Header().Set("Content-Type", "application/problem+json")
+			w.WriteHeader(http.StatusNotFound)
+			_, _ = w.Write([]byte(`{"type":"about:blank","title":"Not Found","status":404,"detail":"no round"}`))
+			return
+		}
+		mu.Lock()
+		*batches = append(*batches, len(q["seq"]))
+		mu.Unlock()
+		w.Header().Set("Content-Type", MediaType+"; charset=utf-8")
+		for _, text := range q["seq"] {
+			n, _ := strconv.ParseInt(text, 10, 64)
+			if f, ok := stored[n]; ok {
+				_, _ = w.Write([]byte(frame(f, n)))
+			}
+		}
+	}))
+}
+
+func testContext(serverURL string) context.Context {
+	return context.WithValue(context.Background(), contextkey.BaseURL{}, serverURL+"/graphql")
+}
+
+func TestEveryChosenFileComesBackByteForByte(t *testing.T) {
+	var batches []int
+	var mu sync.Mutex
+	srv := server(t, &batches, &mu)
+	defer srv.Close()
+
+	got := map[string][]byte{}
+	take := func(f File, content []byte) error {
+		got[f.ID] = content
+		return nil
+	}
+	if err := Read(testContext(srv.URL), "c", "agent", "sender", "s", []int64{1, 2, 3, 4, 9}, take); err != nil {
+		t.Fatal(err)
+	}
+	if len(got) != len(stored) {
+		t.Fatalf("%d files, want %d", len(got), len(stored))
+	}
+	for _, f := range stored {
+		if !bytes.Equal(got[f.name], f.content) {
+			t.Errorf("%s: %d bytes, want %d", f.name, len(got[f.name]), len(f.content))
+		}
+	}
+}
+
+func TestSeqsAreAskedForInBatchesUnderTheRouteLimit(t *testing.T) {
+	var batches []int
+	var mu sync.Mutex
+	srv := server(t, &batches, &mu)
+	defer srv.Close()
+
+	many := make([]int64, 0, 2*MaxSeqs+1)
+	for i := int64(0); i < 2*MaxSeqs+1; i++ {
+		many = append(many, 1000+i)
+	}
+	if err := Read(testContext(srv.URL), "c", "agent", "sender", "s", many, func(File, []byte) error { return nil }); err != nil {
+		t.Fatal(err)
+	}
+	if fmt.Sprint(batches) != fmt.Sprint([]int{MaxSeqs, MaxSeqs, 1}) {
+		t.Fatalf("batches %v", batches)
+	}
+}
+
+func TestParseRefusesWhatDoesNotMatchItsNamingLine(t *testing.T) {
+	f := storedFile{"a", []byte("{\"h\":1}\n{\"t\":\"end\"}\n")}
+	unended := storedFile{"b", []byte("{\"h\":1}")}
+	cases := map[string]string{
+		"a wrong digest":             strings.Replace(naming(f, 1), `"digest":"`, `"digest":"00`, 1) + string(f.content),
+		"a short file":               naming(f, 1) + "{\"h\":1}\n",
+		"no naming line":             "not json\n",
+		"a cut naming line":          `{"file":"a"`,
+		"no newline after unended":   naming(unended, 2) + string(unended.content),
+		"a negative size":            `{"file":"a","bytes":-1,"lines":0,"digest":""}` + "\n",
+		"a size no response carries": `{"file":"a","seq":1,"lines":0,"bytes":9223372036854775807,"digest":""}` + "\n",
+		"another byte after unended": naming(unended, 2) + string(unended.content) + "x",
+	}
+	for what, body := range cases {
+		if err := Parse(strings.NewReader(body), func(File, []byte) error { return nil }); err == nil {
+			t.Errorf("%s: no error", what)
+		}
+	}
+	// an empty file is followed by nothing, and the next naming line comes straight after its own
+	empty := storedFile{"e", nil}
+	whole := frame(f, 1) + frame(empty, 3) + frame(unended, 2)
+	var got [][]byte
+	if err := Parse(strings.NewReader(whole), func(_ File, content []byte) error {
+		got = append(got, content)
+		return nil
+	}); err != nil || len(got) != 3 || len(got[1]) != 0 || !bytes.Equal(got[2], unended.content) {
+		t.Errorf("a whole stream: %v, %d files", err, len(got))
+	}
+}
+
+func TestTheSenderTheSessionAndASeqAreRequired(t *testing.T) {
+	noop := func(File, []byte) error { return nil }
+	for what, err := range map[string]error{
+		"no instance": Read(context.Background(), "c", "agent", "", "s", []int64{1}, noop),
+		"no session":  Read(context.Background(), "c", "agent", "sender", "", []int64{1}, noop),
+		"no seq":      Read(context.Background(), "c", "agent", "sender", "s", nil, noop),
+	} {
+		if err == nil {
+			t.Errorf("%s: no error", what)
+		}
+	}
+}
+
+func TestAProblemDocumentIsTheError(t *testing.T) {
+	var batches []int
+	var mu sync.Mutex
+	srv := server(t, &batches, &mu)
+	defer srv.Close()
+
+	var problem *view.Problem
+	err := Read(testContext(srv.URL), "missing", "agent", "sender", "s", []int64{1}, func(File, []byte) error { return nil })
+	if !errors.As(err, &problem) || problem.Status != 404 {
+		t.Fatalf("problem: %v", err)
+	}
+}
diff --git a/pkg/aiagent/view/view.go b/pkg/aiagent/view/view.go
index 94546c6..9042ada 100644
--- a/pkg/aiagent/view/view.go
+++ b/pkg/aiagent/view/view.go
@@ -81,13 +81,14 @@
 }
 
 // Fetch streams the document of the conversation to out and returns the Content-Type
-// it came with. serviceName is required; instanceName narrows the read to one sender.
-// A non-2xx answer is returned as a *Problem when the OAP sent one.
+// it came with. serviceName and instanceName are both required, as a list row names the
+// sender of every conversation. A non-2xx answer is returned as a *Problem when the OAP
+// sent one.
 func Fetch(ctx context.Context, conversation, serviceName, instanceName string, yaml bool, out io.Writer) (string, error) {
-	query := url.Values{"service": {serviceName}}
-	if instanceName != "" {
-		query.Set("instance", instanceName)
+	if serviceName == "" || instanceName == "" {
+		return "", fmt.Errorf("the service and the instance are both required")
 	}
+	query := url.Values{"service": {serviceName}, "instance": {instanceName}}
 	full := CoreURL(transport.GetValue(ctx, contextkey.BaseURL{}, defaultBaseURL)) + Path(conversation) + "?" + query.Encode()
 
 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, http.NoBody)
@@ -111,15 +112,15 @@
 
 	contentType := resp.Header.Get("Content-Type")
 	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
-		return contentType, readError(resp, full)
+		return contentType, ReadError(resp, full)
 	}
 	_, err = io.Copy(out, resp.Body)
 	return contentType, err
 }
 
-// readError turns a non-2xx response into an error: the problem document when the OAP
+// ReadError turns a non-2xx response into an error: the problem document when the OAP
 // sent one, otherwise the status and whatever the body says.
-func readError(resp *http.Response, full string) error {
+func ReadError(resp *http.Response, full string) error {
 	body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
 	mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
 	if mediaType == problemType {
diff --git a/pkg/aiagent/view/view_test.go b/pkg/aiagent/view/view_test.go
index dadf616..fb9089c 100644
--- a/pkg/aiagent/view/view_test.go
+++ b/pkg/aiagent/view/view_test.go
@@ -123,7 +123,7 @@
 	}
 	defer resp.Body.Close()
 	var problem *Problem
-	if err := readError(resp, req.URL.String()); !errors.As(err, &problem) ||
+	if err := ReadError(resp, req.URL.String()); !errors.As(err, &problem) ||
 		problem.Status != 404 || problem.Detail != "no round" || problem.Title != "Not Found" {
 		t.Fatalf("problem: %v", err)
 	}
diff --git a/pkg/graphql/aiagent/conversation.go b/pkg/graphql/aiagent/conversation.go
index fce0246..b9ebd26 100644
--- a/pkg/graphql/aiagent/conversation.go
+++ b/pkg/graphql/aiagent/conversation.go
@@ -15,9 +15,9 @@
 // specific language governing permissions and limitations
 // under the License.
 
-// Package aiagent wraps the GraphQL queries of ai-agent-conversation.graphqls: the
-// list page and the raw-file export of the conversations the AI Sessionizer lands.
-// The conversation document itself is not a GraphQL query; see pkg/aiagent/view.
+// Package aiagent wraps the GraphQL query of ai-agent-conversation.graphqls: the list
+// page of the conversations the AI Sessionizer lands. The conversation document and its
+// files are not GraphQL queries; see pkg/aiagent/view and pkg/aiagent/files.
 package aiagent
 
 import (
@@ -42,17 +42,3 @@
 	err := client.ExecuteQuery(ctx, request, &response)
 	return response["result"], err
 }
-
-// RawFiles lists every landed file and round of a conversation as stored, or only the
-// named ones; with body, each file comes verbatim, which is the export path.
-func RawFiles(ctx context.Context, condition *api.ConversationCondition, files []string, body bool) (api.ConversationRawFiles, error) {
-	var response map[string]api.ConversationRawFiles
-
-	request := graphql.NewRequest(assets.Read("graphqls/aiagent/ConversationRawFiles.graphql"))
-	request.Var("condition", condition)
-	request.Var("files", files)
-	request.Var("body", body)
-
-	err := client.ExecuteQuery(ctx, request, &response)
-	return response["result"], err
-}