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/16 01:08:49 UTC

(skywalking-horizon-ui) branch feat/conversation-prompt-panel created (now 47b59a7c)

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

wu-sheng pushed a change to branch feat/conversation-prompt-panel
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


      at 47b59a7c feat(ai-conversation): show the prompt a model call sent and the answer it got

This branch includes the following new commits:

     new 47b59a7c feat(ai-conversation): show the prompt a model call sent and the answer it got

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(skywalking-horizon-ui) 01/01: feat(ai-conversation): show the prompt a model call sent and the answer it got

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/conversation-prompt-panel
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 47b59a7cceb7a7774b6499b1ec6b9e7cb2c377e6
Author: Wu Sheng <wu...@tetrate.io>
AuthorDate: Wed Sep 16 09:08:27 2026 +0800

    feat(ai-conversation): show the prompt a model call sent and the answer it got
    
    A conversation document says only where each call's provider bodies landed. This
    adds the inspector's Prompt tab, which reads those files from OAP's new files
    route and rebuilds the bodies in the browser.
    
    Nothing is read until the reader asks: the tab opens on an offer naming how many
    files of the session it needs and how many bytes they are. What is read is kept,
    so walking from call to call within a session costs one read, and the tab stays
    chosen as the reader moves.
    
    A request is shown whole — system prompt, tools, messages, settings — or as what
    it added to the request before it, which names the shared history instead of
    repeating it. The predecessor is resolved through the document by id, never by
    position. A response is shown with its stop reason, token counts and blocks.
    
    Every body is checked as it is rebuilt: the byte count and the SHA-256 digest its
    manifest carries, a copy's own digest and length, a piece's presence, the depth
    and the landing order. A part's bytes are taken from the record line exactly as
    it spells them, because a re-encoded part cannot match the digest. A body that
    does not rebuild says so; one of a shape the reader does not know is shown as the
    document it is, and a body that is not JSON as its own text.
    
    The BFF relays the files route under `ai-conversation:read`, validating the
    service, instance, session and up to 32 seqs, and forwards the bytes as OAP sent
    them.
---
 .licenserc.yaml                                    |    4 +
 apps/bff/src/client/ai-conversation.ts             |   55 +
 apps/bff/src/http/query/ai-conversation.test.ts    |   84 ++
 apps/bff/src/http/query/ai-conversation.ts         |  103 +-
 apps/bff/src/rbac/route-policy.ts                  |    1 +
 apps/ui/src/api/scopes/ai-conversation.test.ts     |   70 +-
 apps/ui/src/api/scopes/ai-conversation.ts          |  174 +++
 apps/ui/src/i18n/locales/de.json                   |   41 +-
 apps/ui/src/i18n/locales/en.json                   |   41 +-
 apps/ui/src/i18n/locales/es.json                   |   41 +-
 apps/ui/src/i18n/locales/fr.json                   |   41 +-
 apps/ui/src/i18n/locales/ja.json                   |   41 +-
 apps/ui/src/i18n/locales/ko.json                   |   41 +-
 apps/ui/src/i18n/locales/pt.json                   |   41 +-
 apps/ui/src/i18n/locales/zh-CN.json                |   41 +-
 .../layer/ai-conversation/ConversationPageView.vue |   10 +-
 .../layer/ai-conversation/ConversationViewHost.vue |   22 +
 .../layer/ai-conversation/conversationStrings.ts   |   41 +
 docs/operate/ai-agent-conversations.md             |   17 +-
 packages/api-client/src/ai-conversation.ts         |    4 +
 packages/api-client/src/index.ts                   |    2 +
 packages/conversation-view/src/index.ts            |   24 +-
 packages/conversation-view/src/model.ts            |    4 +
 packages/conversation-view/src/prompt/cache.ts     |  108 ++
 packages/conversation-view/src/prompt/messages.ts  |  284 +++++
 packages/conversation-view/src/prompt/sha256.ts    |   92 ++
 packages/conversation-view/src/prompt/store.ts     |  492 ++++++++
 packages/conversation-view/src/strings.ts          |   83 ++
 packages/conversation-view/src/styles.css          |   22 +
 packages/conversation-view/src/types.ts            |   15 +
 packages/conversation-view/src/view/context.ts     |   17 +-
 packages/conversation-view/src/view/inspector.ts   |   33 +-
 packages/conversation-view/src/view/prompt.ts      |  437 +++++++
 .../test/fixtures/provider-bodies.json             | 1318 ++++++++++++++++++++
 .../test/fixtures/provider-bodies.sd               |   16 +
 .../conversation-view/test/prompt-guards.test.ts   |  116 ++
 .../conversation-view/test/prompt-panel.test.ts    |  172 +++
 .../conversation-view/test/prompt-store.test.ts    |  128 ++
 38 files changed, 4249 insertions(+), 27 deletions(-)

diff --git a/.licenserc.yaml b/.licenserc.yaml
index 4b5350fc..9c423b70 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -58,6 +58,10 @@ header:
     # Generated by `pnpm proto:sync` from the .proto above, and rewritten
     # wholesale on every run — a header would be regenerated away.
     - 'apps/bff/src/client/banyandb/proto.pb.d.ts'
+    # Landed Session Data captured from the AI Sessionizer, kept as a test
+    # fixture. Every line is one record the reader parses and whose bytes it
+    # checks against a digest, so a header comment would make the file invalid.
+    - '**/test/fixtures/*.sd'
     - 'pnpm-lock.yaml'
     - '.browserslistrc'
     - '.prettierrc'
diff --git a/apps/bff/src/client/ai-conversation.ts b/apps/bff/src/client/ai-conversation.ts
index bd52c56b..a82b7b65 100644
--- a/apps/bff/src/client/ai-conversation.ts
+++ b/apps/bff/src/client/ai-conversation.ts
@@ -34,6 +34,7 @@ import http from 'node:http';
 import https from 'node:https';
 import type { Readable } from 'node:stream';
 import {
+  ASZ_FILES_MEDIA_TYPE,
   ASZ_VIEW_JSON_MEDIA_TYPE,
   ASZ_VIEW_YAML_MEDIA_TYPE,
   type AiConversationRow,
@@ -247,6 +248,60 @@ export function openAiConversationView(
   });
 }
 
+export interface AiConversationFilesRequest {
+  conversation: string;
+  serviceName: string;
+  instanceName: string;
+  /** The session the seqs belong to. */
+  session: string;
+  /** The landed seqs of the files, 1 to 32, as the OAP route allows. */
+  seqs: number[];
+  coldStage?: boolean;
+  acceptEncoding?: string;
+}
+
+/** The route's path and query for a session's stored files, as OAP defines them. */
+export function aiConversationFilesPath(q: AiConversationFilesRequest): string {
+  const qs = new URLSearchParams({ service: q.serviceName, instance: q.instanceName, session: q.session });
+  for (const seq of q.seqs) qs.append('seq', String(seq));
+  if (q.coldStage) qs.set('coldStage', 'true');
+  return `/ai-agent/conversations/${encodeURIComponent(q.conversation)}/v1/files?${qs.toString()}`;
+}
+
+/**
+ * The stored files of a session, as OAP streams them. The body is passed to the browser as it
+ * arrives: a file is as large as it was landed, and the page reads one file at a time.
+ */
+export function openAiConversationFiles(
+  opts: AiConversationViewOptions,
+  q: AiConversationFilesRequest,
+): Promise<AiConversationViewUpstream> {
+  const url = new URL(opts.queryUrl.replace(/\/$/, '') + aiConversationFilesPath(q));
+  const lib = url.protocol === 'https:' ? https : http;
+  const headers: Record<string, string> = { accept: ASZ_FILES_MEDIA_TYPE };
+  if (q.acceptEncoding) headers['accept-encoding'] = q.acceptEncoding;
+  if (opts.auth) headers.authorization = basicAuthHeader(opts.auth.username, opts.auth.password);
+
+  return new Promise((resolve, reject) => {
+    if (opts.signal?.aborted) {
+      reject(abortError());
+      return;
+    }
+    const req = lib.request(url, { method: 'GET', headers, timeout: opts.timeoutMs }, (res) => {
+      const flat: Record<string, string> = {};
+      for (const [k, v] of Object.entries(res.headers)) {
+        if (typeof v === 'string') flat[k.toLowerCase()] = v;
+        else if (Array.isArray(v)) flat[k.toLowerCase()] = v.join(', ');
+      }
+      resolve({ status: res.statusCode ?? 502, headers: flat, body: res, abort: () => req.destroy(abortError()) });
+    });
+    req.on('timeout', () => req.destroy(new AiConversationViewTimeout(opts.timeoutMs)));
+    req.on('error', (err) => reject(err));
+    opts.signal?.addEventListener('abort', () => req.destroy(abortError()), { once: true });
+    req.end();
+  });
+}
+
 function abortError(): Error {
   const e = new Error('This operation was aborted');
   e.name = 'AbortError';
diff --git a/apps/bff/src/http/query/ai-conversation.test.ts b/apps/bff/src/http/query/ai-conversation.test.ts
index 15324651..035ce3cf 100644
--- a/apps/bff/src/http/query/ai-conversation.test.ts
+++ b/apps/bff/src/http/query/ai-conversation.test.ts
@@ -18,6 +18,7 @@
 import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
 import type { AddressInfo } from 'node:net';
 import { gzipSync } from 'node:zlib';
+import { PassThrough } from 'node:stream';
 import { afterEach, describe, expect, it } from 'vitest';
 import Fastify, { type FastifyInstance } from 'fastify';
 import cookie from '@fastify/cookie';
@@ -33,6 +34,7 @@ import {
   clampWindowMs,
   passthroughHeaders,
   registerAiConversationRoutes,
+  takesGzip,
   wantsYaml,
 } from './ai-conversation.js';
 
@@ -85,6 +87,20 @@ describe('wantsYaml', () => {
   });
 });
 
+describe('takesGzip', () => {
+  it('asks OAP for gzip only when the client takes it', () => {
+    expect(takesGzip(undefined)).toBe(false);
+    expect(takesGzip('gzip, deflate, br')).toBe(true);
+    expect(takesGzip('GZIP;q=1.0')).toBe(true);
+    expect(takesGzip('deflate, br')).toBe(false);
+    // the body is forwarded as it came, so a client that names gzip to refuse it must get plain bytes
+    expect(takesGzip('gzip;q=0, identity')).toBe(false);
+    expect(takesGzip('*')).toBe(true);
+    expect(takesGzip('*;q=0')).toBe(false);
+    expect(takesGzip('gzip;q=0, *')).toBe(false);
+  });
+});
+
 describe('passthroughHeaders', () => {
   it('keeps what names the bytes and drops the upstream hop', () => {
     expect(
@@ -380,3 +396,71 @@ describe('GET /api/ai-conversation/:conversation/view', () => {
     expect(res.json()).toMatchObject({ error: 'oap_unreachable' });
   });
 });
+
+describe('GET /api/ai-conversation/:conversation/files', () => {
+  const path =
+    '/api/ai-conversation/c/files?service=agent&instance=me%40host&session=s&seq=1&seq=2';
+
+  it('refuses what the OAP route would refuse, before any call', async () => {
+    const { app, cookie: c } = await build(fakeOap().fetch);
+    const cases: Array<[string, string]> = [
+      ['/api/ai-conversation/c/files?session=s&seq=1', 'service_and_instance_required'],
+      ['/api/ai-conversation/c/files?service=agent&session=s&seq=1', 'service_and_instance_required'],
+      ['/api/ai-conversation/c/files?service=agent&instance=i&seq=1', 'session_required'],
+      ['/api/ai-conversation/c/files?service=agent&instance=i&session=s', 'seq_required'],
+      ['/api/ai-conversation/c/files?service=agent&instance=i&session=s&seq=0', 'seq_invalid'],
+      ['/api/ai-conversation/c/files?service=agent&instance=i&session=s&seq=x', 'seq_invalid'],
+    ];
+    for (const [url, error] of cases) {
+      const res = await app.inject({ method: 'GET', url, headers: { cookie: c } });
+      expect(res.statusCode, url).toBe(400);
+      expect((res.json() as { error: string }).error, url).toBe(error);
+    }
+    const many = Array.from({ length: 33 }, (_, i) => `seq=${i + 1}`).join('&');
+    const over = await app.inject({
+      method: 'GET',
+      url: `/api/ai-conversation/c/files?service=agent&instance=i&session=s&${many}`,
+      headers: { cookie: c },
+    });
+    expect(over.statusCode).toBe(400);
+    expect((over.json() as { error: string }).error).toBe('too_many_seqs');
+  });
+
+  it('asks OAP for those seqs and relays the stream as it came', async () => {
+    let asked = '';
+    const upstream = new PassThrough();
+    const server = createServer((req, res) => {
+      asked = req.url ?? '';
+      res.writeHead(200, { 'content-type': 'application/vnd.skywalking.asz.files+ndjson' });
+      res.end('{"file":"s/provider_body/x.sd","seq":1,"lines":1,"bytes":3,"digest":"d"}\nab\n');
+    });
+    await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
+    const port = (server.address() as AddressInfo).port;
+    const { app, cookie: c } = await build(fakeOap().fetch, { oap: { queryUrl: `http://127.0.0.1:${port}` } });
+    const res = await app.inject({ method: 'GET', url: path, headers: { cookie: c } });
+    expect(res.statusCode).toBe(200);
+    expect(res.headers['content-type']).toBe('application/vnd.skywalking.asz.files+ndjson');
+    expect(res.body).toBe('{"file":"s/provider_body/x.sd","seq":1,"lines":1,"bytes":3,"digest":"d"}\nab\n');
+    expect(asked).toContain('/ai-agent/conversations/c/v1/files');
+    expect(asked).toContain('session=s');
+    expect(asked).toContain('seq=1&seq=2');
+    expect(asked).toContain('instance=me%40host');
+    upstream.destroy();
+    server.close();
+  });
+
+  it('passes a problem document through with its status', async () => {
+    const server = createServer((_req, res) => {
+      res.writeHead(404, { 'content-type': 'application/problem+json' });
+      res.end('{"type":"about:blank","title":"Not Found","status":404,"detail":"no round"}');
+    });
+    await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
+    const port = (server.address() as AddressInfo).port;
+    const { app, cookie: c } = await build(fakeOap().fetch, { oap: { queryUrl: `http://127.0.0.1:${port}` } });
+    const res = await app.inject({ method: 'GET', url: path, headers: { cookie: c } });
+    expect(res.statusCode).toBe(404);
+    expect(res.headers['content-type']).toBe('application/problem+json');
+    expect((res.json() as { detail: string }).detail).toBe('no round');
+    server.close();
+  });
+});
diff --git a/apps/bff/src/http/query/ai-conversation.ts b/apps/bff/src/http/query/ai-conversation.ts
index 807bf5f5..88fe85fa 100644
--- a/apps/bff/src/http/query/ai-conversation.ts
+++ b/apps/bff/src/http/query/ai-conversation.ts
@@ -55,12 +55,14 @@ import { requireAuth } from '../../user/middleware.js';
 import {
   AI_CONVERSATION_DOCUMENT_BYTES_HEADER,
   AI_CONVERSATION_DOCUMENT_SUMMARY_HEADER,
+  ASZ_FILES_MAX_SEQS,
 } from '@skywalking-horizon-ui/api-client';
 import { buildOapOpts } from '../../client/graphql.js';
 import { holdDocument } from '../../logic/ai-conversation/hold-document.js';
 import {
   AiConversationViewTimeout,
   listAiConversations,
+  openAiConversationFiles,
   openAiConversationView,
   type AiConversationViewUpstream,
 } from '../../client/ai-conversation.js';
@@ -117,6 +119,29 @@ export function wantsYaml(accept: string | string[] | undefined): boolean {
   return v.toLowerCase().includes('yaml');
 }
 
+/**
+ * Whether the client takes gzip. The body is forwarded exactly as OAP sends it, so a client that
+ * refuses gzip must not be asked for it upstream. `Accept-Encoding: gzip;q=0` names gzip and refuses
+ * it, and `*` offers everything unless gzip is refused by name, which RFC 9110 spells out.
+ */
+export function takesGzip(accept: string | string[] | undefined): boolean {
+  const v = Array.isArray(accept) ? accept.join(',') : accept ?? '';
+  let any = false;
+  for (const part of v.split(',')) {
+    const [name, ...rest] = part.trim().split(';');
+    const coding = (name ?? '').trim().toLowerCase();
+    if (coding !== 'gzip' && coding !== 'x-gzip' && coding !== '*') continue;
+    const q = rest.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith('q='));
+    const refused = q ? Number(q.slice(2)) === 0 : false;
+    if (coding === '*') {
+      if (!refused) any = true;
+      continue;
+    }
+    return !refused;
+  }
+  return any;
+}
+
 /** The upstream headers the browser must see. `Content-Encoding` is the one
  *  that matters — the body is forwarded compressed — and `Content-Type` names
  *  the document and its version. Everything else about the upstream hop
@@ -204,8 +229,7 @@ export function registerAiConversationRoutes(app: FastifyInstance, deps: AiConve
       // Only what the BFF can decode is asked of OAP: gzip when the browser
       // takes gzip, plain bytes otherwise. Forwarding the browser's list could
       // bring back brotli or zstd, which would have to be relayed uncounted.
-      const acceptEncoding = req.headers['accept-encoding'];
-      const browserTakesGzip = typeof acceptEncoding === 'string' && /\bgzip\b/i.test(acceptEncoding);
+      const browserTakesGzip = takesGzip(req.headers['accept-encoding']);
       const yaml = wantsYaml(req.headers.accept);
       let upstream: AiConversationViewUpstream;
       try {
@@ -267,4 +291,79 @@ export function registerAiConversationRoutes(app: FastifyInstance, deps: AiConve
       return reply.send(held.body);
     },
   );
+
+  // The stored files of one session, by their landed seqs: what the page reads when someone opens a
+  // call's prompt. The bodies are as large as they were landed, so the stream is relayed as it
+  // arrives rather than held, and the browser's own gzip is asked of OAP and passed through.
+  app.get(
+    '/api/ai-conversation/:conversation/files',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const { conversation } = req.params as { conversation: string };
+      const q = req.query as { service?: string; instance?: string; session?: string; seq?: string | string[] };
+      const serviceName = typeof q.service === 'string' ? q.service.trim() : '';
+      const instanceName = typeof q.instance === 'string' ? q.instance.trim() : '';
+      const session = typeof q.session === 'string' ? q.session.trim() : '';
+      if (!conversation || !serviceName || !instanceName) {
+        return reply.code(400).send({ error: 'service_and_instance_required' });
+      }
+      if (!session) {
+        return reply.code(400).send({ error: 'session_required' });
+      }
+      const raw = q.seq === undefined ? [] : Array.isArray(q.seq) ? q.seq : [q.seq];
+      const seqs: number[] = [];
+      for (const text of raw) {
+        const n = Number(text);
+        if (!Number.isSafeInteger(n) || n <= 0) {
+          return reply.code(400).send({ error: 'seq_invalid', message: `${text} is not a positive whole number` });
+        }
+        seqs.push(n);
+      }
+      if (!seqs.length) {
+        return reply.code(400).send({ error: 'seq_required' });
+      }
+      if (seqs.length > ASZ_FILES_MAX_SEQS) {
+        return reply.code(400).send({ error: 'too_many_seqs', message: `at most ${ASZ_FILES_MAX_SEQS} seqs a request` });
+      }
+      const cfg = deps.config.current;
+      const browserTakesGzip = takesGzip(req.headers['accept-encoding']);
+      let upstream: AiConversationViewUpstream;
+      try {
+        upstream = await openAiConversationFiles(
+          {
+            queryUrl: cfg.oap.queryUrl,
+            auth: cfg.oap.auth,
+            timeoutMs: cfg.performance.aiConversation.viewTimeoutMs,
+            signal: clientGone(reply),
+          },
+          {
+            conversation,
+            serviceName,
+            instanceName,
+            session,
+            seqs,
+            coldStage: !!req.coldStage,
+            acceptEncoding: browserTakesGzip ? 'gzip' : 'identity',
+          },
+        );
+      } catch (err) {
+        if (err instanceof AiConversationViewTimeout) {
+          return reply.code(504).send({ error: 'oap_timeout', message: err.message });
+        }
+        if (err instanceof Error && err.name === 'AbortError') {
+          return reply.code(499).send();
+        }
+        return reply
+          .code(502)
+          .send({ error: 'oap_unreachable', message: err instanceof Error ? err.message : String(err) });
+      }
+      // Both a problem document and the files themselves go as they came; the browser reads the
+      // framing, and the BFF adds nothing to it.
+      reply.code(upstream.status);
+      for (const [name, value] of Object.entries(passthroughHeaders(upstream.headers))) {
+        reply.header(name, value);
+      }
+      return reply.send(upstream.body);
+    },
+  );
 }
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index adb042c6..7d7d8c20 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -178,6 +178,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
 
   'POST /api/layer/:key/ai-conversations':         'ai-conversation:read',
   'GET /api/ai-conversation/:conversation/view':   'ai-conversation:read',
+  'GET /api/ai-conversation/:conversation/files':  'ai-conversation:read',
 
   'POST /api/explore/query':                       'inspect:read',
 
diff --git a/apps/ui/src/api/scopes/ai-conversation.test.ts b/apps/ui/src/api/scopes/ai-conversation.test.ts
index 3570e68e..0c39c20a 100644
--- a/apps/ui/src/api/scopes/ai-conversation.test.ts
+++ b/apps/ui/src/api/scopes/ai-conversation.test.ts
@@ -19,7 +19,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
 // The scope imports `withBase` from the client, and the client constructs every
 // scope at load: the client has to be the module that starts the cycle.
 import '../client';
-import { AiConversationApi, AiConversationViewError } from './ai-conversation';
+import { AiConversationApi, AiConversationViewError, readFiles } from './ai-conversation';
 import type { BffClient } from '../client';
 
 const coldStage = vi.hoisted(() => ({ enabled: false }));
@@ -163,3 +163,71 @@ describe('bff.aiConversation.view', () => {
     expect(err.kind).toBe('unsupported');
   });
 });
+
+describe('readFiles', () => {
+  const frame = (file: string, seq: number, bytes: string): string => {
+    const size = new TextEncoder().encode(bytes).length;
+    const naming = JSON.stringify({ file, seq, lines: (bytes.match(/\n/g) ?? []).length, bytes: size, digest: 'd' });
+    return `${naming}\n${bytes}${size > 0 && !bytes.endsWith('\n') ? '\n' : ''}`;
+  };
+  /** The body in chunks of `at` bytes, so a file is split across reads wherever the caller says. */
+  const stream = (text: string, at: number): ReadableStream<Uint8Array> => {
+    const bytes = new TextEncoder().encode(text);
+    let pos = 0;
+    return new ReadableStream({
+      pull(c) {
+        if (pos >= bytes.length) {
+          c.close();
+          return;
+        }
+        c.enqueue(bytes.slice(pos, pos + at));
+        pos += at;
+      },
+    });
+  };
+  const read = async (text: string, at: number): Promise<Array<{ seq: number; file: string; text: string }>> => {
+    const out: Array<{ seq: number; file: string; text: string }> = [];
+    await readFiles(stream(text, at), (f) => out.push({ seq: f.seq, file: f.file, text: new TextDecoder().decode(f.bytes) }));
+    return out;
+  };
+
+  it('reads each file whole, at any chunk boundary', async () => {
+    // a file ending with its own newline, one that does not, one that is empty, and a 4 KB one
+    const body =
+      frame('a.sd', 1, '{"h":1}\n{"t":"end"}\n') + frame('b.sd', 2, '{"h":1}') + frame('c.sd', 3, '') + frame('d.sd', 4, `${'x'.repeat(4096)}\n`);
+    for (const at of [1, 3, 64, 4096, 1 << 20]) {
+      const files = await read(body, at);
+      expect(files.map((f) => f.seq), `chunks of ${at}`).toEqual([1, 2, 3, 4]);
+      expect(files[0]!.text).toBe('{"h":1}\n{"t":"end"}\n');
+      expect(files[1]!.text).toBe('{"h":1}');
+      expect(files[2]!.text).toBe('');
+      expect(files[3]!.text).toBe(`${'x'.repeat(4096)}\n`);
+    }
+  });
+
+  it('answers with nothing when nothing was stored', async () => {
+    expect(await read('', 16)).toEqual([]);
+  });
+
+  it('refuses a body that ends inside a file, or is not this framing', async () => {
+    await expect(read(frame('a.sd', 1, '{"h":1}\n').slice(0, -3), 8)).rejects.toThrow();
+    await expect(read('{"file":"a.sd"}\n', 8)).rejects.toThrow();
+    await expect(read('not a naming line\n', 8)).rejects.toThrow();
+  });
+
+  it('refuses a file whose count is not followed by the newline that closes it', async () => {
+    // the byte after a file that does not end with its own newline says the count was the file's. A
+    // different byte there means every file after it would be read at the wrong offset.
+    const wrong = `${JSON.stringify({ file: 'a.sd', seq: 1, lines: 1, bytes: 7, digest: 'd' })}\n{"h":1}X${frame('b.sd', 2, 'later\n')}`;
+    await expect(read(wrong, 8)).rejects.toThrow();
+  });
+
+  it('reads a file of megabytes without copying it again for every chunk', async () => {
+    const big = `${'y'.repeat(2 * 1024 * 1024)}\n`;
+    const started = performance.now();
+    const files = await read(frame('big.sd', 9, big), 16 * 1024);
+    expect(files[0]!.text.length).toBe(big.length);
+    // joining each chunk onto the whole would be a hundredfold this on the same bytes
+    expect(performance.now() - started).toBeLessThan(2000);
+  });
+});
diff --git a/apps/ui/src/api/scopes/ai-conversation.ts b/apps/ui/src/api/scopes/ai-conversation.ts
index 0026495b..056baae9 100644
--- a/apps/ui/src/api/scopes/ai-conversation.ts
+++ b/apps/ui/src/api/scopes/ai-conversation.ts
@@ -18,6 +18,7 @@
 import {
   AI_CONVERSATION_DOCUMENT_BYTES_HEADER,
   AI_CONVERSATION_DOCUMENT_SUMMARY_HEADER,
+  ASZ_FILES_MEDIA_TYPE,
   ASZ_VIEW_FORMAT,
   ASZ_VIEW_JSON_MEDIA_TYPE,
   ASZ_VIEW_MAJOR_VERSION,
@@ -113,6 +114,53 @@ export class AiConversationApi {
     return `/api/ai-conversation/${encodeURIComponent(conversation)}/view?${params}`;
   }
 
+  /** The BFF path of a session's stored files, by their landed seqs. */
+  filesPath(conversation: string, q: { service: string; instance: string; session: string; seqs: number[] }): string {
+    const params = new URLSearchParams({ service: q.service, instance: q.instance, session: q.session });
+    for (const seq of q.seqs) params.append('seq', String(seq));
+    return `/api/ai-conversation/${encodeURIComponent(conversation)}/files?${params}`;
+  }
+
+  /**
+   * The stored files of a session, handed over one at a time as they arrive.
+   *
+   * The body is `application/vnd.skywalking.asz.files+ndjson`: for each file a naming line, then
+   * exactly the bytes it names, then one newline after a non-empty file that does not end with one.
+   * It is read as bytes, never as text, because a file's bytes are what its digest covers.
+   */
+  async files(
+    conversation: string,
+    q: { service: string; instance: string; session: string; seqs: number[] },
+    onFile: (file: { seq: number; file: string; bytes: Uint8Array }) => void,
+    opts: { signal?: AbortSignal } = {},
+  ): Promise<void> {
+    const path = this.filesPath(conversation, q);
+    let res: Response;
+    try {
+      res = await fetch(withBase(path), {
+        credentials: 'include',
+        headers: {
+          accept: ASZ_FILES_MEDIA_TYPE,
+          ...(readColdStageHeader() ? { [COLD_STAGE_HEADER]: '1' } : {}),
+        },
+        ...(opts.signal ? { signal: opts.signal } : {}),
+      });
+    } catch (err) {
+      if (err instanceof Error && err.name === 'AbortError') throw err;
+      const detail = err instanceof Error ? err.message : String(err);
+      pushEvent('api', 'err', `GET ${path} · network ${detail}`);
+      throw new AiConversationViewError('network', 0, `Cannot reach the server — the BFF is unreachable (${detail}).`);
+    }
+    if (res.status === 401) {
+      this.bff.handleUnauthorized();
+      throw new AiConversationViewError('unauthenticated', 401, 'The session has ended.');
+    }
+    if (!res.ok || !res.body) {
+      throw await failureOf(res, path);
+    }
+    await readFiles(res.body, onFile);
+  }
+
   async view(
     conversation: string,
     q: { service: string; instance?: string },
@@ -242,3 +290,129 @@ async function failureOf(res: Response, path: string): Promise<AiConversationVie
   pushEvent('api', 'err', `GET ${path} · ${res.status} ${title}`);
   return new AiConversationViewError(kind, res.status, title, detail);
 }
+
+/**
+ * Reads a files body: for each stored file a naming line, then exactly `bytes` bytes, then the one
+ * newline that follows a non-empty file not ending with its own. The reader holds only what it has
+ * not handed over yet, so a stream of many files never sits in memory whole.
+ */
+export async function readFiles(
+  body: ReadableStream<Uint8Array>,
+  onFile: (file: { seq: number; file: string; bytes: Uint8Array }) => void,
+): Promise<void> {
+  const reader = body.getReader();
+  const held = new Held();
+  let naming: { file: string; seq: number; bytes: number } | null = null;
+  const bad = (why: string): AiConversationViewError => new AiConversationViewError('unsupported', 200, why);
+  for (;;) {
+    const { value, done } = await reader.read();
+    if (value?.length) held.push(value);
+    for (;;) {
+      if (!naming) {
+        const end = held.indexOf(0x0a);
+        if (end < 0) break;
+        const line = new TextDecoder().decode(held.take(end));
+        held.skip(1);
+        let parsed: { file?: unknown; seq?: unknown; bytes?: unknown };
+        try {
+          parsed = JSON.parse(line) as { file?: unknown; seq?: unknown; bytes?: unknown };
+        } catch {
+          throw bad('The files response is not the framing this reader knows.');
+        }
+        if (typeof parsed.file !== 'string' || !Number.isSafeInteger(parsed.seq) || !Number.isSafeInteger(parsed.bytes) || (parsed.bytes as number) < 0) {
+          throw bad('The files response is not the framing this reader knows.');
+        }
+        naming = { file: parsed.file, seq: parsed.seq as number, bytes: parsed.bytes as number };
+      }
+      if (held.length < naming.bytes) break;
+      // A file that does not end with a newline of its own is followed by one, which is not part of
+      // it. Where that byte is expected it is checked: a different byte there means the count was not
+      // the file's, and everything after it would be read at the wrong offset.
+      const delimited = naming.bytes > 0 && held.at(naming.bytes - 1) !== 0x0a;
+      if (delimited && held.length < naming.bytes + 1) break;
+      const bytes = held.take(naming.bytes);
+      if (delimited) {
+        if (held.at(0) !== 0x0a) throw bad('The files response is not the framing this reader knows.');
+        held.skip(1);
+      }
+      onFile({ seq: naming.seq, file: naming.file, bytes });
+      naming = null;
+    }
+    if (done) {
+      if (naming || held.length) throw bad('The files response ended inside a file.');
+      return;
+    }
+  }
+}
+
+/**
+ * The bytes read and not handed over yet, as the chunks they arrived in.
+ *
+ * Joining every chunk onto one buffer copies what is already held again for each one, which on a file
+ * of a few megabytes arriving in small chunks is hundreds of megabytes of copying. The chunks are kept
+ * as they came and only what a frame needs is copied out, once.
+ */
+class Held {
+  private chunks: Uint8Array[] = [];
+  /** How far into the first chunk has been handed over. */
+  private from = 0;
+  length = 0;
+
+  push(chunk: Uint8Array): void {
+    this.chunks.push(chunk);
+    this.length += chunk.length;
+  }
+
+  /** The byte at that offset of what is not handed over yet, or -1 past the end. */
+  at(offset: number): number {
+    let left = offset + this.from;
+    for (const c of this.chunks) {
+      if (left < c.length) return c[left]!;
+      left -= c.length;
+    }
+    return -1;
+  }
+
+  /** Where the byte first occurs, counted from what is not handed over yet, or -1. */
+  indexOf(byte: number): number {
+    let before = 0;
+    let from = this.from;
+    for (const c of this.chunks) {
+      const found = c.indexOf(byte, from);
+      if (found >= 0) return before + found - from;
+      before += c.length - from;
+      from = 0;
+    }
+    return -1;
+  }
+
+  /** The first `n` bytes, copied out once and dropped from what is held. */
+  take(n: number): Uint8Array {
+    const out = new Uint8Array(n);
+    let written = 0;
+    while (written < n) {
+      const c = this.chunks[0]!;
+      const step = Math.min(n - written, c.length - this.from);
+      out.set(c.subarray(this.from, this.from + step), written);
+      written += step;
+      this.advance(step);
+    }
+    return out;
+  }
+
+  skip(n: number): void {
+    let left = n;
+    while (left > 0) left -= this.advance(Math.min(left, this.chunks[0]!.length - this.from));
+  }
+
+  private advance(step: number): number {
+    this.from += step;
+    this.length -= step;
+    if (this.from >= this.chunks[0]!.length) {
+      this.chunks.shift();
+      this.from = 0;
+    }
+    return step;
+  }
+}
+
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index c7d10fdc..7ae4df08 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -3279,5 +3279,44 @@
   "{count} of the {n} most recent records": "{count} der {n} neuesten Datensätze",
   "share of the newest {n}": "Anteil an den neuesten {n}",
   "No spans yet — the trace may still be landing; looking again…": "Noch keine Spans — der Trace wird womöglich noch geschrieben; es wird erneut gelesen…",
-  "Evaluation catalog unreachable.": "Bewertungskatalog nicht erreichbar."
+  "Evaluation catalog unreachable.": "Bewertungskatalog nicht erreichbar.",
+  "Prompt": "Prompt",
+  "The bodies of this call are not among the files this document lists.": "Die Nachrichtenkörper dieses Aufrufs gehören nicht zu den Dateien, die dieses Dokument auflistet.",
+  "The request this call sent and the response it received are stored.": "Die Anfrage dieses Aufrufs und die Antwort darauf sind gespeichert.",
+  "The request this call sent is stored.": "Die Anfrage dieses Aufrufs ist gespeichert.",
+  "The response this call received is stored.": "Die Antwort auf diesen Aufruf ist gespeichert.",
+  "Reading them loads {files} file(s) of this session, {bytes} bytes as stored.": "Dafür werden {files} Datei(en) dieser Sitzung geladen, {bytes} Bytes wie gespeichert.",
+  "Load the prompt": "Prompt laden",
+  "{loaded} of {total} files": "{loaded} von {total} Dateien",
+  "The files could not be read: {why}": "Die Dateien konnten nicht gelesen werden: {why}",
+  "These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.": "Diese Dateien der Sitzung sind nicht gespeichert, daher lässt sich ein Körper, der sie braucht, nicht wiederherstellen: seq {seqs}.",
+  "Which side of the call to show": "Welche Seite des Aufrufs gezeigt wird",
+  "Request": "Anfrage",
+  "The request of this call was not captured.": "Die Anfrage dieses Aufrufs wurde nicht erfasst.",
+  "The response of this call was not captured.": "Die Antwort dieses Aufrufs wurde nicht erfasst.",
+  "This body did not come back whole.": "Dieser Körper kam nicht vollständig zurück.",
+  "How much of the request to show": "Wie viel von der Anfrage gezeigt wird",
+  "Whole request": "Ganze Anfrage",
+  "The body": "Der Körper",
+  "What it added": "Was hinzukam",
+  "The request before this one is not among the loaded bodies, so there is nothing to compare with.": "Die vorherige Anfrage gehört nicht zu den geladenen Körpern, es gibt also nichts zum Vergleichen.",
+  "System prompt": "System-Prompt",
+  "Tools ({count})": "Werkzeuge ({count})",
+  "Messages ({count})": "Nachrichten ({count})",
+  "{bytes} bytes as sent": "{bytes} Bytes wie gesendet",
+  "Settings": "Einstellungen",
+  "{count} block(s), {bytes} bytes": "{count} Block/Blöcke, {bytes} Bytes",
+  "The {count} message(s) before are as the call before sent them, {bytes} bytes.": "Die {count} vorherigen Nachrichten sind so, wie der vorige Aufruf sie sendete, {bytes} Bytes.",
+  "The history was rewritten, which is what a compaction does.": "Der Verlauf wurde neu geschrieben, wie es eine Verdichtung tut.",
+  "Also changed: {what}.": "Ebenfalls geändert: {what}.",
+  "Added by this call ({count})": "Von diesem Aufruf hinzugefügt ({count})",
+  "This request adds no message.": "Diese Anfrage fügt keine Nachricht hinzu.",
+  "stopped: {reason}": "beendet: {reason}",
+  "Thinking": "Denken",
+  "Injected by the runtime": "Von der Laufzeit eingefügt",
+  "Tool use: {name}": "Werkzeugaufruf: {name}",
+  "Tool result": "Werkzeugergebnis",
+  "message": "Nachricht",
+  "Tool result, which failed": "Werkzeugergebnis, das fehlgeschlagen ist",
+  "for {id}": "für {id}"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 7fdd106c..a5d8f488 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -3279,5 +3279,44 @@
   "{count} of the {n} most recent records": "{count} of the {n} most recent records",
   "share of the newest {n}": "share of the newest {n}",
   "No spans yet — the trace may still be landing; looking again…": "No spans yet — the trace may still be landing; looking again…",
-  "Evaluation catalog unreachable.": "Evaluation catalog unreachable."
+  "Evaluation catalog unreachable.": "Evaluation catalog unreachable.",
+  "Prompt": "Prompt",
+  "The bodies of this call are not among the files this document lists.": "The bodies of this call are not among the files this document lists.",
+  "The request this call sent and the response it received are stored.": "The request this call sent and the response it received are stored.",
+  "The request this call sent is stored.": "The request this call sent is stored.",
+  "The response this call received is stored.": "The response this call received is stored.",
+  "Reading them loads {files} file(s) of this session, {bytes} bytes as stored.": "Reading them loads {files} file(s) of this session, {bytes} bytes as stored.",
+  "Load the prompt": "Load the prompt",
+  "{loaded} of {total} files": "{loaded} of {total} files",
+  "The files could not be read: {why}": "The files could not be read: {why}",
+  "These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.": "These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.",
+  "Which side of the call to show": "Which side of the call to show",
+  "Request": "Request",
+  "The request of this call was not captured.": "The request of this call was not captured.",
+  "The response of this call was not captured.": "The response of this call was not captured.",
+  "This body did not come back whole.": "This body did not come back whole.",
+  "How much of the request to show": "How much of the request to show",
+  "Whole request": "Whole request",
+  "The body": "The body",
+  "What it added": "What it added",
+  "The request before this one is not among the loaded bodies, so there is nothing to compare with.": "The request before this one is not among the loaded bodies, so there is nothing to compare with.",
+  "System prompt": "System prompt",
+  "Tools ({count})": "Tools ({count})",
+  "Messages ({count})": "Messages ({count})",
+  "{bytes} bytes as sent": "{bytes} bytes as sent",
+  "Settings": "Settings",
+  "{count} block(s), {bytes} bytes": "{count} block(s), {bytes} bytes",
+  "The {count} message(s) before are as the call before sent them, {bytes} bytes.": "The {count} message(s) before are as the call before sent them, {bytes} bytes.",
+  "The history was rewritten, which is what a compaction does.": "The history was rewritten, which is what a compaction does.",
+  "Also changed: {what}.": "Also changed: {what}.",
+  "Added by this call ({count})": "Added by this call ({count})",
+  "This request adds no message.": "This request adds no message.",
+  "stopped: {reason}": "stopped: {reason}",
+  "Thinking": "Thinking",
+  "Injected by the runtime": "Injected by the runtime",
+  "Tool use: {name}": "Tool use: {name}",
+  "Tool result": "Tool result",
+  "message": "message",
+  "Tool result, which failed": "Tool result, which failed",
+  "for {id}": "for {id}"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 3d879e80..e073d8c6 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -3279,5 +3279,44 @@
   "{count} of the {n} most recent records": "{count} de los {n} registros más recientes",
   "share of the newest {n}": "proporción de los {n} más recientes",
   "No spans yet — the trace may still be landing; looking again…": "Aún no hay spans — puede que la traza todavía se esté guardando; buscando de nuevo…",
-  "Evaluation catalog unreachable.": "Catálogo de evaluación inaccesible."
+  "Evaluation catalog unreachable.": "Catálogo de evaluación inaccesible.",
+  "Prompt": "Prompt",
+  "The bodies of this call are not among the files this document lists.": "Los cuerpos de esta llamada no están entre los archivos que lista este documento.",
+  "The request this call sent and the response it received are stored.": "La solicitud que envió esta llamada y la respuesta que recibió están almacenadas.",
+  "The request this call sent is stored.": "La solicitud que envió esta llamada está almacenada.",
+  "The response this call received is stored.": "La respuesta que recibió esta llamada está almacenada.",
+  "Reading them loads {files} file(s) of this session, {bytes} bytes as stored.": "Leerlos carga {files} archivo(s) de esta sesión, {bytes} bytes tal como se almacenaron.",
+  "Load the prompt": "Cargar el prompt",
+  "{loaded} of {total} files": "{loaded} de {total} archivos",
+  "The files could not be read: {why}": "No se pudieron leer los archivos: {why}",
+  "These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.": "Estos archivos de la sesión no están almacenados, así que un cuerpo que los necesite no puede reconstruirse: seq {seqs}.",
+  "Which side of the call to show": "Qué lado de la llamada mostrar",
+  "Request": "Solicitud",
+  "The request of this call was not captured.": "La solicitud de esta llamada no se capturó.",
+  "The response of this call was not captured.": "La respuesta de esta llamada no se capturó.",
+  "This body did not come back whole.": "Este cuerpo no se reconstruyó completo.",
+  "How much of the request to show": "Cuánto de la solicitud mostrar",
+  "Whole request": "Solicitud completa",
+  "The body": "El cuerpo",
+  "What it added": "Lo que añadió",
+  "The request before this one is not among the loaded bodies, so there is nothing to compare with.": "La solicitud anterior no está entre los cuerpos cargados, así que no hay con qué comparar.",
+  "System prompt": "Prompt del sistema",
+  "Tools ({count})": "Herramientas ({count})",
+  "Messages ({count})": "Mensajes ({count})",
+  "{bytes} bytes as sent": "{bytes} bytes tal como se enviaron",
+  "Settings": "Ajustes",
+  "{count} block(s), {bytes} bytes": "{count} bloque(s), {bytes} bytes",
+  "The {count} message(s) before are as the call before sent them, {bytes} bytes.": "Los {count} mensajes anteriores son como los envió la llamada anterior, {bytes} bytes.",
+  "The history was rewritten, which is what a compaction does.": "El historial se reescribió, que es lo que hace una compactación.",
+  "Also changed: {what}.": "También cambió: {what}.",
+  "Added by this call ({count})": "Añadido por esta llamada ({count})",
+  "This request adds no message.": "Esta solicitud no añade ningún mensaje.",
+  "stopped: {reason}": "detenido: {reason}",
+  "Thinking": "Razonamiento",
+  "Injected by the runtime": "Insertado por el runtime",
+  "Tool use: {name}": "Uso de herramienta: {name}",
+  "Tool result": "Resultado de la herramienta",
+  "message": "mensaje",
+  "Tool result, which failed": "Resultado de la herramienta, que falló",
+  "for {id}": "para {id}"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index a2e510e9..a984beed 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -3279,5 +3279,44 @@
   "{count} of the {n} most recent records": "{count} des {n} enregistrements les plus récents",
   "share of the newest {n}": "part des {n} plus récents",
   "No spans yet — the trace may still be landing; looking again…": "Pas encore de spans — la trace est peut-être encore en cours d'écriture ; nouvelle lecture…",
-  "Evaluation catalog unreachable.": "Catalogue d'évaluation injoignable."
+  "Evaluation catalog unreachable.": "Catalogue d'évaluation injoignable.",
+  "Prompt": "Prompt",
+  "The bodies of this call are not among the files this document lists.": "Les corps de cet appel ne font pas partie des fichiers listés par ce document.",
+  "The request this call sent and the response it received are stored.": "La requête envoyée par cet appel et la réponse reçue sont stockées.",
+  "The request this call sent is stored.": "La requête envoyée par cet appel est stockée.",
+  "The response this call received is stored.": "La réponse reçue par cet appel est stockée.",
+  "Reading them loads {files} file(s) of this session, {bytes} bytes as stored.": "Les lire charge {files} fichier(s) de cette session, {bytes} octets tels que stockés.",
+  "Load the prompt": "Charger le prompt",
+  "{loaded} of {total} files": "{loaded} sur {total} fichiers",
+  "The files could not be read: {why}": "Les fichiers n’ont pas pu être lus : {why}",
+  "These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.": "Ces fichiers de la session ne sont pas stockés ; un corps qui en dépend ne peut pas être reconstruit : seq {seqs}.",
+  "Which side of the call to show": "Quel côté de l’appel afficher",
+  "Request": "Requête",
+  "The request of this call was not captured.": "La requête de cet appel n’a pas été capturée.",
+  "The response of this call was not captured.": "La réponse de cet appel n’a pas été capturée.",
+  "This body did not come back whole.": "Ce corps n’est pas revenu entier.",
+  "How much of the request to show": "Quelle part de la requête afficher",
+  "Whole request": "Requête entière",
+  "The body": "Le corps",
+  "What it added": "Ce qu’il a ajouté",
+  "The request before this one is not among the loaded bodies, so there is nothing to compare with.": "La requête précédente ne fait pas partie des corps chargés ; il n’y a rien à comparer.",
+  "System prompt": "Prompt système",
+  "Tools ({count})": "Outils ({count})",
+  "Messages ({count})": "Messages ({count})",
+  "{bytes} bytes as sent": "{bytes} octets tels qu’envoyés",
+  "Settings": "Réglages",
+  "{count} block(s), {bytes} bytes": "{count} bloc(s), {bytes} octets",
+  "The {count} message(s) before are as the call before sent them, {bytes} bytes.": "Les {count} messages précédents sont tels que l’appel précédent les a envoyés, {bytes} octets.",
+  "The history was rewritten, which is what a compaction does.": "L’historique a été réécrit, ce que fait un compactage.",
+  "Also changed: {what}.": "A aussi changé : {what}.",
+  "Added by this call ({count})": "Ajouté par cet appel ({count})",
+  "This request adds no message.": "Cette requête n’ajoute aucun message.",
+  "stopped: {reason}": "arrêté : {reason}",
+  "Thinking": "Réflexion",
+  "Injected by the runtime": "Inséré par le runtime",
+  "Tool use: {name}": "Utilisation d’outil : {name}",
+  "Tool result": "Résultat d’outil",
+  "message": "message",
+  "Tool result, which failed": "Résultat de l’outil, en échec",
+  "for {id}": "pour {id}"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 940010ef..d69edffc 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -3279,5 +3279,44 @@
   "{count} of the {n} most recent records": "直近 {n} 件中 {count} 件",
   "share of the newest {n}": "直近 {n} 件に占める割合",
   "No spans yet — the trace may still be landing; looking again…": "スパンはまだありません。トレースが保存中の可能性があるため、再取得しています…",
-  "Evaluation catalog unreachable.": "評価カタログに接続できません。"
+  "Evaluation catalog unreachable.": "評価カタログに接続できません。",
+  "Prompt": "プロンプト",
+  "The bodies of this call are not among the files this document lists.": "この呼び出しのボディは、このドキュメントが挙げるファイルに含まれていません。",
+  "The request this call sent and the response it received are stored.": "この呼び出しが送ったリクエストと受け取ったレスポンスが保存されています。",
+  "The request this call sent is stored.": "この呼び出しが送ったリクエストが保存されています。",
+  "The response this call received is stored.": "この呼び出しが受け取ったレスポンスが保存されています。",
+  "Reading them loads {files} file(s) of this session, {bytes} bytes as stored.": "読み込むと、このセッションの {files} 個のファイル(保存時 {bytes} バイト)を取得します。",
+  "Load the prompt": "プロンプトを読み込む",
+  "{loaded} of {total} files": "{total} 個中 {loaded} 個のファイル",
+  "The files could not be read: {why}": "ファイルを読み込めませんでした: {why}",
+  "These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.": "このセッションの次のファイルは保存されていないため、それを必要とするボディは復元できません: seq {seqs}。",
+  "Which side of the call to show": "呼び出しのどちら側を表示するか",
+  "Request": "リクエスト",
+  "The request of this call was not captured.": "この呼び出しのリクエストは取得されていません。",
+  "The response of this call was not captured.": "この呼び出しのレスポンスは取得されていません。",
+  "This body did not come back whole.": "このボディは完全には復元できませんでした。",
+  "How much of the request to show": "リクエストをどこまで表示するか",
+  "Whole request": "リクエスト全体",
+  "The body": "ボディ",
+  "What it added": "追加された内容",
+  "The request before this one is not among the loaded bodies, so there is nothing to compare with.": "直前のリクエストは読み込まれたボディに含まれないため、比較できません。",
+  "System prompt": "システムプロンプト",
+  "Tools ({count})": "ツール ({count})",
+  "Messages ({count})": "メッセージ ({count})",
+  "{bytes} bytes as sent": "送信時 {bytes} バイト",
+  "Settings": "設定",
+  "{count} block(s), {bytes} bytes": "{count} ブロック、{bytes} バイト",
+  "The {count} message(s) before are as the call before sent them, {bytes} bytes.": "先行する {count} 件のメッセージは前回の呼び出しが送ったままです({bytes} バイト)。",
+  "The history was rewritten, which is what a compaction does.": "履歴が書き換えられています。コンパクションが行われたときの状態です。",
+  "Also changed: {what}.": "ほかに変わった点: {what}。",
+  "Added by this call ({count})": "この呼び出しが追加 ({count})",
+  "This request adds no message.": "このリクエストはメッセージを追加していません。",
+  "stopped: {reason}": "停止理由: {reason}",
+  "Thinking": "思考",
+  "Injected by the runtime": "ランタイムが挿入",
+  "Tool use: {name}": "ツール使用: {name}",
+  "Tool result": "ツール結果",
+  "message": "メッセージ",
+  "Tool result, which failed": "ツールの結果(失敗)",
+  "for {id}": "{id} に対して"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 1a13369a..60ca3dd4 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -3279,5 +3279,44 @@
   "{count} of the {n} most recent records": "최근 {n}개 레코드 중 {count}개",
   "share of the newest {n}": "최근 {n}개 중 비율",
   "No spans yet — the trace may still be landing; looking again…": "아직 스팬이 없습니다. 트레이스가 아직 저장 중일 수 있어 다시 읽는 중…",
-  "Evaluation catalog unreachable.": "평가 카탈로그에 접근할 수 없습니다."
+  "Evaluation catalog unreachable.": "평가 카탈로그에 접근할 수 없습니다.",
+  "Prompt": "프롬프트",
+  "The bodies of this call are not among the files this document lists.": "이 호출의 본문은 이 문서가 나열한 파일에 없습니다.",
+  "The request this call sent and the response it received are stored.": "이 호출이 보낸 요청과 받은 응답이 저장되어 있습니다.",
+  "The request this call sent is stored.": "이 호출이 보낸 요청이 저장되어 있습니다.",
+  "The response this call received is stored.": "이 호출이 받은 응답이 저장되어 있습니다.",
+  "Reading them loads {files} file(s) of this session, {bytes} bytes as stored.": "읽으려면 이 세션의 파일 {files}개, 저장된 {bytes}바이트를 불러옵니다.",
+  "Load the prompt": "프롬프트 불러오기",
+  "{loaded} of {total} files": "{total}개 중 {loaded}개 파일",
+  "The files could not be read: {why}": "파일을 읽지 못했습니다: {why}",
+  "These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.": "세션의 다음 파일이 저장되어 있지 않아, 이를 필요로 하는 본문은 복원할 수 없습니다: seq {seqs}.",
+  "Which side of the call to show": "호출의 어느 쪽을 표시할지",
+  "Request": "요청",
+  "The request of this call was not captured.": "이 호출의 요청은 수집되지 않았습니다.",
+  "The response of this call was not captured.": "이 호출의 응답은 수집되지 않았습니다.",
+  "This body did not come back whole.": "이 본문은 온전히 복원되지 않았습니다.",
+  "How much of the request to show": "요청을 어디까지 표시할지",
+  "Whole request": "요청 전체",
+  "The body": "본문",
+  "What it added": "추가된 내용",
+  "The request before this one is not among the loaded bodies, so there is nothing to compare with.": "이전 요청이 불러온 본문에 없어 비교할 대상이 없습니다.",
+  "System prompt": "시스템 프롬프트",
+  "Tools ({count})": "도구 ({count})",
+  "Messages ({count})": "메시지 ({count})",
+  "{bytes} bytes as sent": "보낸 시점 {bytes}바이트",
+  "Settings": "설정",
+  "{count} block(s), {bytes} bytes": "블록 {count}개, {bytes}바이트",
+  "The {count} message(s) before are as the call before sent them, {bytes} bytes.": "앞선 메시지 {count}개는 이전 호출이 보낸 그대로입니다({bytes}바이트).",
+  "The history was rewritten, which is what a compaction does.": "대화 기록이 다시 작성되었습니다. 컴팩션이 일어났을 때의 모습입니다.",
+  "Also changed: {what}.": "그 밖에 바뀐 것: {what}.",
+  "Added by this call ({count})": "이 호출이 추가함 ({count})",
+  "This request adds no message.": "이 요청은 메시지를 추가하지 않습니다.",
+  "stopped: {reason}": "중단: {reason}",
+  "Thinking": "사고",
+  "Injected by the runtime": "런타임이 삽입함",
+  "Tool use: {name}": "도구 사용: {name}",
+  "Tool result": "도구 결과",
+  "message": "메시지",
+  "Tool result, which failed": "도구 결과(실패)",
+  "for {id}": "{id}에 대한"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index f516ba5e..f00f7793 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -3279,5 +3279,44 @@
   "{count} of the {n} most recent records": "{count} dos {n} registros mais recentes",
   "share of the newest {n}": "proporção dos {n} mais recentes",
   "No spans yet — the trace may still be landing; looking again…": "Ainda sem spans — o trace pode ainda estar sendo gravado; lendo novamente…",
-  "Evaluation catalog unreachable.": "Catálogo de avaliação inacessível."
+  "Evaluation catalog unreachable.": "Catálogo de avaliação inacessível.",
+  "Prompt": "Prompt",
+  "The bodies of this call are not among the files this document lists.": "Os corpos desta chamada não estão entre os arquivos listados por este documento.",
+  "The request this call sent and the response it received are stored.": "A requisição enviada por esta chamada e a resposta recebida estão armazenadas.",
+  "The request this call sent is stored.": "A requisição enviada por esta chamada está armazenada.",
+  "The response this call received is stored.": "A resposta recebida por esta chamada está armazenada.",
+  "Reading them loads {files} file(s) of this session, {bytes} bytes as stored.": "Lê-los carrega {files} arquivo(s) desta sessão, {bytes} bytes como armazenados.",
+  "Load the prompt": "Carregar o prompt",
+  "{loaded} of {total} files": "{loaded} de {total} arquivos",
+  "The files could not be read: {why}": "Não foi possível ler os arquivos: {why}",
+  "These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.": "Estes arquivos da sessão não estão armazenados, então um corpo que precise deles não pode ser reconstruído: seq {seqs}.",
+  "Which side of the call to show": "Qual lado da chamada mostrar",
+  "Request": "Requisição",
+  "The request of this call was not captured.": "A requisição desta chamada não foi capturada.",
+  "The response of this call was not captured.": "A resposta desta chamada não foi capturada.",
+  "This body did not come back whole.": "Este corpo não voltou inteiro.",
+  "How much of the request to show": "Quanto da requisição mostrar",
+  "Whole request": "Requisição inteira",
+  "The body": "O corpo",
+  "What it added": "O que acrescentou",
+  "The request before this one is not among the loaded bodies, so there is nothing to compare with.": "A requisição anterior não está entre os corpos carregados, então não há com o que comparar.",
+  "System prompt": "Prompt do sistema",
+  "Tools ({count})": "Ferramentas ({count})",
+  "Messages ({count})": "Mensagens ({count})",
+  "{bytes} bytes as sent": "{bytes} bytes como enviados",
+  "Settings": "Configurações",
+  "{count} block(s), {bytes} bytes": "{count} bloco(s), {bytes} bytes",
+  "The {count} message(s) before are as the call before sent them, {bytes} bytes.": "As {count} mensagens anteriores estão como a chamada anterior as enviou, {bytes} bytes.",
+  "The history was rewritten, which is what a compaction does.": "O histórico foi reescrito, que é o que uma compactação faz.",
+  "Also changed: {what}.": "Também mudou: {what}.",
+  "Added by this call ({count})": "Adicionado por esta chamada ({count})",
+  "This request adds no message.": "Esta requisição não acrescenta nenhuma mensagem.",
+  "stopped: {reason}": "parou: {reason}",
+  "Thinking": "Raciocínio",
+  "Injected by the runtime": "Inserido pelo runtime",
+  "Tool use: {name}": "Uso de ferramenta: {name}",
+  "Tool result": "Resultado da ferramenta",
+  "message": "mensagem",
+  "Tool result, which failed": "Resultado da ferramenta, que falhou",
+  "for {id}": "para {id}"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 0f04f427..f60e3f09 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -3279,5 +3279,44 @@
   "{count} of the {n} most recent records": "最近 {n} 条记录中的 {count} 条",
   "share of the newest {n}": "占最近 {n} 条的比例",
   "No spans yet — the trace may still be landing; looking again…": "暂无 Span,Trace 可能仍在写入,正在重新读取…",
-  "Evaluation catalog unreachable.": "无法访问评估目录。"
+  "Evaluation catalog unreachable.": "无法访问评估目录。",
+  "Prompt": "提示词",
+  "The bodies of this call are not among the files this document lists.": "该调用的报文不在本文档列出的文件中。",
+  "The request this call sent and the response it received are stored.": "该调用发送的请求与收到的响应均已存储。",
+  "The request this call sent is stored.": "该调用发送的请求已存储。",
+  "The response this call received is stored.": "该调用收到的响应已存储。",
+  "Reading them loads {files} file(s) of this session, {bytes} bytes as stored.": "读取将加载该会话的 {files} 个文件,存储大小 {bytes} 字节。",
+  "Load the prompt": "加载提示词",
+  "{loaded} of {total} files": "{total} 个文件中的 {loaded} 个",
+  "The files could not be read: {why}": "无法读取文件:{why}",
+  "These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.": "该会话的以下文件未存储,依赖它们的报文无法重建:seq {seqs}。",
+  "Which side of the call to show": "显示调用的哪一侧",
+  "Request": "请求",
+  "The request of this call was not captured.": "未捕获该调用的请求。",
+  "The response of this call was not captured.": "未捕获该调用的响应。",
+  "This body did not come back whole.": "该报文未能完整重建。",
+  "How much of the request to show": "显示请求的范围",
+  "Whole request": "完整请求",
+  "The body": "报文",
+  "What it added": "新增内容",
+  "The request before this one is not among the loaded bodies, so there is nothing to compare with.": "上一条请求不在已加载的报文中,无法比较。",
+  "System prompt": "系统提示词",
+  "Tools ({count})": "工具({count})",
+  "Messages ({count})": "消息({count})",
+  "{bytes} bytes as sent": "发送时 {bytes} 字节",
+  "Settings": "设置",
+  "{count} block(s), {bytes} bytes": "{count} 个块,{bytes} 字节",
+  "The {count} message(s) before are as the call before sent them, {bytes} bytes.": "此前的 {count} 条消息与上次调用发送时相同,共 {bytes} 字节。",
+  "The history was rewritten, which is what a compaction does.": "历史记录被重写,这正是压缩(compaction)的表现。",
+  "Also changed: {what}.": "同时变化:{what}。",
+  "Added by this call ({count})": "本次调用新增({count})",
+  "This request adds no message.": "该请求没有新增消息。",
+  "stopped: {reason}": "停止原因:{reason}",
+  "Thinking": "思考",
+  "Injected by the runtime": "由运行时注入",
+  "Tool use: {name}": "工具调用:{name}",
+  "Tool result": "工具结果",
+  "message": "消息",
+  "Tool result, which failed": "工具结果(失败)",
+  "for {id}": "对应 {id}"
 }
diff --git a/apps/ui/src/layer/ai-conversation/ConversationPageView.vue b/apps/ui/src/layer/ai-conversation/ConversationPageView.vue
index 1d665475..3e78b433 100644
--- a/apps/ui/src/layer/ai-conversation/ConversationPageView.vue
+++ b/apps/ui/src/layer/ai-conversation/ConversationPageView.vue
@@ -292,7 +292,15 @@ onBeforeUnmount(() => {
       </div>
     </section>
 
-    <ConversationViewHost v-else-if="doc" :document="doc" :state="position" @update:state="onPosition" />
+    <ConversationViewHost
+      v-else-if="doc"
+      :document="doc"
+      :state="position"
+      :conversation="conversation"
+      :service="service"
+      :instance="instance"
+      @update:state="onPosition"
+    />
   </div>
 </template>
 
diff --git a/apps/ui/src/layer/ai-conversation/ConversationViewHost.vue b/apps/ui/src/layer/ai-conversation/ConversationViewHost.vue
index 21891f21..1d69e46d 100644
--- a/apps/ui/src/layer/ai-conversation/ConversationViewHost.vue
+++ b/apps/ui/src/layer/ai-conversation/ConversationViewHost.vue
@@ -20,14 +20,19 @@
   inside Vue: mounts it once per document, hands it the UI's texts and locale,
   and round-trips the reader's position (talk / step / stream) so the page can
   keep it in the URL. The renderer owns everything inside the host element.
+
+  It also gives the renderer the one read it cannot make itself: the stored files
+  of a session, which is how a call's prompt is loaded when a reader opens it.
 -->
 <script setup lang="ts">
 import '@skywalking-horizon-ui/conversation-view/style.css';
 import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { bff } from '@/api/client';
 import {
   makeFormatter,
   mountConversationView,
+  type StoredFile,
   type AszViewDocument,
   type ConversationView,
   type PublicState,
@@ -38,6 +43,10 @@ import { conversationStrings } from './conversationStrings';
 const props = defineProps<{
   document: AszViewDocument;
   state?: PublicState;
+  /** Where the document came from; the files route needs all three. */
+  conversation: string;
+  service: string;
+  instance?: string;
 }>();
 const emit = defineEmits<{ (e: 'update:state', state: PublicState): void }>();
 
@@ -57,6 +66,19 @@ function mountView(): void {
     strings: conversationStrings(t),
     formatter: makeFormatter(currentLocale(), t('unavailable')),
     ...(props.state ? { state: props.state } : {}),
+    // The prompt of a call is read on demand. Without a sender there is no files
+    // route to call, and the renderer then offers no prompts at all.
+    ...(props.instance
+      ? {
+          loadFiles: (request: { session: string; seqs: number[]; signal: AbortSignal }, onFile: (file: StoredFile) => void) =>
+            bff.aiConversation.files(
+              props.conversation,
+              { service: props.service, instance: props.instance!, session: request.session, seqs: request.seqs },
+              onFile,
+              { signal: request.signal },
+            ),
+        }
+      : {}),
     onStateChange: (s) => emit('update:state', s),
   });
 }
diff --git a/apps/ui/src/layer/ai-conversation/conversationStrings.ts b/apps/ui/src/layer/ai-conversation/conversationStrings.ts
index 25e7a61d..0dcc4fda 100644
--- a/apps/ui/src/layer/ai-conversation/conversationStrings.ts
+++ b/apps/ui/src/layer/ai-conversation/conversationStrings.ts
@@ -228,6 +228,47 @@ export function conversationStrings(t: Translate): ViewStrings {
     close: t('Close'),
     whatDoesMean: t('What does {key} mean?', { key: '{key}' }),
     changes: t('Changes'),
+    prompt: t('Prompt'),
+    promptNotHere: t('The bodies of this call are not among the files this document lists.'),
+    promptBothStored: t('The request this call sent and the response it received are stored.'),
+    promptRequestStored: t('The request this call sent is stored.'),
+    promptResponseStored: t('The response this call received is stored.'),
+    promptLoadCost: t('Reading them loads {files} file(s) of this session, {bytes} bytes as stored.', { files: '{files}', bytes: '{bytes}' }),
+    promptLoad: t('Load the prompt'),
+    promptLoading: t('{loaded} of {total} files', { loaded: '{loaded}', total: '{total}' }),
+    promptReading: t('Reading…'),
+    promptLoadFailed: t('The files could not be read: {why}', { why: '{why}' }),
+    promptFilesMissing: t('These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.', { seqs: '{seqs}' }),
+    promptSides: t('Which side of the call to show'),
+    promptRequest: t('Request'),
+    promptResponse: t('Response'),
+    promptNoRequest: t('The request of this call was not captured.'),
+    promptNoResponse: t('The response of this call was not captured.'),
+    promptRebuildFailed: t('This body did not come back whole.'),
+    promptRequestModes: t('How much of the request to show'),
+    promptWholeRequest: t('Whole request'),
+    promptWholeBody: t('The body'),
+    promptChanges: t('What it added'),
+    promptNoPrevious: t('The request before this one is not among the loaded bodies, so there is nothing to compare with.'),
+    promptSystem: t('System prompt'),
+    promptTools: t('Tools ({count})', { count: '{count}' }),
+    promptMessages: t('Messages ({count})', { count: '{count}' }),
+    promptMessagesNote: t('{bytes} bytes as sent', { bytes: '{bytes}' }),
+    promptSettings: t('Settings'),
+    promptBlocks: t('{count} block(s), {bytes} bytes', { count: '{count}', bytes: '{bytes}' }),
+    promptShared: t('The {count} message(s) before are as the call before sent them, {bytes} bytes.', { count: '{count}', bytes: '{bytes}' }),
+    promptRewritten: t('The history was rewritten, which is what a compaction does.'),
+    promptAlsoChanged: t('Also changed: {what}.', { what: '{what}' }),
+    promptAdded: t('Added by this call ({count})', { count: '{count}' }),
+    promptNothingAdded: t('This request adds no message.'),
+    promptStopReason: t('stopped: {reason}', { reason: '{reason}' }),
+    promptThinking: t('Thinking'),
+    promptReminder: t('Injected by the runtime'),
+    promptToolUse: t('Tool use: {name}', { name: '{name}' }),
+    promptToolResult: t('Tool result'),
+    promptToolFailed: t('Tool result, which failed'),
+    promptToolFor: t('for {id}', { id: '{id}' }),
+    promptUnknownRole: t('message'),
     changesBadge: t('{n} changes', { n: '{n}' }),
     oneFileChanged: t('1 file'),
     filesChanged: t('{n} files', { n: '{n}' }),
diff --git a/docs/operate/ai-agent-conversations.md b/docs/operate/ai-agent-conversations.md
index 5046608c..e22b3839 100644
--- a/docs/operate/ai-agent-conversations.md
+++ b/docs/operate/ai-agent-conversations.md
@@ -82,10 +82,23 @@ The status strip at the top states the document's integrity — *verified* when
 
 What the page shows is exactly what the Sessionizer's own viewer shows for the same conversation: the two draw the same document with the same renderer. The words the runtime uses for things — node kinds such as `message.external`, relation types, join qualities — are shown as the document carries them, in every locale.
 
+### Prompts
+
+When the Sessionizer captured the bodies a model call exchanged with its provider, the call's step gains a **Prompt** tab in the inspector: the exact request that was sent, and the exact response that came back, as the agent's runtime wrote them.
+
+**Nothing is read until you ask.** The tab opens on an offer that says how many stored files the prompt needs and how many bytes they are, over a **Load the prompt** button. The bodies are not part of the conversation document — they are megabytes per call, and a conversation has hundreds of calls — so Horizon reads them from OAP one session at a time, only for the conversation you have open, and only when you press the button. What it has read it keeps for as long as the page is open, s [...]
+
+A **request** opens whole: the **system prompt**, the **tools** it offered with their descriptions, the **messages** it sent with their text, tool uses and tool results, and the **settings** — every other field of the body, the model, the sampling settings and the metadata, as JSON. Text the runtime injected rather than a person writing it, such as a `<system-reminder>`, is marked as injected. The **What it added** mode compares the request with the one the call before it sent and shows  [...]
+
+A **response** is shown on its own: what stopped it, the token counts the provider reported, and the blocks it returned — text, reasoning, and the tool uses the agent went on to run. Every text has a *copy* button that copies it in full, never the shortened preview.
+
+**What is shown is checked, not trusted.** The Sessionizer stores a body cut into pieces so that history repeated across calls is stored once, and Horizon rebuilds each body in your browser and checks it against the byte count and the SHA-256 digest the Sessionizer recorded. A body it cannot rebuild says so instead of showing a partial one. A body of a shape it does not know — a provider other than the one it reads — is shown as the JSON document it is, with nothing left out.
+
 ### Limits
 
 - **One document, all at once.** OAP sends the whole conversation, and its `viewTimeoutMs` budget (120 s by default, see [Configuration File](../setup/horizon-yaml.md)) covers the wait for the first byte. A conversation OAP cannot assemble within that time reports a timeout; try again, or raise the budget on both sides. Horizon holds the document before the browser starts receiving it, which is how the page knows the size to count against; that hold is a fifth of the document's size per  [...]
-- **Records are not on OAP.** The Sessionizer's own viewer can open the raw landed record behind a step; OAP stores the assembled document only, so this page shows the text the document carries and says when it was clipped.
+- **Landed records are not on OAP, except the prompts.** The Sessionizer's own viewer can open the raw landed record behind any step; OAP stores the assembled document, so this page shows the text the document carries and says when it was clipped. The one exception is the provider bodies above, which OAP stores as files and serves on request.
+- **Prompts need a Sessionizer that captured them.** Provider bodies exist from AI Sessionizer 0.4.0, and only for the calls it was configured to capture. A call with none has no Prompt tab; nothing about it is hidden, it was never stored.
 
 ## Troubleshooting
 
@@ -95,4 +108,6 @@ What the page shows is exactly what the Sessionizer's own viewer shows for the s
 - **The conversation page says OAP holds no round for this agent.** The link names a agent OAP has no round of this conversation for — the Sessionizer was renamed between pushes, or the agent runtime in the link is not the one that pushed it. Open the conversation from the list again.
 - **The page waits a long time, then reports a timeout.** OAP assembles the whole chain before answering, and a very long conversation can exceed the time budget. Try again; if it keeps happening, raise `performance.aiConversation.viewTimeoutMs` in Horizon and the matching `viewRequestTimeout` on OAP.
 - **A row does nothing when clicked.** The browser blocked the new tab as a pop-up. Allow pop-ups for Horizon's address, or open the row with Enter after focusing it.
+- **A model call has no Prompt tab.** The Sessionizer captured no request or response body for it — either it is older than 0.4.0, or provider body capture was not configured for that agent runtime. The Sessionizer's [Claude Code provider bodies](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/readme/) page says what it captures.
+- **The prompt says a file is missing, or cannot be rebuilt.** OAP answered without one of the session's stored files, so the bodies that refer to it cannot be put back together. The file is past OAP's retention while the conversation document is not, or it never arrived. The panel names the file's position, and reading another call of the same session still works when its own history is there.
 - **No tool call shows a change mark.** Change records exist from AI Sessionizer 0.3.0 with an OAP that carries them in the document. The runtime's own `Edit` and `Write` patches need no plugin, but a session collected by an earlier Sessionizer has none, since the record is made when the transcript is collected, not when it is re-read. Shell commands and edits inside subagents are recorded only where the asz Claude Code plugin is installed.
diff --git a/packages/api-client/src/ai-conversation.ts b/packages/api-client/src/ai-conversation.ts
index da36984f..4ac5f558 100644
--- a/packages/api-client/src/ai-conversation.ts
+++ b/packages/api-client/src/ai-conversation.ts
@@ -107,6 +107,10 @@ export const ASZ_VIEW_FORMAT = 'asz.view';
 export const ASZ_VIEW_MAJOR_VERSION = 1;
 export const ASZ_VIEW_JSON_MEDIA_TYPE = 'application/vnd.skywalking.asz.view+json';
 export const ASZ_VIEW_YAML_MEDIA_TYPE = 'application/vnd.skywalking.asz.view+yaml';
+/** The files route's body: for each stored file a naming line, then its bytes. */
+export const ASZ_FILES_MEDIA_TYPE = 'application/vnd.skywalking.asz.files+ndjson';
+/** The most seqs one files request carries, the OAP route's own limit. */
+export const ASZ_FILES_MAX_SEQS = 32;
 
 /** What the view route answers with instead of a document: an RFC 9457 problem,
  *  `application/problem+json`. 400 without a service, 404 when the service
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 3e2c2817..5deb82bc 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -192,6 +192,8 @@ export type {
 export {
   ASZ_VIEW_FORMAT,
   ASZ_VIEW_MAJOR_VERSION,
+  ASZ_FILES_MAX_SEQS,
+  ASZ_FILES_MEDIA_TYPE,
   ASZ_VIEW_JSON_MEDIA_TYPE,
   ASZ_VIEW_YAML_MEDIA_TYPE,
   AI_CONVERSATION_DOCUMENT_BYTES_HEADER,
diff --git a/packages/conversation-view/src/index.ts b/packages/conversation-view/src/index.ts
index 6a9a209b..55267c33 100644
--- a/packages/conversation-view/src/index.ts
+++ b/packages/conversation-view/src/index.ts
@@ -31,6 +31,8 @@
 
 import './styles.css';
 import { esc, cssEscape, reducedMotion, scrollTo } from './dom.js';
+import type { StoredFile } from './prompt/store.js';
+import { PromptCache } from './prompt/cache.js';
 import { EN_US_FORMATTER, type TimeFormatter } from './format.js';
 import { ConversationModel, type Step } from './model.js';
 import { ENGLISH, fill, type ViewStrings } from './strings.js';
@@ -58,6 +60,7 @@ export { makeFormatter, EN_US_FORMATTER } from './format.js';
 export type { ViewStrings } from './strings.js';
 export { ENGLISH } from './strings.js';
 export * from './types.js';
+export type { StoredFile, PromptManifest } from './prompt/store.js';
 
 /** The reader's position, as a host keeps it in its URL. */
 export interface PublicState {
@@ -74,6 +77,15 @@ export interface MountOptions {
   /** Reads one landed record by address. Offered in the Evidence tab only when
    *  present: the Sessionizer's viewer has such an endpoint, the OAP has none. */
   loadRecord?: (ref: AszRef) => Promise<LandedRecord>;
+  /** Reads stored files of one session by their landed seqs, which is how a
+   *  reader gets the provider bodies a call points at. Each file is handed
+   *  over as it arrives, so a long read shows its progress, and the signal
+   *  ends it when the reader moves on. Offered in the Prompt tab only when
+   *  present: a host without a files route shows no prompts. */
+  loadFiles?: (
+    request: { session: string; seqs: number[]; signal: AbortSignal },
+    onFile: (file: StoredFile) => void,
+  ) => Promise<void>;
   state?: PublicState;
   onStateChange?: (state: PublicState) => void;
 }
@@ -128,6 +140,7 @@ function skeleton(s: ViewStrings): string {
           <button class="acv-tab" type="button" role="tab" data-tab="relations" aria-selected="false">${esc(s.relations)}</button>
           <button class="acv-tab" type="button" role="tab" data-tab="evidence" aria-selected="false">${esc(s.evidence)}</button>
           <button class="acv-tab" type="button" role="tab" data-tab="changes" aria-selected="false" hidden>${esc(s.changes)}</button>
+          <button class="acv-tab" type="button" role="tab" data-tab="prompt" aria-selected="false" hidden>${esc(s.prompt)}</button>
         </div>
         <div class="acv-inspector-body" role="tabpanel"></div>
       </aside>
@@ -204,6 +217,9 @@ export function mountConversationView(host: HTMLElement, opts: MountOptions): Co
     openChangeFiles: new Set(),
     openTexts: new Set(),
     inspectorPopped: false,
+    promptSide: 'request',
+    promptWhole: true,
+    openPromptSections: new Set<string>(),
     fullDiffs: new Set(),
   };
 
@@ -228,6 +244,8 @@ export function mountConversationView(host: HTMLElement, opts: MountOptions): Co
     f: opts.formatter ?? EN_US_FORMATTER,
     glossary: opts.glossary ?? null,
     loadRecord: opts.loadRecord,
+    loadFiles: opts.loadFiles,
+    prompts: new PromptCache(),
     state,
     q<T extends Element = HTMLElement>(selector: string): T {
       const el = root.querySelector<T>(selector);
@@ -437,7 +455,8 @@ export function mountConversationView(host: HTMLElement, opts: MountOptions): Co
 
   function showTab(tab: InspectorTab): void {
     state.tab = tab;
-    root.querySelectorAll<HTMLElement>('[data-tab]').forEach((t) => t.setAttribute('aria-selected', String(t.dataset.tab === tab)));
+    // the inspector marks which tab is selected: it is the one that knows when a step has nothing
+    // for the chosen tab and details is drawn in its place
     drawInspector(ctx);
   }
 
@@ -618,6 +637,9 @@ export function mountConversationView(host: HTMLElement, opts: MountOptions): Co
     destroy(): void {
       document.removeEventListener('keydown', onKey);
       document.removeEventListener('pointerdown', onPointerDown);
+      // a read in flight has no reader any more: ending it lets the browser drop the request, and the
+      // relay behind it stop asking OAP for the rest
+      ctx.prompts.stop();
       teardownPanels();
       root.innerHTML = '';
       root.classList.remove('acv');
diff --git a/packages/conversation-view/src/model.ts b/packages/conversation-view/src/model.ts
index 5a8ea37b..1202253c 100644
--- a/packages/conversation-view/src/model.ts
+++ b/packages/conversation-view/src/model.ts
@@ -30,6 +30,7 @@ import type {
   AszEdge,
   AszFileChange,
   AszNode,
+  AszProviderBody,
   AszRef,
   AszSegment,
   AszStream,
@@ -74,6 +75,8 @@ export interface Step {
   edges: AszEdge[];
   /** Whether workspace change records join to this step (the node's `changes`). */
   hasChanges: boolean;
+  /** Where this step's provider bodies landed, when it has any. */
+  providerBodies?: AszProviderBody[];
   /** Position in the flattened document, for ties and for nodes without a ref. */
   order: number;
   depth: number;
@@ -297,6 +300,7 @@ export class ConversationModel {
             refs: n.refs,
             attrs: n.attrs,
             usage: n.usage,
+            providerBodies: n.provider_bodies,
             flags: n.flags,
             dropped: n.dropped,
             edges: n.edges ?? [],
diff --git a/packages/conversation-view/src/prompt/cache.ts b/packages/conversation-view/src/prompt/cache.ts
new file mode 100644
index 00000000..d0a84ec4
--- /dev/null
+++ b/packages/conversation-view/src/prompt/cache.ts
@@ -0,0 +1,108 @@
+/*
+ * 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.
+ */
+
+/**
+ * What a view holds of the provider bodies: one store per session, and the read in flight.
+ *
+ * A body refers to bodies and pieces that landed before it, so a session's files are loaded in seq
+ * order and kept. Opening a later call then loads only the files after the ones already held, and
+ * moving back to an earlier call loads nothing at all.
+ */
+
+import { PromptStore, type StoredFile } from './store.js';
+
+/** How a read ended, for the words the panel shows. */
+export interface LoadOutcome {
+  /** The seqs asked for and not answered: the OAP leaves out what it does not store. */
+  missing: number[];
+  /** Why the read stopped early, when it did. */
+  failed?: string;
+}
+
+export class PromptCache {
+  private readonly stores = new Map<string, PromptStore>();
+  private readonly outcomes = new Map<string, LoadOutcome>();
+  private running: { key: string; controller: AbortController } | null = null;
+
+  store(session: string): PromptStore {
+    let s = this.stores.get(session);
+    if (!s) {
+      s = new PromptStore();
+      this.stores.set(session, s);
+    }
+    return s;
+  }
+
+  outcome(session: string): LoadOutcome | null {
+    return this.outcomes.get(session) ?? null;
+  }
+
+  /** Whether a read is in flight for that session. */
+  reading(session: string): boolean {
+    return this.running?.key === session;
+  }
+
+  /**
+   * Loads the files of a session up to and including `throughSeq`, skipping what is held, at most
+   * `batch` seqs a request, and tells `onProgress` after each file. One read runs at a time: starting
+   * another ends the one before it, as a reader who moves on has stopped waiting for it.
+   */
+  async load(
+    session: string,
+    seqs: number[],
+    loader: (request: { session: string; seqs: number[]; signal: AbortSignal }, onFile: (file: StoredFile) => void) => Promise<void>,
+    batch: number,
+    onProgress: (loaded: number, total: number) => void,
+  ): Promise<LoadOutcome> {
+    this.running?.controller.abort();
+    const controller = new AbortController();
+    this.running = { key: session, controller };
+    const store = this.store(session);
+    const want = seqs.filter((seq) => !store.has(seq)).sort((a, b) => a - b);
+    const outcome: LoadOutcome = { missing: [] };
+    let loaded = 0;
+    try {
+      for (let at = 0; at < want.length; at += batch) {
+        const asked = want.slice(at, at + batch);
+        const answered = new Set<number>();
+        await loader({ session, seqs: asked, signal: controller.signal }, (file) => {
+          // in seq order, as the route answers, so a body's earlier bodies are held before it
+          store.addFile(file);
+          answered.add(file.seq);
+          loaded++;
+          onProgress(loaded, want.length);
+        });
+        for (const seq of asked) if (!answered.has(seq)) outcome.missing.push(seq);
+      }
+    } catch (err) {
+      if (controller.signal.aborted) throw err;
+      outcome.failed = err instanceof Error ? err.message : String(err);
+    } finally {
+      if (this.running?.controller === controller) this.running = null;
+    }
+    this.outcomes.set(session, outcome);
+    return outcome;
+  }
+
+  /** Ends any read in flight, as a view being destroyed must. */
+  stop(): void {
+    this.running?.controller.abort();
+    this.running = null;
+  }
+}
diff --git a/packages/conversation-view/src/prompt/messages.ts b/packages/conversation-view/src/prompt/messages.ts
new file mode 100644
index 00000000..8ec3efe5
--- /dev/null
+++ b/packages/conversation-view/src/prompt/messages.ts
@@ -0,0 +1,284 @@
+/*
+ * 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.
+ */
+
+/**
+ * Reading a provider body as a conversation.
+ *
+ * The bodies are the runtime's own, so their shape is the provider's API, not the model's. This reads
+ * the one shape the Sessionizer captures today, the Anthropic Messages API, and keeps everything it
+ * does not know: an unread field stays under the settings, an unread block prints as JSON. A body of
+ * another shape reads as one JSON document and nothing is lost.
+ */
+
+export interface PromptBlock {
+  /** `text`, `tool_use`, `tool_result`, `thinking`, or whatever the body called it. */
+  kind: string;
+  text?: string;
+  name?: string;
+  /** A tool use's own id, and the id a tool result answers, which is how the two are paired. */
+  id?: string;
+  /** A tool use's input, a tool result's content, or an unread block, as JSON. */
+  json?: unknown;
+  /** Whether the runtime injected this text rather than a person writing it. */
+  reminder?: boolean;
+  /** Whether the provider marked this tool result a failure. A failed result and a successful one
+   *  can carry the same text, and only this tells them apart. */
+  failed?: boolean;
+}
+
+export interface PromptMessage {
+  role: string;
+  blocks: PromptBlock[];
+  /** The message as it was sent, for the raw view and for comparing. */
+  raw: unknown;
+}
+
+export interface PromptTool {
+  name: string;
+  description: string;
+  raw: unknown;
+}
+
+export interface ReadRequest {
+  kind: 'request';
+  model?: string;
+  system: PromptBlock[];
+  tools: PromptTool[];
+  messages: PromptMessage[];
+  /** Every top-level field the sections above do not show, as JSON. */
+  rest: Record<string, unknown>;
+  raw: Record<string, unknown>;
+}
+
+export interface ReadResponse {
+  kind: 'response';
+  model?: string;
+  id?: string;
+  stopReason?: string;
+  blocks: PromptBlock[];
+  usage?: Record<string, unknown>;
+  rest: Record<string, unknown>;
+  raw: Record<string, unknown>;
+}
+
+/** A body that is not the shape this reader knows: it is shown as JSON, or as its own text when it is
+ *  not JSON at all — a provider's plain error page rebuilds and verifies like any other body. */
+export interface ReadOther {
+  kind: 'other';
+  raw: unknown;
+  /** The body as text, when it did not read as JSON. */
+  text?: string;
+}
+
+export type ReadBody = ReadRequest | ReadResponse | ReadOther;
+
+const REMINDER = '<system-reminder>';
+
+/** How deep the provider's cache marker sits in each thing the delta compares: on a message and on
+ *  each of its content blocks, two levels in because the content is an array; on a system block or a
+ *  tool, one level into the list; nowhere in the settings. */
+const MARKED_IN_MESSAGE = 2;
+const MARKED_IN_LIST = 1;
+const NOT_MARKED = -1;
+
+/** Reads a rebuilt body. `role` is what the manifest says the body is. */
+export function readBody(bytes: Uint8Array, role: string): ReadBody {
+  const text = new TextDecoder().decode(bytes);
+  let value: unknown;
+  try {
+    value = JSON.parse(text) as unknown;
+  } catch {
+    return { kind: 'other', raw: null, text };
+  }
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return { kind: 'other', raw: value };
+  const body = value as Record<string, unknown>;
+  if (role === 'request' && Array.isArray(body['messages'])) return readRequest(body);
+  if (role === 'response' && Array.isArray(body['content'])) return readResponse(body);
+  return { kind: 'other', raw: value };
+}
+
+function readRequest(body: Record<string, unknown>): ReadRequest {
+  const rest: Record<string, unknown> = {};
+  for (const [k, v] of Object.entries(body)) {
+    if (k !== 'system' && k !== 'tools' && k !== 'messages') rest[k] = v;
+  }
+  return {
+    kind: 'request',
+    model: typeof body['model'] === 'string' ? body['model'] : undefined,
+    system: readSystem(body['system']),
+    tools: readTools(body['tools']),
+    messages: (body['messages'] as unknown[]).map(readMessage),
+    rest,
+    raw: body,
+  };
+}
+
+function readResponse(body: Record<string, unknown>): ReadResponse {
+  const rest: Record<string, unknown> = {};
+  for (const [k, v] of Object.entries(body)) {
+    if (k !== 'content' && k !== 'usage' && k !== 'stop_reason' && k !== 'id') rest[k] = v;
+  }
+  return {
+    kind: 'response',
+    model: typeof body['model'] === 'string' ? body['model'] : undefined,
+    id: typeof body['id'] === 'string' ? body['id'] : undefined,
+    stopReason: typeof body['stop_reason'] === 'string' ? body['stop_reason'] : undefined,
+    blocks: (body['content'] as unknown[]).map(readBlock),
+    usage: body['usage'] && typeof body['usage'] === 'object' ? (body['usage'] as Record<string, unknown>) : undefined,
+    rest,
+    raw: body,
+  };
+}
+
+function readSystem(value: unknown): PromptBlock[] {
+  if (typeof value === 'string') return [{ kind: 'text', text: value }];
+  if (!Array.isArray(value)) return [];
+  return value.map(readBlock);
+}
+
+function readTools(value: unknown): PromptTool[] {
+  if (!Array.isArray(value)) return [];
+  return value.map((t) => {
+    const tool = (t ?? {}) as Record<string, unknown>;
+    const description = typeof tool['description'] === 'string' ? tool['description'] : '';
+    return { name: typeof tool['name'] === 'string' ? tool['name'] : '', description, raw: t };
+  });
+}
+
+function readMessage(value: unknown): PromptMessage {
+  const m = (value ?? {}) as Record<string, unknown>;
+  const content = m['content'];
+  const blocks = typeof content === 'string' ? [{ kind: 'text', text: content }] : Array.isArray(content) ? content.map(readBlock) : [];
+  return { role: typeof m['role'] === 'string' ? m['role'] : '', blocks, raw: value };
+}
+
+function readBlock(value: unknown): PromptBlock {
+  if (typeof value === 'string') return { kind: 'text', text: value };
+  if (!value || typeof value !== 'object') return { kind: 'unknown', json: value };
+  const b = value as Record<string, unknown>;
+  const kind = typeof b['type'] === 'string' ? b['type'] : 'unknown';
+  if (kind === 'text' && typeof b['text'] === 'string') {
+    return { kind, text: b['text'], reminder: b['text'].includes(REMINDER) };
+  }
+  if (kind === 'thinking') {
+    const thinking = typeof b['thinking'] === 'string' ? b['thinking'] : '';
+    return { kind, text: thinking, json: value };
+  }
+  if (kind === 'tool_use') {
+    return {
+      kind,
+      name: typeof b['name'] === 'string' ? b['name'] : '',
+      ...(typeof b['id'] === 'string' ? { id: b['id'] } : {}),
+      json: b['input'],
+    };
+  }
+  if (kind === 'tool_result') {
+    const content = b['content'];
+    const about = {
+      ...(typeof b['tool_use_id'] === 'string' ? { id: b['tool_use_id'] } : {}),
+      ...(b['is_error'] === true ? { failed: true } : {}),
+    };
+    if (typeof content === 'string') return { kind, text: content, json: value, ...about };
+    return { kind, json: content ?? value, ...about };
+  }
+  return { kind, json: value };
+}
+
+/** What one request adds to the one before it. */
+export interface PromptDelta {
+  /** The messages this request carries that the one before it did not. */
+  added: PromptMessage[];
+  /** How many messages the two share, and how many bytes they are. */
+  sharedMessages: number;
+  sharedBytes: number;
+  /** Whether the shared messages differ in their own text, which a compaction does. */
+  rewritten: boolean;
+  /** What else changed outside the messages. */
+  systemChanged: boolean;
+  toolsChanged: boolean;
+  settingsChanged: boolean;
+}
+
+/**
+ * Compares two requests, message by message, from the start. The cache marker the runtime moves down
+ * the list as it goes is left out of the comparison, so a message that only lost or gained that marker
+ * counts as unchanged; everything else is compared as it was sent.
+ */
+export function deltaOf(previous: ReadRequest, current: ReadRequest): PromptDelta {
+  let shared = 0;
+  let rewritten = false;
+  const least = Math.min(previous.messages.length, current.messages.length);
+  while (shared < least && same(previous.messages[shared]!.raw, current.messages[shared]!.raw, MARKED_IN_MESSAGE)) shared++;
+  if (shared < least) rewritten = true;
+  const added = current.messages.slice(shared);
+  let sharedBytes = 0;
+  for (let i = 0; i < shared; i++) sharedBytes += size(current.messages[i]!.raw);
+  return {
+    added,
+    sharedMessages: shared,
+    sharedBytes,
+    rewritten,
+    systemChanged: !same(previous.raw['system'], current.raw['system'], MARKED_IN_LIST),
+    toolsChanged: !same(previous.raw['tools'], current.raw['tools'], MARKED_IN_LIST),
+    settingsChanged: !same(settings(previous), settings(current), NOT_MARKED),
+  };
+}
+
+function settings(r: ReadRequest): Record<string, unknown> {
+  const out: Record<string, unknown> = {};
+  for (const [k, v] of Object.entries(r.rest)) if (k !== 'metadata') out[k] = v;
+  return out;
+}
+
+/**
+ * Two values, compared without the moving cache marker. `markedTo` is how deep the marker can sit in
+ * this value: a message carries one and so does each of its content blocks, which are two levels in
+ * because the content is an array; a system block or a tool is one level into the list it is in.
+ */
+function same(a: unknown, b: unknown, markedTo: number): boolean {
+  return canonical(a, markedTo) === canonical(b, markedTo);
+}
+
+function size(value: unknown): number {
+  return new TextEncoder().encode(JSON.stringify(value) ?? '').length;
+}
+
+/**
+ * The value as one string, with the provider's cache markers left out.
+ *
+ * The marker is a `cache_control` field the runtime moves down the message list as the conversation
+ * grows, so a message that only gained or lost one is the same message. It sits on a message, a
+ * content block, a system block or a tool, and nowhere deeper: a `cache_control` inside a tool's own
+ * input or schema is the agent's data, and two calls that differ only there differ. That is why the
+ * walk carries how deep the marker can be rather than removing the name at every depth.
+ *
+ * It builds a string instead of an object because a body can carry a `__proto__` key, and assigning
+ * that into an object changes the object's prototype instead of keeping the value.
+ */
+function canonical(value: unknown, markedTo: number, depth = 0): string {
+  if (Array.isArray(value)) return `[${value.map((v) => canonical(v, markedTo, depth + 1)).join(',')}]`;
+  if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
+  const obj = value as Record<string, unknown>;
+  const parts: string[] = [];
+  for (const k of Object.keys(obj).sort()) {
+    if (depth <= markedTo && k === 'cache_control') continue;
+    parts.push(`${JSON.stringify(k)}:${canonical(obj[k], markedTo, depth + 1)}`);
+  }
+  return `{${parts.join(',')}}`;
+}
diff --git a/packages/conversation-view/src/prompt/sha256.ts b/packages/conversation-view/src/prompt/sha256.ts
new file mode 100644
index 00000000..269d4b8b
--- /dev/null
+++ b/packages/conversation-view/src/prompt/sha256.ts
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+
+/**
+ * SHA-256, FIPS 180-4, over bytes.
+ *
+ * The provider bodies are checked against the digests their manifests carry, and a piece is found by
+ * its digest, so the reader hashes as it rebuilds. `crypto.subtle` cannot serve that: it is
+ * asynchronous, and it is absent outside a secure context, which a page served over plain HTTP on a
+ * host other than localhost is. This is a few hundred lines less than that trade.
+ */
+
+const K = new Uint32Array([
+  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01,
+  0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
+  0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
+  0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+  0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08,
+  0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
+  0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+]);
+
+/** @returns the sha256 of the bytes, as lower-case hexadecimal */
+export function sha256Hex(bytes: Uint8Array): string {
+  const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
+  const w = new Uint32Array(64);
+  const length = bytes.length;
+  // the message, a 0x80 byte, zeroes, and the bit length in the last eight bytes, rounded up to whole
+  // blocks: the nine bytes must fit, and when they fit exactly no further block is added
+  const padded = new Uint8Array(((length + 72) >> 6) << 6);
+  padded.set(bytes);
+  padded[length] = 0x80;
+  const bits = length * 8;
+  const view = new DataView(padded.buffer);
+  view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000));
+  view.setUint32(padded.length - 4, bits >>> 0);
+
+  for (let at = 0; at < padded.length; at += 64) {
+    for (let i = 0; i < 16; i++) w[i] = view.getUint32(at + i * 4);
+    for (let i = 16; i < 64; i++) {
+      const a = w[i - 15]!;
+      const b = w[i - 2]!;
+      const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
+      const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
+      w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0;
+    }
+    let [a, b, c, d, e, f, g, hh] = [h[0]!, h[1]!, h[2]!, h[3]!, h[4]!, h[5]!, h[6]!, h[7]!];
+    for (let i = 0; i < 64; i++) {
+      const s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
+      const ch = (e & f) ^ (~e & g);
+      const t1 = (hh + s1 + ch + K[i]! + w[i]!) >>> 0;
+      const s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
+      const maj = (a & b) ^ (a & c) ^ (b & c);
+      const t2 = (s0 + maj) >>> 0;
+      hh = g;
+      g = f;
+      f = e;
+      e = (d + t1) >>> 0;
+      d = c;
+      c = b;
+      b = a;
+      a = (t1 + t2) >>> 0;
+    }
+    h[0] = (h[0]! + a) >>> 0;
+    h[1] = (h[1]! + b) >>> 0;
+    h[2] = (h[2]! + c) >>> 0;
+    h[3] = (h[3]! + d) >>> 0;
+    h[4] = (h[4]! + e) >>> 0;
+    h[5] = (h[5]! + f) >>> 0;
+    h[6] = (h[6]! + g) >>> 0;
+    h[7] = (h[7]! + hh) >>> 0;
+  }
+  let out = '';
+  for (const word of h) out += word.toString(16).padStart(8, '0');
+  return out;
+}
diff --git a/packages/conversation-view/src/prompt/store.ts b/packages/conversation-view/src/prompt/store.ts
new file mode 100644
index 00000000..1a437101
--- /dev/null
+++ b/packages/conversation-view/src/prompt/store.ts
@@ -0,0 +1,492 @@
+/*
+ * 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 provider bodies of one session, as the Sessionizer's `pkg/providerbody` holds them.
+ *
+ * A body is stored cut: a record keeps only what the session did not hold yet, and its last part is a
+ * `provider_body/1` manifest whose segments say how to put the body back together. A segment is a
+ * literal, one of the record's own parts, a piece an earlier record holds, or the front of an earlier
+ * body. So the bodies of a session must be added in the order they landed, which is why a reader loads
+ * every provider file up to the one it wants.
+ *
+ * Bytes are kept as bytes. A part's `data` is the source's own JSON, byte for byte, so it is taken from
+ * the record line verbatim rather than decoded and printed again, which would change escapes, spacing
+ * and number spelling and break both the piece lookup and the digest.
+ */
+
+import { sha256Hex } from './sha256.js';
+
+/** The largest body this reader rebuilds, as the Sessionizer's MaxBytes. */
+export const MAX_BODY_BYTES = 256 * 1024 * 1024;
+/** The deepest chain of copies, as the Sessionizer's MaxDepth. */
+export const MAX_DEPTH = 32;
+/** Rebuilt bodies kept, in bytes, before the oldest is dropped. */
+const CACHE_LIMIT = 32 * 1024 * 1024;
+
+export const ROLE_REQUEST = 'request';
+export const ROLE_RESPONSE = 'response';
+
+export interface PromptCopy {
+  from: string;
+  sha256: string;
+  len: number;
+}
+
+export interface PromptSegment {
+  lit?: string;
+  part?: number;
+  piece?: string;
+  copy?: PromptCopy;
+}
+
+/** The manifest of one body, in the model's words. */
+export interface PromptManifest {
+  schema: string;
+  role: string;
+  src?: string;
+  sha256: string;
+  bytes: number;
+  depth: number;
+  chain?: string;
+  why?: string;
+  model?: string;
+  session?: string;
+  run?: string;
+  call?: string;
+  request?: string;
+  previous_request?: string;
+  segments: PromptSegment[];
+}
+
+interface Part {
+  kind: string;
+  /** The part's `data` as the record line spells it, or null when it has none. */
+  raw: string | null;
+  encoding?: string;
+}
+
+export interface PromptRecord {
+  id: string;
+  seq: number;
+  row: number;
+  parts: Part[];
+  manifest: PromptManifest;
+}
+
+export const PROMPT_SCHEMA = 'provider_body/1';
+
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
+/** One file as a host hands it over: the landed seq, the name, and its bytes. */
+export interface StoredFile {
+  seq: number;
+  file: string;
+  bytes: Uint8Array;
+}
+
+/**
+ * The records of one `provider_body` file, in line order. The header is row 0 and the closing line is
+ * no record, as a Session Data reader counts them. A line that does not read ends the file there,
+ * and a record that is not a provider body is skipped.
+ */
+export function readProviderFile(seq: number, bytes: Uint8Array): PromptRecord[] {
+  const out: PromptRecord[] = [];
+  let row = 0;
+  for (const line of splitLines(bytes)) {
+    if (row === 0) {
+      row++;
+      continue; // the header
+    }
+    const text = decoder.decode(line);
+    let node: Record<string, unknown>;
+    try {
+      node = JSON.parse(text) as Record<string, unknown>;
+    } catch {
+      return out; // a line that does not read ends the records, as the Sessionizer's reader does
+    }
+    if (node['t'] === 'end') return out;
+    const record = readRecord(seq, row, node, text);
+    if (record) out.push(record);
+    row++;
+  }
+  return out;
+}
+
+function readRecord(seq: number, row: number, node: Record<string, unknown>, line: string): PromptRecord | null {
+  const parts = Array.isArray(node['parts']) ? (node['parts'] as Array<Record<string, unknown>>) : [];
+  if (!parts.length) return null;
+  const raws = rawPartData(line);
+  const read: Part[] = parts.map((p, i) => ({
+    kind: typeof p['k'] === 'string' ? (p['k'] as string) : '',
+    raw: raws[i] ?? null,
+    encoding: typeof p['encoding'] === 'string' ? (p['encoding'] as string) : undefined,
+  }));
+  const last = read[read.length - 1]!;
+  if (last.kind !== 'data' || last.raw === null) return null;
+  let manifest: PromptManifest;
+  try {
+    manifest = JSON.parse(last.raw) as PromptManifest;
+  } catch {
+    return null;
+  }
+  if (!wellFormed(manifest)) return null;
+  const id = typeof node['id'] === 'string' ? (node['id'] as string) : '';
+  if (!id) return null;
+  return { id, seq, row, parts: read, manifest };
+}
+
+/**
+ * Whether the manifest is the shape this reader works on. The types are checked, not only the values:
+ * a `bytes` that is an object compares as smaller than the budget and larger than zero, and would
+ * then be handed to `new Uint8Array`. The Sessionizer's own decoder refuses the same records.
+ */
+function wellFormed(m: PromptManifest | null): boolean {
+  if (!m || typeof m !== 'object') return false;
+  if (m.schema !== PROMPT_SCHEMA || typeof m.sha256 !== 'string' || typeof m.role !== 'string') return false;
+  if (!Number.isSafeInteger(m.bytes) || !Number.isSafeInteger(m.depth)) return false;
+  if (!Array.isArray(m.segments)) return false;
+  for (const seg of m.segments) {
+    if (!seg || typeof seg !== 'object') return false;
+    if (seg.lit !== undefined && typeof seg.lit !== 'string') return false;
+    if (seg.piece !== undefined && typeof seg.piece !== 'string') return false;
+    if (seg.part !== undefined && seg.part !== null && !Number.isSafeInteger(seg.part)) return false;
+    if (seg.copy !== undefined) {
+      const c = seg.copy;
+      if (!c || typeof c !== 'object') return false;
+      if (typeof c.from !== 'string' || typeof c.sha256 !== 'string' || !Number.isSafeInteger(c.len)) return false;
+    }
+  }
+  return true;
+}
+
+/** Why a body could not be rebuilt, in the reader's words. */
+export class PromptError extends Error {}
+
+/**
+ * What happened to one record. `unresolved` is the one that can change: the record refers to another
+ * the session has not read yet, and reading the file it is in makes this record readable.
+ */
+type AddOutcome = 'added' | 'refused' | 'unresolved';
+
+/**
+ * The provider bodies of one session. Records are added in landing order: seq, then row. A record whose
+ * references the session cannot answer is refused, so a body either rebuilds to its digest or says why
+ * it cannot.
+ */
+export class PromptStore {
+  private readonly records = new Map<string, PromptRecord>();
+  private readonly byPosition = new Map<string, PromptRecord>();
+  /** The record and part holding each piece, by the piece's sha256. */
+  private readonly pieces = new Map<string, { id: string; part: number }>();
+  private readonly cache = new Map<string, Uint8Array>();
+  private readonly cacheOrder: string[] = [];
+  private cacheBytes = 0;
+  /** The seqs whose files were added, so a reader knows what it still needs. */
+  readonly loaded = new Set<number>();
+
+  /** Whether the file of that seq was added already. */
+  has(seq: number): boolean {
+    return this.loaded.has(seq);
+  }
+
+  /**
+   * Adds one file's records, in line order. A record already held is left as it is.
+   *
+   * The file counts as held only when nothing in it waited on a record this session does not have.
+   * A body refers to bodies that landed before it, so a file read out of order loses those records;
+   * reading the earlier file later must bring this one back, and it cannot if the file is held.
+   */
+  addFile(file: StoredFile): void {
+    let waiting = false;
+    for (const record of readProviderFile(file.seq, file.bytes)) {
+      if (this.add(record) === 'unresolved') waiting = true;
+    }
+    if (waiting) this.loaded.delete(file.seq);
+    else this.loaded.add(file.seq);
+  }
+
+  /** The manifest of the body at a landed position, or null when it is not held. */
+  manifestAt(seq: number, row: number): PromptManifest | null {
+    return this.byPosition.get(`${seq}/${row}`)?.manifest ?? null;
+  }
+
+  /** The body at a landed position. Throws a PromptError when it is not held or does not rebuild. */
+  bodyAt(seq: number, row: number): Uint8Array {
+    const record = this.byPosition.get(`${seq}/${row}`);
+    if (!record) throw new PromptError(`no body landed at seq ${seq} row ${row}`);
+    return this.body(record.id);
+  }
+
+  /** The body held under a record id. */
+  body(id: string): Uint8Array {
+    const held = this.cache.get(id);
+    if (held) return held;
+    const record = this.records.get(id);
+    if (!record) throw new PromptError(`no body ${id}`);
+    const bytes = this.rebuild(record);
+    this.remember(id, bytes);
+    return bytes;
+  }
+
+  /** The record a response with that provider request id carries, or null. */
+  responseOfRequestId(requestId: string): PromptRecord | null {
+    for (const r of this.records.values()) {
+      if (r.manifest.role === ROLE_RESPONSE && r.manifest.request === requestId) return r;
+    }
+    return null;
+  }
+
+  private add(record: PromptRecord): AddOutcome {
+    const m = record.manifest;
+    if (m.bytes < 0 || m.bytes > MAX_BODY_BYTES || m.depth < 0 || m.depth > MAX_DEPTH) return 'refused';
+    const own = record.parts.length - 1;
+    let copies = 0;
+    for (const seg of m.segments ?? []) {
+      if (seg.part != null) {
+        if (seg.part < 0 || seg.part >= own) return 'refused';
+      } else if (seg.piece) {
+        if (!this.pieces.has(seg.piece)) return 'unresolved';
+      } else if (seg.copy) {
+        copies++;
+        const base = this.records.get(seg.copy.from);
+        if (!base) return 'unresolved';
+        if (base.manifest.sha256 !== seg.copy.sha256 || seg.copy.len < 0 || seg.copy.len > base.manifest.bytes) return 'refused';
+        if (m.depth !== base.manifest.depth + 1 || m.depth > MAX_DEPTH) return 'refused';
+      }
+    }
+    if (copies === 0 && m.depth !== 0) return 'refused';
+    const already = this.records.get(record.id);
+    if (already) {
+      // A record landed twice by an interrupted pass is one body. Both must say the same thing, and
+      // the one arriving now is rebuilt to prove it: a digest it claims is not evidence of itself.
+      if (already.manifest.sha256 !== m.sha256 || already.manifest.bytes !== m.bytes) return 'refused';
+      try {
+        if (sha256Hex(this.rebuild(record)) !== m.sha256) return 'refused';
+      } catch {
+        return 'refused';
+      }
+      this.byPosition.set(`${record.seq}/${record.row}`, already);
+      return 'added';
+    }
+    this.records.set(record.id, record);
+    this.byPosition.set(`${record.seq}/${record.row}`, record);
+    for (let i = 0; i < own; i++) {
+      const part = record.parts[i]!;
+      if (part.kind !== 'data' || part.raw === null) continue;
+      const d = sha256Hex(encoder.encode(part.raw));
+      if (!this.pieces.has(d)) this.pieces.set(d, { id: record.id, part: i });
+    }
+    return 'added';
+  }
+
+  private rebuild(record: PromptRecord): Uint8Array {
+    const m = record.manifest;
+    if (m.bytes < 0 || m.bytes > MAX_BODY_BYTES) throw new PromptError(`${record.id} claims ${m.bytes} bytes`);
+    const out = new Uint8Array(m.bytes);
+    let at = 0;
+    const take = (bytes: Uint8Array): void => {
+      if (at + bytes.length > m.bytes) throw new PromptError(`${record.id} rebuilds past the ${m.bytes} bytes it claims`);
+      out.set(bytes, at);
+      at += bytes.length;
+    };
+    for (const seg of m.segments ?? []) {
+      if (seg.lit) {
+        take(encoder.encode(seg.lit));
+      } else if (seg.part != null) {
+        if (seg.part < 0 || seg.part >= record.parts.length - 1) {
+          throw new PromptError(`${record.id} names part ${seg.part} it does not have`);
+        }
+        take(partBytes(record.parts[seg.part]!));
+      } else if (seg.piece) {
+        const at2 = this.pieces.get(seg.piece);
+        if (!at2) throw new PromptError(`${record.id} names a piece no earlier record holds`);
+        take(partBytes(this.records.get(at2.id)!.parts[at2.part]!));
+      } else if (seg.copy) {
+        const base = this.body(seg.copy.from);
+        if (seg.copy.len < 0 || seg.copy.len > base.length) {
+          throw new PromptError(`${record.id} copies ${seg.copy.len} bytes of a ${base.length} byte body`);
+        }
+        take(base.subarray(0, seg.copy.len));
+      }
+    }
+    if (at !== m.bytes || sha256Hex(out) !== m.sha256) {
+      throw new PromptError(`${record.id} rebuilds to ${at} bytes that do not match its digest`);
+    }
+    return out;
+  }
+
+  private remember(id: string, bytes: Uint8Array): void {
+    this.cache.set(id, bytes);
+    this.cacheOrder.push(id);
+    this.cacheBytes += bytes.length;
+    while (this.cacheBytes > CACHE_LIMIT && this.cacheOrder.length > 1) {
+      const old = this.cacheOrder.shift()!;
+      this.cacheBytes -= this.cache.get(old)?.length ?? 0;
+      this.cache.delete(old);
+    }
+  }
+}
+
+/** A part's bytes: its data as the line spells it, or an unknown part's own bytes. */
+function partBytes(part: Part): Uint8Array {
+  if (part.raw === null) throw new PromptError('a part without data');
+  if (part.kind !== 'unknown') return encoder.encode(part.raw);
+  const text = JSON.parse(part.raw) as unknown;
+  if (typeof text !== 'string') throw new PromptError("an unknown part's data is not one JSON string");
+  if (!part.encoding) return encoder.encode(text);
+  if (part.encoding === 'base64') {
+    const binary = atob(text);
+    const bytes = new Uint8Array(binary.length);
+    for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+    return bytes;
+  }
+  throw new PromptError(`unknown part encoding ${part.encoding}`);
+}
+
+/** The lines of a file, without their newline. A JSON string never holds a raw newline, so a record
+ *  line ends at the first one. */
+function splitLines(bytes: Uint8Array): Uint8Array[] {
+  const out: Uint8Array[] = [];
+  let from = 0;
+  for (let i = 0; i < bytes.length; i++) {
+    if (bytes[i] === 0x0a) {
+      out.push(bytes.subarray(from, i));
+      from = i + 1;
+    }
+  }
+  if (from < bytes.length) out.push(bytes.subarray(from));
+  return out;
+}
+
+/**
+ * The raw `data` text of each element of a record line's `parts` array, in order, null for a part
+ * without one. A walk over the line's syntax, not a parse: it needs only where each value starts and
+ * ends, so the bytes a part carries reach the body unchanged.
+ */
+export function rawPartData(line: string): Array<string | null> {
+  let pos = 0;
+  const peek = (): string => line.charAt(pos);
+  const skipSpace = (): void => {
+    while (pos < line.length && ' \t\r\n'.includes(line.charAt(pos))) pos++;
+  };
+  const expect = (c: string): void => {
+    if (line.charAt(pos) !== c) throw new PromptError(`expected ${c} at ${pos}`);
+    pos++;
+  };
+  const string = (): string => {
+    expect('"');
+    let out = '';
+    for (;;) {
+      const c = line.charAt(pos++);
+      if (c === '"') return out;
+      if (c === '\\') {
+        const e = line.charAt(pos++);
+        if (e === 'u') {
+          out += String.fromCharCode(parseInt(line.slice(pos, pos + 4), 16));
+          pos += 4;
+        } else {
+          out += e;
+        }
+      } else {
+        out += c;
+      }
+    }
+  };
+  const skipValue = (): void => {
+    const c = peek();
+    if (c === '"') {
+      string();
+    } else if (c === '{' || c === '[') {
+      const close = c === '{' ? '}' : ']';
+      pos++;
+      for (;;) {
+        skipSpace();
+        if (peek() === close) {
+          pos++;
+          return;
+        }
+        if (c === '{') {
+          string();
+          skipSpace();
+          expect(':');
+          skipSpace();
+        }
+        skipValue();
+        skipSpace();
+        if (peek() === ',') pos++;
+      }
+    } else {
+      while (pos < line.length && !',}] \t\r\n'.includes(line.charAt(pos))) pos++;
+    }
+  };
+  const partDataOf = (): string | null => {
+    let data: string | null = null;
+    expect('{');
+    for (;;) {
+      skipSpace();
+      if (peek() === '}') {
+        pos++;
+        return data;
+      }
+      const key = string();
+      skipSpace();
+      expect(':');
+      skipSpace();
+      const start = pos;
+      skipValue();
+      if (key === 'data') data = line.slice(start, pos);
+      skipSpace();
+      if (peek() === ',') pos++;
+    }
+  };
+  const out: Array<string | null> = [];
+  try {
+    skipSpace();
+    expect('{');
+    for (;;) {
+      skipSpace();
+      if (peek() === '}') return out;
+      const key = string();
+      skipSpace();
+      expect(':');
+      skipSpace();
+      if (key === 'parts' && peek() === '[') {
+        pos++;
+        for (;;) {
+          skipSpace();
+          if (peek() === ']') {
+            pos++;
+            break;
+          }
+          out.push(partDataOf());
+          skipSpace();
+          if (peek() === ',') pos++;
+        }
+      } else {
+        skipValue();
+      }
+      skipSpace();
+      if (peek() === ',') pos++;
+    }
+  } catch {
+    return out;
+  }
+}
diff --git a/packages/conversation-view/src/strings.ts b/packages/conversation-view/src/strings.ts
index 8b7712f6..5d40ab4c 100644
--- a/packages/conversation-view/src/strings.ts
+++ b/packages/conversation-view/src/strings.ts
@@ -234,6 +234,48 @@ export interface ViewStrings {
   // Workspace changes: the tool card's pill, the inline details, the
   // inspector tab and the conversation-level panel
   changes: string;
+  // The prompt of a call: the bodies it exchanged with the model provider.
+  prompt: string;
+  promptNotHere: string;
+  promptBothStored: string;
+  promptRequestStored: string;
+  promptResponseStored: string;
+  promptLoadCost: string;
+  promptLoad: string;
+  promptLoading: string;
+  promptReading: string;
+  promptLoadFailed: string;
+  promptFilesMissing: string;
+  promptSides: string;
+  promptRequest: string;
+  promptResponse: string;
+  promptNoRequest: string;
+  promptNoResponse: string;
+  promptRebuildFailed: string;
+  promptRequestModes: string;
+  promptWholeRequest: string;
+  promptWholeBody: string;
+  promptChanges: string;
+  promptNoPrevious: string;
+  promptSystem: string;
+  promptTools: string;
+  promptMessages: string;
+  promptMessagesNote: string;
+  promptSettings: string;
+  promptBlocks: string;
+  promptShared: string;
+  promptRewritten: string;
+  promptAlsoChanged: string;
+  promptAdded: string;
+  promptNothingAdded: string;
+  promptStopReason: string;
+  promptThinking: string;
+  promptReminder: string;
+  promptToolUse: string;
+  promptToolResult: string;
+  promptToolFailed: string;
+  promptToolFor: string;
+  promptUnknownRole: string;
   changesBadge: string;
   oneFileChanged: string;
   filesChanged: string;
@@ -496,6 +538,47 @@ export const ENGLISH: ViewStrings = {
   close: 'Close',
   whatDoesMean: 'What does {key} mean?',
   changes: 'Changes',
+  prompt: 'Prompt',
+  promptNotHere: 'The bodies of this call are not among the files this document lists.',
+  promptBothStored: 'The request this call sent and the response it received are stored.',
+  promptRequestStored: 'The request this call sent is stored.',
+  promptResponseStored: 'The response this call received is stored.',
+  promptLoadCost: 'Reading them loads {files} file(s) of this session, {bytes} bytes as stored.',
+  promptLoad: 'Load the prompt',
+  promptLoading: '{loaded} of {total} files',
+  promptReading: 'Reading…',
+  promptLoadFailed: 'The files could not be read: {why}',
+  promptFilesMissing: 'These files of the session are not stored, so a body that needs them cannot be rebuilt: seq {seqs}.',
+  promptSides: 'Which side of the call to show',
+  promptRequest: 'Request',
+  promptResponse: 'Response',
+  promptNoRequest: 'The request of this call was not captured.',
+  promptNoResponse: 'The response of this call was not captured.',
+  promptRebuildFailed: 'This body did not come back whole.',
+  promptRequestModes: 'How much of the request to show',
+  promptWholeRequest: 'Whole request',
+  promptWholeBody: 'The body',
+  promptChanges: 'What it added',
+  promptNoPrevious: 'The request before this one is not among the loaded bodies, so there is nothing to compare with.',
+  promptSystem: 'System prompt',
+  promptTools: 'Tools ({count})',
+  promptMessages: 'Messages ({count})',
+  promptMessagesNote: '{bytes} bytes as sent',
+  promptSettings: 'Settings',
+  promptBlocks: '{count} block(s), {bytes} bytes',
+  promptShared: 'The {count} message(s) before are as the call before sent them, {bytes} bytes.',
+  promptRewritten: 'The history was rewritten, which is what a compaction does.',
+  promptAlsoChanged: 'Also changed: {what}.',
+  promptAdded: 'Added by this call ({count})',
+  promptNothingAdded: 'This request adds no message.',
+  promptStopReason: 'stopped: {reason}',
+  promptThinking: 'Thinking',
+  promptReminder: 'Injected by the runtime',
+  promptToolUse: 'Tool use: {name}',
+  promptToolResult: 'Tool result',
+  promptToolFailed: 'Tool result, which failed',
+  promptToolFor: 'for {id}',
+  promptUnknownRole: 'message',
   changesBadge: '{n} changes',
   oneFileChanged: '1 file',
   filesChanged: '{n} files',
diff --git a/packages/conversation-view/src/styles.css b/packages/conversation-view/src/styles.css
index 26969c00..2d3cf2ea 100644
--- a/packages/conversation-view/src/styles.css
+++ b/packages/conversation-view/src/styles.css
@@ -483,3 +483,25 @@ select.acv-stream-btn { border: 1px solid var(--acv-line); background: var(--acv
 .acv-panel-entry { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 3px 0 0 60px; color: var(--acv-muted); font-size: 10.5px; }
 .acv-to-changes { display: block; margin-top: 10px; }
 .acv-hashes { display: block; margin-top: 2px; font-family: var(--acv-mono); font-size: 9.5px; color: var(--acv-faint); overflow-wrap: anywhere; }
+
+/* The prompt of a call: the bodies it exchanged with the model provider. The panel reuses the
+   inspector's blocks and chips; these are the few shapes it adds. */
+.acv-prompt-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
+.acv-prompt-ids { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.acv-prompt-modes { display: flex; gap: 6px; }
+.acv-prompt-offer { display: grid; gap: 8px; justify-items: start; padding: 12px 0; }
+.acv-prompt-progress:empty { display: none; }
+.acv-prompt-section { margin: 10px 0; border: 1px solid var(--acv-line); border-radius: 9px; overflow: hidden; }
+.acv-prompt-head-btn { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 11px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
+.acv-prompt-head-btn:hover { background: color-mix(in srgb, var(--sw-info) 4%, transparent); }
+.acv-prompt-caret { margin-left: auto; color: var(--acv-faint); }
+.acv-prompt-body { padding: 4px 11px 11px; }
+.acv-prompt-message { margin: 8px 0; padding-left: 10px; border-left: 2px solid var(--acv-line); }
+.acv-prompt-role { margin-bottom: 4px; color: var(--acv-faint); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
+.acv-prompt-block { margin: 6px 0; }
+.acv-prompt-block.failed { padding-left: 8px; border-left: 2px solid color-mix(in srgb, var(--sw-warn) 70%, transparent); }
+.acv-prompt-block.failed > .acv-kicker { color: var(--sw-warn); }
+.acv-prompt-tool { margin: 6px 0; padding: 6px 0; border-bottom: 1px solid var(--acv-line); }
+.acv-prompt-tool:last-child { border-bottom: 0; }
+.acv-prompt-tool-name { font-weight: 600; }
+.acv-prompt-facts { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
diff --git a/packages/conversation-view/src/types.ts b/packages/conversation-view/src/types.ts
index e7c3b7c2..be0d301c 100644
--- a/packages/conversation-view/src/types.ts
+++ b/packages/conversation-view/src/types.ts
@@ -41,6 +41,14 @@ export interface AszDrop {
   why?: string;
 }
 
+/** One provider body of a call: which side it is, and where it landed. The
+ *  body itself is not in the document; a reader loads it from those files. */
+export interface AszProviderBody {
+  /** `request` or `response`. */
+  role: string;
+  ref: AszRef;
+}
+
 export interface AszEdge {
   type: string;
   other: string;
@@ -94,6 +102,9 @@ export interface AszNode {
    *  `workspace_changes` lists them. Not unique across producers: the join
    *  is `WorkspaceChange.step`, this list only says there are some. */
   changes?: string[];
+  /** Where this call's request and response landed, when they were captured.
+   *  A call may list one of them, both, or neither. */
+  provider_bodies?: AszProviderBody[];
   children?: AszNode[];
   edges?: AszEdge[];
 }
@@ -276,6 +287,10 @@ export interface AszSummary {
   /** Workspace change RECORDS, skipped and empty observations included.
    *  Absent from a document written before the records existed. */
   changes?: number;
+  /** The session's landed provider bodies, and the calls whose request is
+   *  among them. Absent from a document written before they existed. */
+  provider_bodies?: number;
+  captured_prompts?: number;
   from: number;
   to: number;
   kinds: Record<string, number>;
diff --git a/packages/conversation-view/src/view/context.ts b/packages/conversation-view/src/view/context.ts
index 06e4c953..6a765259 100644
--- a/packages/conversation-view/src/view/context.ts
+++ b/packages/conversation-view/src/view/context.ts
@@ -19,8 +19,10 @@ import type { ConversationModel, TalkRow } from '../model.js';
 import type { ViewStrings } from '../strings.js';
 import type { TimeFormatter } from '../format.js';
 import type { AszRef, Glossary, LandedRecord } from '../types.js';
+import type { PromptCache } from '../prompt/cache.js';
+import type { StoredFile } from '../prompt/store.js';
 
-export type InspectorTab = 'details' | 'relations' | 'evidence' | 'changes';
+export type InspectorTab = 'details' | 'relations' | 'evidence' | 'changes' | 'prompt';
 
 /** What the reader is looking at. The three fields a host round-trips through
  *  its URL are the talk, the selected step and the stream; the rest is local. */
@@ -55,6 +57,12 @@ export interface ViewState {
   openTexts: Set<string>;
   /** The inspector drawn as a wide panel over the workbench rather than at its side. */
   inspectorPopped: boolean;
+  /** Which side of a call's prompt is shown, and whether a request is shown whole
+   *  or as what it added. Both are remembered as the reader moves between calls. */
+  promptSide: 'request' | 'response';
+  promptWhole: boolean;
+  /** Prompt sections opened past their heading, keyed `<step>|<section>`. */
+  openPromptSections: Set<string>;
 }
 
 /** Everything a view module needs: the model, the words, the state, and the
@@ -66,6 +74,13 @@ export interface ViewContext {
   f: TimeFormatter;
   glossary: Glossary | null;
   loadRecord?: (ref: AszRef) => Promise<LandedRecord>;
+  /** Reads a session's stored files by seq; see MountOptions.loadFiles. */
+  loadFiles?: (
+    request: { session: string; seqs: number[]; signal: AbortSignal },
+    onFile: (file: StoredFile) => void,
+  ) => Promise<void>;
+  /** The provider bodies loaded so far, by session, and what is being read. */
+  prompts: PromptCache;
   state: ViewState;
   q<T extends Element = HTMLElement>(selector: string): T;
 
diff --git a/packages/conversation-view/src/view/inspector.ts b/packages/conversation-view/src/view/inspector.ts
index c6ca4890..45d7ab12 100644
--- a/packages/conversation-view/src/view/inspector.ts
+++ b/packages/conversation-view/src/view/inspector.ts
@@ -29,7 +29,8 @@ import { fill } from '../strings.js';
 import type { AszRef, LandedRecord } from '../types.js';
 import { kindTitle } from '../vocabulary.js';
 import { drawChangesTab } from './changes.js';
-import { streamName, type ViewContext } from './context.js';
+import { drawPrompt, hasPrompt } from './prompt.js';
+import { streamName, type InspectorTab, type ViewContext } from './context.js';
 import { clipNote, copyButton, copyField, textBody } from './structured.js';
 
 function field(dt: string, dd: string, mono = false): string {
@@ -46,17 +47,24 @@ export function drawInspector(ctx: ViewContext): void {
   const relTab = ctx.q<HTMLButtonElement>('[data-tab="relations"]');
   const hasRels = !!(e && e.edges.length);
   relTab.hidden = !hasRels;
-  if (!hasRels && state.tab === 'relations') {
-    ctx.showTab('details');
-    return;
-  }
+  const promptTab = ctx.q<HTMLButtonElement>('[data-tab="prompt"]');
+  const prompts = hasPrompt(ctx, e);
+  promptTab.hidden = !prompts;
   const changesTab = ctx.q<HTMLButtonElement>('[data-tab="changes"]');
   const hasChanges = !!(e && m.changesOf(e.id).length);
   changesTab.hidden = !hasChanges;
-  if (!hasChanges && state.tab === 'changes') {
-    ctx.showTab('details');
-    return;
-  }
+  // What the reader chose stays chosen. A step this tab has nothing for is drawn as details, and the
+  // choice comes back on the next step that has it, so walking a run does not drop the reader out of
+  // the prompt or the relations every time a step in between has none.
+  const offered: Record<InspectorTab, boolean> = {
+    details: true,
+    evidence: true,
+    relations: hasRels,
+    changes: hasChanges,
+    prompt: prompts,
+  };
+  const tab: InspectorTab = offered[state.tab] ? state.tab : 'details';
+  ctx.root.querySelectorAll<HTMLElement>('[data-tab]').forEach((t) => t.setAttribute('aria-selected', String(t.dataset.tab === tab)));
   const title = ctx.q('.acv-inspector-title');
   const meta = ctx.q('.acv-inspector-meta');
   const body = ctx.q('.acv-inspector-body');
@@ -68,9 +76,10 @@ export function drawInspector(ctx: ViewContext): void {
   }
   title.textContent = e.name ? `${e.kind} · ${e.name}` : kindTitle(e.kind, s);
   meta.textContent = `${e.stream.slice(0, 12)} · ${f.time(e.at)}${e.bytes ? ` · ${f.number(e.bytes)} B` : ''}`;
-  if (state.tab === 'details') drawDetails(ctx, body, e);
-  else if (state.tab === 'relations') drawRelations(ctx, body, e);
-  else if (state.tab === 'changes') drawChangesTab(ctx, body, e);
+  if (tab === 'details') drawDetails(ctx, body, e);
+  else if (tab === 'relations') drawRelations(ctx, body, e);
+  else if (tab === 'changes') drawChangesTab(ctx, body, e);
+  else if (tab === 'prompt') drawPrompt(ctx, body, e);
   else void drawEvidence(ctx, body, e);
 }
 
diff --git a/packages/conversation-view/src/view/prompt.ts b/packages/conversation-view/src/view/prompt.ts
new file mode 100644
index 00000000..beb9c912
--- /dev/null
+++ b/packages/conversation-view/src/view/prompt.ts
@@ -0,0 +1,437 @@
+/*
+ * 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 prompt of one call: what was sent to the model provider, and what came back.
+ *
+ * The document says only where the bodies landed. This tab loads the session's provider files up to
+ * that position when the reader asks, rebuilds the two bodies, and shows them: the request whole or as
+ * what it added to the call before it, and the response plainly. Nothing is loaded before the reader
+ * presses the button, and what is loaded is kept, so the next call on the same session costs only the
+ * files that landed after it.
+ */
+
+import { esc } from '../dom.js';
+import type { Step } from '../model.js';
+import { fill } from '../strings.js';
+import type { AszProviderBody, AszRef } from '../types.js';
+import {
+  deltaOf,
+  readBody,
+  type PromptBlock,
+  type PromptDelta,
+  type PromptMessage,
+  type ReadBody,
+  type ReadRequest,
+  type ReadResponse,
+} from '../prompt/messages.js';
+import { PromptError, ROLE_REQUEST, ROLE_RESPONSE, type PromptManifest } from '../prompt/store.js';
+import type { ViewContext } from './context.js';
+import { copyButton, copyField } from './structured.js';
+
+/** Seqs asked for in one request, the OAP route's own limit. */
+const BATCH = 32;
+/** Text shown before a reader opens a block, in characters. */
+const PREVIEW = 600;
+
+/** The provider bodies of a step, as the document lists them. */
+export function bodiesOf(e: Step | null): AszProviderBody[] {
+  return e?.providerBodies ?? [];
+}
+
+/** Whether the tab has anything to show: the host can read files and the call points at some. */
+export function hasPrompt(ctx: ViewContext, e: Step | null): boolean {
+  return !!ctx.loadFiles && bodiesOf(e).length > 0;
+}
+
+/** The session a body belongs to, and the provider files to load for it: every provider file of that
+ *  session up to the body's own, since a body refers to what landed before it. */
+function plan(ctx: ViewContext, ref: AszRef): { session: string; seqs: number[]; bytes: number } | null {
+  const files = ctx.model.doc.files ?? [];
+  const own = files.find((f) => f.kind === 'provider_body' && f.seq === ref.seq);
+  if (!own) return null;
+  const session = own.file.split('/')[0] ?? '';
+  const earlier = files.filter((f) => f.kind === 'provider_body' && f.seq !== null && f.seq <= ref.seq && f.file.startsWith(`${session}/`));
+  return {
+    session,
+    seqs: earlier.map((f) => f.seq!).sort((a, b) => a - b),
+    bytes: earlier.reduce((n, f) => n + f.bytes, 0),
+  };
+}
+
+/** What the tab draws from: the two sides a call lists, and the files they need. */
+interface Sides {
+  request?: AszRef;
+  response?: AszRef;
+  session: string;
+  seqs: number[];
+  bytes: number;
+}
+
+function sidesOf(ctx: ViewContext, e: Step): Sides | null {
+  const bodies = bodiesOf(e);
+  const request = bodies.find((b) => b.role === ROLE_REQUEST)?.ref;
+  const response = bodies.find((b) => b.role === ROLE_RESPONSE)?.ref;
+  const deepest = bodies.reduce<AszRef | null>((a, b) => (!a || b.ref.seq > a.seq ? b.ref : a), null);
+  if (!deepest) return null;
+  const p = plan(ctx, deepest);
+  if (!p) return null;
+  return { request, response, session: p.session, seqs: p.seqs, bytes: p.bytes };
+}
+
+export function drawPrompt(ctx: ViewContext, body: HTMLElement, e: Step): void {
+  const { s, f, state } = ctx;
+  const sides = sidesOf(ctx, e);
+  if (!sides) {
+    body.innerHTML = `<div class="acv-empty">${esc(s.promptNotHere)}</div>`;
+    return;
+  }
+  const store = ctx.prompts.store(sides.session);
+  const absent = sides.seqs.filter((seq) => !store.has(seq));
+  const outcome = ctx.prompts.outcome(sides.session);
+  // Nothing is read until the reader asks. A read that ended in an error brings the offer back with
+  // what went wrong, so it can be tried again. A read the server answered without some of the files
+  // does not: asking again brings the same answer, and the panel draws the bodies that did rebuild
+  // over a note naming the files that are not there.
+  if (absent.length && (!outcome || outcome.failed)) {
+    drawUnloaded(ctx, body, sides, outcome?.failed);
+    return;
+  }
+  // The side the reader last chose, when this call has it.
+  if (state.promptSide === 'request' && !sides.request) state.promptSide = 'response';
+  if (state.promptSide === 'response' && !sides.response) state.promptSide = 'request';
+  const ref = state.promptSide === 'request' ? sides.request : sides.response;
+  const role = state.promptSide === 'request' ? ROLE_REQUEST : ROLE_RESPONSE;
+
+  let read: ReadBody | null = null;
+  let manifest: PromptManifest | null = null;
+  let failure = '';
+  let bytes = 0;
+  if (ref) {
+    manifest = store.manifestAt(ref.seq, ref.row);
+    try {
+      const raw = store.bodyAt(ref.seq, ref.row);
+      bytes = raw.length;
+      read = readBody(raw, role);
+    } catch (err) {
+      failure = err instanceof PromptError ? err.message : String(err);
+    }
+  }
+
+  const missing = absent.length
+    ? `<div class="acv-warning">${esc(fill(s.promptFilesMissing, { seqs: absent.join(', ') }))}</div>`
+    : '';
+  const head = `
+    <div class="acv-prompt-head">
+      <div class="acv-prompt-ids">
+        ${manifest?.model ? `<span class="acv-source-badge">${esc(manifest.model)}</span>` : ''}
+        ${ref ? `<span class="acv-faint mono">seq ${ref.seq} · row ${ref.row}</span>` : ''}
+        ${bytes ? `<span class="acv-faint">${esc(f.number(bytes))} B</span>` : ''}
+      </div>
+      <div class="acv-prompt-modes" role="group" aria-label="${esc(s.promptSides)}">
+        ${sides.request ? sideButton(ctx, 'request', s.promptRequest) : ''}
+        ${sides.response ? sideButton(ctx, 'response', s.promptResponse) : ''}
+      </div>
+    </div>`;
+
+  let panel: string;
+  if (!ref) {
+    panel = `<div class="acv-empty">${esc(state.promptSide === 'request' ? s.promptNoRequest : s.promptNoResponse)}</div>`;
+  } else if (failure) {
+    panel = `<div class="acv-warning">${esc(s.promptRebuildFailed)}<br>${esc(failure)}</div>`;
+  } else if (!read) {
+    panel = `<div class="acv-warning">${esc(s.promptRebuildFailed)}</div>`;
+  } else if (read.kind === 'request') {
+    panel = drawRequest(ctx, e, read, sides, store);
+  } else if (read.kind === 'response') {
+    panel = drawResponse(ctx, read);
+  } else if (read.text != null) {
+    // the body rebuilt and matched its digest but is not JSON — a provider's plain error, say. It is
+    // shown as the text it is; nothing about it is known beyond that.
+    panel = section(ctx, e, 'raw', s.promptWholeBody, '', block(ctx, { kind: 'text', text: read.text }), true);
+  } else {
+    panel = section(ctx, e, 'raw', s.promptWholeBody, '', json(ctx, read.raw), true);
+  }
+
+  body.innerHTML = `${missing}${head}${panel}`;
+  wire(ctx, body);
+}
+
+function sideButton(ctx: ViewContext, side: 'request' | 'response', label: string): string {
+  const on = ctx.state.promptSide === side;
+  return `<button type="button" class="acv-chip${on ? ' on' : ''}" data-prompt-side="${side}" aria-pressed="${on}">${esc(label)}</button>`;
+}
+
+/** Before anything is read: what is there, what loading it costs, and one button. */
+function drawUnloaded(ctx: ViewContext, body: HTMLElement, sides: Sides, failed?: string): void {
+  const { s, f } = ctx;
+  const store = ctx.prompts.store(sides.session);
+  const missing = sides.seqs.filter((seq) => !store.has(seq));
+  // A read of this session started from another call is this call's read too: both wait for the same
+  // files. The button says so rather than starting a second read of the same bytes.
+  const reading = ctx.prompts.reading(sides.session);
+  body.innerHTML = `
+    <div class="acv-prompt-offer">
+      <div>${esc(sides.request && sides.response ? s.promptBothStored : sides.request ? s.promptRequestStored : s.promptResponseStored)}</div>
+      <div class="acv-faint">${esc(fill(s.promptLoadCost, { files: String(missing.length), bytes: f.number(sides.bytes) }))}</div>
+      <div class="acv-prompt-progress">${
+        failed ? `<span class="acv-warning">${esc(fill(s.promptLoadFailed, { why: failed }))}</span>` : ''
+      }</div>
+      <button type="button" class="acv-btn" data-load-prompt ${reading ? 'disabled' : ''}>${esc(reading ? s.promptReading : s.promptLoad)}</button>
+    </div>`;
+  const button = body.querySelector<HTMLButtonElement>('[data-load-prompt]');
+  const progress = body.querySelector<HTMLElement>('.acv-prompt-progress')!;
+  if (!button || !ctx.loadFiles) return;
+  button.onclick = async () => {
+    button.disabled = true;
+    progress.textContent = fill(ctx.s.promptLoading, { loaded: '0', total: String(missing.length) });
+    try {
+      await ctx.prompts.load(sides.session, sides.seqs, ctx.loadFiles!, BATCH, (loaded, total) => {
+        if (progress.isConnected) progress.textContent = fill(ctx.s.promptLoading, { loaded: String(loaded), total: String(total) });
+      });
+    } catch {
+      return; // the reader moved on and the read was ended
+    }
+    // The panel is drawn again whatever happened, and from the state the read left behind: the reader
+    // may have moved to another call while it ran, and the offer this handler drew is gone with it.
+    ctx.drawInspector();
+  };
+}
+
+function drawRequest(ctx: ViewContext, e: Step, read: ReadRequest, sides: Sides, store: ReturnType<ViewContext['prompts']['store']>): string {
+  const { s, f, state } = ctx;
+  const previous = state.promptWhole ? null : previousRequest(ctx, e, sides, store);
+  const modes = `
+    <div class="acv-prompt-modes" role="group" aria-label="${esc(s.promptRequestModes)}">
+      <button type="button" class="acv-chip${state.promptWhole ? ' on' : ''}" data-prompt-whole="1" aria-pressed="${state.promptWhole}">${esc(s.promptWholeRequest)}</button>
+      <button type="button" class="acv-chip${state.promptWhole ? '' : ' on'}" data-prompt-whole="0" aria-pressed="${!state.promptWhole}">${esc(s.promptChanges)}</button>
+    </div>`;
+  if (!state.promptWhole) {
+    if (!previous) return `${modes}<div class="acv-empty">${esc(s.promptNoPrevious)}</div>`;
+    return `${modes}${drawDelta(ctx, deltaOf(previous, read), read)}`;
+  }
+  const system = read.system.length ? section(ctx, e, 'system', s.promptSystem, blockSummary(ctx, read.system), read.system.map((b) => block(ctx, b)).join('')) : '';
+  const tools = read.tools.length
+    ? section(
+        ctx,
+        e,
+        'tools',
+        fill(s.promptTools, { count: String(read.tools.length) }),
+        read.tools
+          .slice(0, 3)
+          .map((t) => t.name)
+          .join(', '),
+        read.tools.map((t) => tool(ctx, t)).join(''),
+      )
+    : '';
+  const messages = section(
+    ctx,
+    e,
+    'messages',
+    fill(s.promptMessages, { count: String(read.messages.length) }),
+    fill(s.promptMessagesNote, { bytes: f.number(new TextEncoder().encode(JSON.stringify(read.raw['messages'])).length) }),
+    read.messages.map((m) => message(ctx, m)).join(''),
+    true,
+  );
+  const settings = section(ctx, e, 'settings', s.promptSettings, Object.keys(read.rest).join(', '), json(ctx, read.rest));
+  return `${modes}${system}${tools}${messages}${settings}`;
+}
+
+/** The request of the call before this one in the same chain, found by the ids the bodies carry:
+ *  this request names the request before it, the response of that request names its call, and the
+ *  document says where that call's request landed. A chain that cannot be followed gives nothing,
+ *  because a copy segment is storage, not a relationship. */
+function previousRequest(
+  ctx: ViewContext,
+  e: Step,
+  sides: Sides,
+  store: ReturnType<ViewContext['prompts']['store']>,
+): ReadRequest | null {
+  if (!sides.request) return null;
+  const mine = store.manifestAt(sides.request.seq, sides.request.row);
+  if (!mine?.previous_request) return null;
+  const response = store.responseOfRequestId(mine.previous_request);
+  if (!response?.manifest.call) return null;
+  const call = response.manifest.call;
+  for (const step of ctx.model.stepById.values()) {
+    const bodies = bodiesOf(step);
+    const theirResponse = bodies.find((b) => b.role === ROLE_RESPONSE)?.ref;
+    const theirRequest = bodies.find((b) => b.role === ROLE_REQUEST)?.ref;
+    if (!theirResponse || !theirRequest || step.id === e.id) continue;
+    if (store.manifestAt(theirResponse.seq, theirResponse.row)?.call !== call) continue;
+    try {
+      const read = readBody(store.bodyAt(theirRequest.seq, theirRequest.row), ROLE_REQUEST);
+      return read.kind === 'request' ? read : null;
+    } catch {
+      return null;
+    }
+  }
+  return null;
+}
+
+function drawDelta(ctx: ViewContext, delta: PromptDelta, read: ReadRequest): string {
+  const { s, f } = ctx;
+  const changed = [
+    delta.systemChanged ? s.promptSystem : '',
+    delta.toolsChanged ? fill(s.promptTools, { count: String(read.tools.length) }) : '',
+    delta.settingsChanged ? s.promptSettings : '',
+  ].filter(Boolean);
+  const notes = [
+    `<div class="acv-faint">${esc(fill(s.promptShared, { count: String(delta.sharedMessages), bytes: f.number(delta.sharedBytes) }))}</div>`,
+    delta.rewritten ? `<div class="acv-warning">${esc(s.promptRewritten)}</div>` : '',
+    changed.length ? `<div class="acv-faint">${esc(fill(s.promptAlsoChanged, { what: changed.join(', ') }))}</div>` : '',
+  ].join('');
+  const added = delta.added.length
+    ? delta.added.map((m) => message(ctx, m)).join('')
+    : `<div class="acv-empty">${esc(s.promptNothingAdded)}</div>`;
+  return `${notes}<div class="acv-kicker" style="margin-top:12px">${esc(fill(s.promptAdded, { count: String(delta.added.length) }))}</div>${added}`;
+}
+
+function drawResponse(ctx: ViewContext, read: ReadResponse): string {
+  const { s } = ctx;
+  const facts = [
+    read.stopReason ? `<span class="acv-source-badge">${esc(fill(s.promptStopReason, { reason: read.stopReason }))}</span>` : '',
+    read.id ? `<span class="acv-faint mono">${esc(read.id)}</span>` : '',
+  ].join(' ');
+  const usage = read.usage
+    ? `<div class="acv-provenance">${Object.entries(read.usage)
+        .filter(([, v]) => typeof v === 'number')
+        .map(([k, v]) => `<span class="acv-source-badge">${esc(k)} ${esc(String(v))}</span>`)
+        .join('')}</div>`
+    : '';
+  return `<div class="acv-prompt-facts">${facts}</div>${usage}${read.blocks.map((b) => block(ctx, b)).join('')}`;
+}
+
+/** One collapsible section, remembered per step so a reader's choice survives redraws. */
+function section(ctx: ViewContext, e: Step, key: string, title: string, note: string, inner: string, openByDefault = false): string {
+  const id = `${e.id}|${key}`;
+  const open = ctx.state.openPromptSections.has(id) || (openByDefault && !ctx.state.openPromptSections.has(`-${id}`));
+  return `
+    <section class="acv-prompt-section">
+      <button type="button" class="acv-prompt-head-btn" data-prompt-section="${esc(id)}" aria-expanded="${open}">
+        <span class="acv-kicker">${esc(title)}</span>
+        ${note ? `<span class="acv-faint">${esc(note)}</span>` : ''}
+        <span class="acv-prompt-caret">${open ? '▾' : '▸'}</span>
+      </button>
+      ${open ? `<div class="acv-prompt-body">${inner}</div>` : ''}
+    </section>`;
+}
+
+function message(ctx: ViewContext, m: PromptMessage): string {
+  return `
+    <div class="acv-prompt-message">
+      <div class="acv-prompt-role">${esc(m.role || ctx.s.promptUnknownRole)}</div>
+      ${m.blocks.map((b) => block(ctx, b)).join('')}
+    </div>`;
+}
+
+function block(ctx: ViewContext, b: PromptBlock): string {
+  const { s } = ctx;
+  if (b.kind === 'text' || b.kind === 'thinking') {
+    const text = b.text ?? '';
+    const label = b.kind === 'thinking' ? s.promptThinking : b.reminder ? s.promptReminder : '';
+    return `
+      <div class="acv-prompt-block">
+        ${label ? `<div class="acv-kicker">${esc(label)}</div>` : ''}
+        <div class="acv-block" data-copy-scope>${esc(clip(text))}${copyButton(s)}<span class="acv-copy-src" hidden>${esc(text)}</span></div>
+      </div>`;
+  }
+  if (b.kind === 'tool_use') {
+    return `
+      <div class="acv-prompt-block">
+        <div class="acv-kicker">${esc(fill(s.promptToolUse, { name: b.name ?? '' }))}${blockId(ctx, b)}</div>
+        ${json(ctx, b.json)}
+      </div>`;
+  }
+  if (b.kind === 'tool_result') {
+    // a result that failed and one that did not can carry the same text, and the panel must not make
+    // them look alike
+    return `
+      <div class="acv-prompt-block${b.failed ? ' failed' : ''}">
+        <div class="acv-kicker">${esc(b.failed ? s.promptToolFailed : s.promptToolResult)}${blockId(ctx, b)}</div>
+        ${b.text != null ? `<div class="acv-block" data-copy-scope>${esc(clip(b.text))}${copyButton(s)}<span class="acv-copy-src" hidden>${esc(b.text)}</span></div>` : json(ctx, b.json)}
+      </div>`;
+  }
+  return `<div class="acv-prompt-block"><div class="acv-kicker">${esc(b.kind)}</div>${json(ctx, b.json)}</div>`;
+}
+
+/** The tool call a block is: its own id, or the one a result answers. */
+function blockId(ctx: ViewContext, b: PromptBlock): string {
+  return b.id ? ` <span class="acv-faint mono">${esc(fill(ctx.s.promptToolFor, { id: b.id }))}</span>` : '';
+}
+
+function tool(ctx: ViewContext, t: { name: string; description: string; raw: unknown }): string {
+  return `
+    <div class="acv-prompt-tool">
+      <div class="acv-prompt-tool-name mono">${esc(t.name)}</div>
+      <div class="acv-faint">${esc(clip(t.description.split('\n')[0] ?? '', 200))}</div>
+      ${json(ctx, t.raw)}
+    </div>`;
+}
+
+function blockSummary(ctx: ViewContext, blocks: PromptBlock[]): string {
+  const bytes = new TextEncoder().encode(blocks.map((b) => b.text ?? '').join('')).length;
+  return fill(ctx.s.promptBlocks, { count: String(blocks.length), bytes: ctx.f.number(bytes) });
+}
+
+function json(ctx: ViewContext, value: unknown): string {
+  const text = JSON.stringify(value, null, 2) ?? '';
+  return `<pre class="acv-raw" data-copy-scope>${esc(clip(text, 4000))}${copyButton(ctx.s)}<span class="acv-copy-src" hidden>${esc(text)}</span></pre>`;
+}
+
+function clip(text: string, at = PREVIEW): string {
+  return text.length > at ? `${text.slice(0, at)}…` : text;
+}
+
+function wire(ctx: ViewContext, body: HTMLElement): void {
+  body.querySelectorAll<HTMLElement>('[data-prompt-side]').forEach((b) => {
+    b.onclick = () => {
+      ctx.state.promptSide = b.dataset.promptSide as 'request' | 'response';
+      ctx.drawInspector();
+    };
+  });
+  body.querySelectorAll<HTMLElement>('[data-prompt-whole]').forEach((b) => {
+    b.onclick = () => {
+      ctx.state.promptWhole = b.dataset.promptWhole === '1';
+      ctx.drawInspector();
+    };
+  });
+  body.querySelectorAll<HTMLElement>('[data-prompt-section]').forEach((b) => {
+    b.onclick = () => {
+      const id = b.dataset.promptSection!;
+      const open = b.getAttribute('aria-expanded') === 'true';
+      if (open) {
+        ctx.state.openPromptSections.delete(id);
+        ctx.state.openPromptSections.add(`-${id}`);
+      } else {
+        ctx.state.openPromptSections.delete(`-${id}`);
+        ctx.state.openPromptSections.add(id);
+      }
+      ctx.drawInspector();
+    };
+  });
+  // Copy takes the whole text, never the clipped preview the panel shows: every block holds its text
+  // hidden beside the button, which is what copyField reads.
+  body.querySelectorAll<HTMLElement>('.acv-copy').forEach((b) => {
+    b.onclick = (ev) => {
+      ev.stopPropagation();
+      copyField(b, ctx.s.copied);
+    };
+  });
+}
diff --git a/packages/conversation-view/test/fixtures/provider-bodies.json b/packages/conversation-view/test/fixtures/provider-bodies.json
new file mode 100644
index 00000000..c4d468ee
--- /dev/null
+++ b/packages/conversation-view/test/fixtures/provider-bodies.json
@@ -0,0 +1,1318 @@
+{
+  "format": "asz.view",
+  "version": "1.0",
+  "conversation": "6b7a6063-8714-4f6b-87cd-6c2da3a5094d",
+  "sessions": [
+    "6b7a6063-8714-4f6b-87cd-6c2da3a5094d"
+  ],
+  "head": {
+    "round": 1,
+    "digest": "ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920"
+  },
+  "parser": "v1",
+  "policy": "v1+idle=10m0s",
+  "summary": {
+    "title": "provider bodies beside the calls",
+    "state": "verified",
+    "problems": [],
+    "talks": 5,
+    "steps": 23,
+    "streams": 2,
+    "segments": 1,
+    "rounds": 1,
+    "unresolved": 0,
+    "changes": 0,
+    "provider_bodies": 14,
+    "captured_prompts": 7,
+    "from": 1767225600000,
+    "to": 1767225608400,
+    "kinds": {
+      "agent.call": 1,
+      "agent.launch_ack": 1,
+      "agent.output": 1,
+      "epoch": 3,
+      "epoch.boundary": 1,
+      "epoch.summary": 1,
+      "llm.call": 7,
+      "message.assistant": 6,
+      "message.external": 3,
+      "run": 5,
+      "segment": 1,
+      "session": 1,
+      "stream": 2,
+      "talk": 5,
+      "tool": 2
+    },
+    "relation_types": {
+      "ends_with": 1,
+      "follows": 1,
+      "in_segment": 5,
+      "starts": 1,
+      "summarizes": 1
+    },
+    "quality": {
+      "exact_unique": 8,
+      "strong_inference": 1
+    }
+  },
+  "rounds": [
+    {
+      "round": 1,
+      "digest": "ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920",
+      "previous": null,
+      "from_seq": 1,
+      "through_seq": 4,
+      "input_digest": "bb774e3f497f8618d3e944070b5d074c0ab6589f0c089db4c2371a99dc4528e3",
+      "from_time": 1767225600000,
+      "through_time": 1767225608400,
+      "verified": true
+    }
+  ],
+  "files": [
+    {
+      "file": "6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/main/transcript-20260101T000000.000000000Z-000001.sd",
+      "format": "sd",
+      "kind": "transcript",
+      "seq": 1,
+      "round": null,
+      "stream": "main",
+      "run": null,
+      "lines": 16,
+      "bytes": 5242,
+      "digest": "293e8d9afbcf0746496e84ad5ce6edc5e7ba5a41c49c015e2294566c337b6a9b",
+      "from_time": 1767225600000,
+      "through_time": 1767225608400
+    },
+    {
+      "file": "6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/a02ba01b82410e9e3/transcript-20260101T000000.000000000Z-000002.sd",
+      "format": "sd",
+      "kind": "transcript",
+      "seq": 2,
+      "round": null,
+      "stream": "a02ba01b82410e9e3",
+      "run": null,
+      "lines": 7,
+      "bytes": 2220,
+      "digest": "b86dbc3bb6b8aaaaa5beaaa4b2226a63ba442fdc0dafa3d0ffe5fd10d941445d",
+      "from_time": 1767225605400,
+      "through_time": 1767225607800
+    },
+    {
+      "file": "6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/a02ba01b82410e9e3/meta-20260101T000000.000000000Z-000003.sd",
+      "format": "sd",
+      "kind": "agent_meta",
+      "seq": 3,
+      "round": null,
+      "stream": "a02ba01b82410e9e3",
+      "run": null,
+      "lines": 3,
+      "bytes": 666,
+      "digest": "ad1b164dea912697af058c40edcb855838ace434a26a675c3feae499056a66e0",
+      "from_time": null,
+      "through_time": null
+    },
+    {
+      "file": "6b7a6063-8714-4f6b-87cd-6c2da3a5094d/provider_body/provider_body-20260101T000000.000000000Z-000004.sd",
+      "format": "sd",
+      "kind": "provider_body",
+      "seq": 4,
+      "round": null,
+      "stream": null,
+      "run": null,
+      "lines": 16,
+      "bytes": 27874,
+      "digest": "a8cfc9e0f6e06ab3ce944fd1db40468d6502e7e1d7549c01292f9326a83fd331",
+      "from_time": null,
+      "through_time": null
+    },
+    {
+      "file": "_conversations/6b7a6063-8714-4f6b-87cd-6c2da3a5094d/rounds/r000001-ff8eaba03b03.sf",
+      "format": "sf",
+      "kind": "round",
+      "seq": null,
+      "round": 1,
+      "stream": null,
+      "run": null,
+      "lines": 51,
+      "bytes": 12009,
+      "digest": "2a0088c80cdf2abd2d9ff4517ccba7ec070035f904019d25f1c24eeec8346f10",
+      "from_time": 1767225600000,
+      "through_time": 1767225608400
+    }
+  ],
+  "streams": [
+    {
+      "id": "stream/main",
+      "name": "main",
+      "role": "main",
+      "label": "",
+      "parent": "",
+      "records": 14,
+      "steps": 15,
+      "talk": "talk/main/s1-cycle",
+      "named_by": "",
+      "opened_by": []
+    },
+    {
+      "id": "stream/a02ba01b82410e9e3",
+      "name": "a02ba01b82410e9e3",
+      "role": "child",
+      "label": "searcher",
+      "parent": "main",
+      "records": 6,
+      "steps": 6,
+      "talk": "talk/a02ba01b82410e9e3",
+      "named_by": "",
+      "opened_by": [
+        {
+          "step": "tool/s5-tool",
+          "stream": "main",
+          "talk": "talk/main/s4-cycle",
+          "quality": "exact_unique"
+        }
+      ]
+    }
+  ],
+  "segments": [
+    {
+      "id": "segment/at_1_2",
+      "state": "open",
+      "committable": false,
+      "talks": 5,
+      "from": 1767225600000,
+      "to": 1767225608400
+    }
+  ],
+  "talks": [
+    {
+      "id": "talk/main/s1-cycle",
+      "kind": "talk",
+      "parent": "epoch/main/0",
+      "stream": "main",
+      "at": 1767225600000,
+      "ref": {
+        "seq": 1,
+        "row": 2
+      },
+      "attrs": {
+        "loops": 1,
+        "runs": 1,
+        "trigger": "external"
+      },
+      "label": "read the configuration and summarise it",
+      "reply": "The timeout is 30 seconds, with 3 retries.",
+      "runs": 1,
+      "steps": 6,
+      "tools": 1,
+      "from": 1767225600000,
+      "to": 1767225602300,
+      "segment": "segment/at_1_2",
+      "children": [
+        {
+          "id": "run/talk_main_s1-cycle/s1-cycle",
+          "kind": "run",
+          "parent": "talk/main/s1-cycle",
+          "stream": "main",
+          "at": 1767225600000,
+          "ref": {
+            "seq": 1,
+            "row": 2
+          },
+          "attrs": {
+            "trigger": "external"
+          },
+          "children": [
+            {
+              "id": "input/1/2",
+              "kind": "message.external",
+              "parent": "run/talk_main_s1-cycle/s1-cycle",
+              "stream": "main",
+              "at": 1767225600000,
+              "ref": {
+                "seq": 1,
+                "row": 2
+              },
+              "text": "read the configuration and summarise it",
+              "state": "available",
+              "bytes": 39,
+              "flags": [
+                "external_input"
+              ]
+            },
+            {
+              "id": "call/s2-call-fdae022ac306",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s1-cycle/s1-cycle",
+              "stream": "main",
+              "at": 1767225601000,
+              "ref": {
+                "seq": 1,
+                "row": 3
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 3
+                },
+                {
+                  "seq": 1,
+                  "row": 4
+                }
+              ],
+              "attrs": {
+                "fragments": 2,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 4,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "provider_bodies": [
+                {
+                  "role": "request",
+                  "ref": {
+                    "seq": 4,
+                    "row": 1
+                  }
+                },
+                {
+                  "role": "response",
+                  "ref": {
+                    "seq": 4,
+                    "row": 2
+                  }
+                }
+              ],
+              "children": [
+                {
+                  "id": "msg/1/3:0",
+                  "kind": "message.assistant",
+                  "parent": "call/s2-call-fdae022ac306",
+                  "stream": "main",
+                  "at": 1767225601000,
+                  "ref": {
+                    "seq": 1,
+                    "row": 3,
+                    "block": 0
+                  },
+                  "text": "Reading it.",
+                  "state": "available",
+                  "bytes": 11
+                },
+                {
+                  "id": "tool/s2-tool",
+                  "kind": "tool",
+                  "parent": "call/s2-call-fdae022ac306",
+                  "stream": "main",
+                  "at": 1767225601100,
+                  "ref": {
+                    "seq": 1,
+                    "row": 4,
+                    "block": 0
+                  },
+                  "refs": [
+                    {
+                      "seq": 1,
+                      "row": 4,
+                      "block": 0
+                    },
+                    {
+                      "seq": 1,
+                      "row": 5,
+                      "block": 0
+                    }
+                  ],
+                  "attrs": {
+                    "name": "Read",
+                    "result": "available",
+                    "result_join": "exact_unique",
+                    "timing": "unavailable"
+                  },
+                  "text": "{\"file_path\":\"/Users/dev/scenario/config.yaml\"}",
+                  "state": "available",
+                  "bytes": 47,
+                  "flags": [
+                    "finished"
+                  ],
+                  "name": "Read",
+                  "result": "timeout: 30\nretries: 3\n",
+                  "result_state": "available",
+                  "result_bytes": 23,
+                  "request_to_result_ms": 200,
+                  "request_to_result_join": "exact_unique"
+                }
+              ]
+            },
+            {
+              "id": "call/s3-call-fdae022ac306",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s1-cycle/s1-cycle",
+              "stream": "main",
+              "at": 1767225602300,
+              "ref": {
+                "seq": 1,
+                "row": 6
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 6
+                }
+              ],
+              "attrs": {
+                "fragments": 1,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 6,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "provider_bodies": [
+                {
+                  "role": "request",
+                  "ref": {
+                    "seq": 4,
+                    "row": 3
+                  }
+                },
+                {
+                  "role": "response",
+                  "ref": {
+                    "seq": 4,
+                    "row": 4
+                  }
+                }
+              ],
+              "children": [
+                {
+                  "id": "msg/1/6:0",
+                  "kind": "message.assistant",
+                  "parent": "call/s3-call-fdae022ac306",
+                  "stream": "main",
+                  "at": 1767225602300,
+                  "ref": {
+                    "seq": 1,
+                    "row": 6,
+                    "block": 0
+                  },
+                  "text": "The timeout is 30 seconds, with 3 retries.",
+                  "state": "available",
+                  "bytes": 42,
+                  "flags": [
+                    "finished"
+                  ]
+                }
+              ]
+            }
+          ]
+        }
+      ],
+      "edges": [
+        {
+          "type": "in_segment",
+          "other": "segment/at_1_2",
+          "dir": "out",
+          "quality": "exact_unique",
+          "via": "activity window"
+        }
+      ]
+    },
+    {
+      "id": "talk/main/s4-cycle",
+      "kind": "talk",
+      "parent": "epoch/main/0",
+      "stream": "main",
+      "at": 1767225603300,
+      "ref": {
+        "seq": 1,
+        "row": 7
+      },
+      "attrs": {
+        "loops": 1,
+        "runs": 1,
+        "trigger": "external"
+      },
+      "label": "find where the timeout is used",
+      "reply": "The searcher found it in server.go.",
+      "runs": 1,
+      "steps": 6,
+      "tools": 1,
+      "from": 1767225603300,
+      "to": 1767225605400,
+      "segment": "segment/at_1_2",
+      "children": [
+        {
+          "id": "run/talk_main_s4-cycle/s4-cycle",
+          "kind": "run",
+          "parent": "talk/main/s4-cycle",
+          "stream": "main",
+          "at": 1767225603300,
+          "ref": {
+            "seq": 1,
+            "row": 7
+          },
+          "attrs": {
+            "trigger": "external"
+          },
+          "children": [
+            {
+              "id": "input/1/7",
+              "kind": "message.external",
+              "parent": "run/talk_main_s4-cycle/s4-cycle",
+              "stream": "main",
+              "at": 1767225603300,
+              "ref": {
+                "seq": 1,
+                "row": 7
+              },
+              "text": "find where the timeout is used",
+              "state": "available",
+              "bytes": 30,
+              "flags": [
+                "external_input"
+              ]
+            },
+            {
+              "id": "call/s5-call-fdae022ac306",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s4-cycle/s4-cycle",
+              "stream": "main",
+              "at": 1767225604300,
+              "ref": {
+                "seq": 1,
+                "row": 8
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 8
+                }
+              ],
+              "attrs": {
+                "fragments": 1,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 8,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "provider_bodies": [
+                {
+                  "role": "request",
+                  "ref": {
+                    "seq": 4,
+                    "row": 5
+                  }
+                },
+                {
+                  "role": "response",
+                  "ref": {
+                    "seq": 4,
+                    "row": 6
+                  }
+                }
+              ],
+              "children": [
+                {
+                  "id": "tool/s5-tool",
+                  "kind": "agent.call",
+                  "parent": "call/s5-call-fdae022ac306",
+                  "stream": "main",
+                  "at": 1767225604300,
+                  "ref": {
+                    "seq": 1,
+                    "row": 8,
+                    "block": 0
+                  },
+                  "refs": [
+                    {
+                      "seq": 1,
+                      "row": 8,
+                      "block": 0
+                    },
+                    {
+                      "seq": 1,
+                      "row": 9,
+                      "block": 0
+                    }
+                  ],
+                  "attrs": {
+                    "name": "Agent",
+                    "result": "available",
+                    "result_join": "exact_unique",
+                    "timing": "unavailable"
+                  },
+                  "text": "{\"description\":\"searcher\",\"prompt\":\"find every use of the timeout\"}",
+                  "state": "available",
+                  "bytes": 67,
+                  "flags": [
+                    "finished"
+                  ],
+                  "name": "Agent",
+                  "result": "launched",
+                  "result_state": "available",
+                  "result_bytes": 8,
+                  "request_to_result_ms": 100,
+                  "request_to_result_join": "exact_unique",
+                  "edges": [
+                    {
+                      "type": "starts",
+                      "other": "stream/a02ba01b82410e9e3",
+                      "dir": "out",
+                      "quality": "exact_unique",
+                      "via": "parent tool result"
+                    }
+                  ]
+                }
+              ]
+            },
+            {
+              "id": "ack/1/9",
+              "kind": "agent.launch_ack",
+              "parent": "run/talk_main_s4-cycle/s4-cycle",
+              "stream": "main",
+              "at": 1767225604400,
+              "ref": {
+                "seq": 1,
+                "row": 9
+              },
+              "text": "launched",
+              "state": "available",
+              "bytes": 8,
+              "flags": [
+                "launch_ack"
+              ]
+            },
+            {
+              "id": "call/s6-call-fdae022ac306",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s4-cycle/s4-cycle",
+              "stream": "main",
+              "at": 1767225605400,
+              "ref": {
+                "seq": 1,
+                "row": 10
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 10
+                }
+              ],
+              "attrs": {
+                "fragments": 1,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 10,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "provider_bodies": [
+                {
+                  "role": "request",
+                  "ref": {
+                    "seq": 4,
+                    "row": 7
+                  }
+                },
+                {
+                  "role": "response",
+                  "ref": {
+                    "seq": 4,
+                    "row": 8
+                  }
+                }
+              ],
+              "children": [
+                {
+                  "id": "msg/1/10:0",
+                  "kind": "message.assistant",
+                  "parent": "call/s6-call-fdae022ac306",
+                  "stream": "main",
+                  "at": 1767225605400,
+                  "ref": {
+                    "seq": 1,
+                    "row": 10,
+                    "block": 0
+                  },
+                  "text": "The searcher found it in server.go.",
+                  "state": "available",
+                  "bytes": 35,
+                  "flags": [
+                    "finished"
+                  ]
+                }
+              ]
+            }
+          ]
+        }
+      ],
+      "edges": [
+        {
+          "type": "in_segment",
+          "other": "segment/at_1_2",
+          "dir": "out",
+          "quality": "exact_unique",
+          "via": "activity window"
+        }
+      ]
+    },
+    {
+      "id": "talk/main/s7-cycle-compact",
+      "kind": "talk",
+      "parent": "epoch/main/s7-boundary",
+      "stream": "main",
+      "at": 1767225606000,
+      "ref": {
+        "seq": 1,
+        "row": 12
+      },
+      "attrs": {
+        "loops": 1,
+        "runs": 1,
+        "trigger": "external"
+      },
+      "runs": 1,
+      "from": 1767225606000,
+      "to": 1767225606000,
+      "segment": "segment/at_1_2",
+      "children": [
+        {
+          "id": "run/talk_main_s7-cycle-compact/s7-cycle-compact",
+          "kind": "run",
+          "parent": "talk/main/s7-cycle-compact",
+          "stream": "main",
+          "at": 1767225606000,
+          "ref": {
+            "seq": 1,
+            "row": 12
+          },
+          "attrs": {
+            "trigger": "external"
+          }
+        }
+      ],
+      "edges": [
+        {
+          "type": "in_segment",
+          "other": "segment/at_1_2",
+          "dir": "out",
+          "quality": "exact_unique",
+          "via": "activity window"
+        }
+      ]
+    },
+    {
+      "id": "talk/main/s8-cycle",
+      "kind": "talk",
+      "parent": "epoch/main/s7-boundary",
+      "stream": "main",
+      "at": 1767225607400,
+      "ref": {
+        "seq": 1,
+        "row": 13
+      },
+      "attrs": {
+        "loops": 1,
+        "runs": 1,
+        "trigger": "external"
+      },
+      "label": "and the retries?",
+      "reply": "There are 3 retries.",
+      "runs": 1,
+      "steps": 3,
+      "from": 1767225607400,
+      "to": 1767225608400,
+      "segment": "segment/at_1_2",
+      "children": [
+        {
+          "id": "run/talk_main_s8-cycle/s8-cycle",
+          "kind": "run",
+          "parent": "talk/main/s8-cycle",
+          "stream": "main",
+          "at": 1767225607400,
+          "ref": {
+            "seq": 1,
+            "row": 13
+          },
+          "attrs": {
+            "trigger": "external"
+          },
+          "children": [
+            {
+              "id": "input/1/13",
+              "kind": "message.external",
+              "parent": "run/talk_main_s8-cycle/s8-cycle",
+              "stream": "main",
+              "at": 1767225607400,
+              "ref": {
+                "seq": 1,
+                "row": 13
+              },
+              "text": "and the retries?",
+              "state": "available",
+              "bytes": 16,
+              "flags": [
+                "external_input"
+              ]
+            },
+            {
+              "id": "call/s9-call-fdae022ac306",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s8-cycle/s8-cycle",
+              "stream": "main",
+              "at": 1767225608400,
+              "ref": {
+                "seq": 1,
+                "row": 14
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 14
+                }
+              ],
+              "attrs": {
+                "fragments": 1,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 14,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "provider_bodies": [
+                {
+                  "role": "request",
+                  "ref": {
+                    "seq": 4,
+                    "row": 13
+                  }
+                },
+                {
+                  "role": "response",
+                  "ref": {
+                    "seq": 4,
+                    "row": 14
+                  }
+                }
+              ],
+              "children": [
+                {
+                  "id": "msg/1/14:0",
+                  "kind": "message.assistant",
+                  "parent": "call/s9-call-fdae022ac306",
+                  "stream": "main",
+                  "at": 1767225608400,
+                  "ref": {
+                    "seq": 1,
+                    "row": 14,
+                    "block": 0
+                  },
+                  "text": "There are 3 retries.",
+                  "state": "available",
+                  "bytes": 20,
+                  "flags": [
+                    "finished"
+                  ]
+                }
+              ]
+            }
+          ]
+        }
+      ],
+      "edges": [
+        {
+          "type": "in_segment",
+          "other": "segment/at_1_2",
+          "dir": "out",
+          "quality": "exact_unique",
+          "via": "activity window"
+        }
+      ]
+    },
+    {
+      "id": "talk/a02ba01b82410e9e3",
+      "kind": "talk",
+      "parent": "epoch/a02ba01b82410e9e3/0",
+      "stream": "a02ba01b82410e9e3",
+      "at": 1767225605400,
+      "ref": {
+        "seq": 2,
+        "row": 1
+      },
+      "attrs": {
+        "loops": 1,
+        "runs": 1,
+        "trigger": "unknown"
+      },
+      "reply": "It is used in server.go.",
+      "runs": 1,
+      "steps": 6,
+      "tools": 1,
+      "from": 1767225605400,
+      "to": 1767225607800,
+      "child": true,
+      "segment": "segment/at_1_2",
+      "children": [
+        {
+          "id": "run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle",
+          "kind": "run",
+          "parent": "talk/a02ba01b82410e9e3",
+          "stream": "a02ba01b82410e9e3",
+          "at": 1767225605400,
+          "ref": {
+            "seq": 2,
+            "row": 1
+          },
+          "attrs": {
+            "trigger": "external"
+          },
+          "children": [
+            {
+              "id": "call/searcher-s1-call-fdae022ac306",
+              "kind": "llm.call",
+              "parent": "run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle",
+              "stream": "a02ba01b82410e9e3",
+              "at": 1767225606400,
+              "ref": {
+                "seq": 2,
+                "row": 2
+              },
+              "refs": [
+                {
+                  "seq": 2,
+                  "row": 2
+                },
+                {
+                  "seq": 2,
+                  "row": 3
+                }
+              ],
+              "attrs": {
+                "fragments": 2,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 3,
+                  "seq": 2
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "provider_bodies": [
+                {
+                  "role": "request",
+                  "ref": {
+                    "seq": 4,
+                    "row": 9
+                  }
+                },
+                {
+                  "role": "response",
+                  "ref": {
+                    "seq": 4,
+                    "row": 10
+                  }
+                }
+              ],
+              "children": [
+                {
+                  "id": "msg/2/2:0",
+                  "kind": "message.assistant",
+                  "parent": "call/searcher-s1-call-fdae022ac306",
+                  "stream": "a02ba01b82410e9e3",
+                  "at": 1767225606400,
+                  "ref": {
+                    "seq": 2,
+                    "row": 2,
+                    "block": 0
+                  },
+                  "text": "Searching.",
+                  "state": "available",
+                  "bytes": 10
+                },
+                {
+                  "id": "tool/searcher-s1-tool",
+                  "kind": "tool",
+                  "parent": "call/searcher-s1-call-fdae022ac306",
+                  "stream": "a02ba01b82410e9e3",
+                  "at": 1767225606500,
+                  "ref": {
+                    "seq": 2,
+                    "row": 3,
+                    "block": 0
+                  },
+                  "refs": [
+                    {
+                      "seq": 2,
+                      "row": 3,
+                      "block": 0
+                    },
+                    {
+                      "seq": 2,
+                      "row": 4,
+                      "block": 0
+                    }
+                  ],
+                  "attrs": {
+                    "name": "Bash",
+                    "result": "available",
+                    "result_join": "exact_unique",
+                    "timing": "unavailable"
+                  },
+                  "text": "{\"command\":\"grep -rn timeout .\"}",
+                  "state": "available",
+                  "bytes": 32,
+                  "flags": [
+                    "finished"
+                  ],
+                  "name": "Bash",
+                  "result": "server.go:3: var timeout = 30",
+                  "result_state": "available",
+                  "result_bytes": 29,
+                  "request_to_result_ms": 300,
+                  "request_to_result_join": "exact_unique"
+                }
+              ]
+            },
+            {
+              "id": "call/searcher-s2-call-fdae022ac306",
+              "kind": "llm.call",
+              "parent": "run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle",
+              "stream": "a02ba01b82410e9e3",
+              "at": 1767225607800,
+              "ref": {
+                "seq": 2,
+                "row": 5
+              },
+              "refs": [
+                {
+                  "seq": 2,
+                  "row": 5
+                }
+              ],
+              "attrs": {
+                "fragments": 1,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 5,
+                  "seq": 2
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "provider_bodies": [
+                {
+                  "role": "request",
+                  "ref": {
+                    "seq": 4,
+                    "row": 11
+                  }
+                },
+                {
+                  "role": "response",
+                  "ref": {
+                    "seq": 4,
+                    "row": 12
+                  }
+                }
+              ],
+              "children": [
+                {
+                  "id": "msg/2/5:0",
+                  "kind": "message.assistant",
+                  "parent": "call/searcher-s2-call-fdae022ac306",
+                  "stream": "a02ba01b82410e9e3",
+                  "at": 1767225607800,
+                  "ref": {
+                    "seq": 2,
+                    "row": 5,
+                    "block": 0
+                  },
+                  "text": "It is used in server.go.",
+                  "state": "available",
+                  "bytes": 24,
+                  "flags": [
+                    "finished"
+                  ]
+                }
+              ]
+            },
+            {
+              "id": "output/a02ba01b82410e9e3",
+              "kind": "agent.output",
+              "parent": "run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle",
+              "stream": "a02ba01b82410e9e3",
+              "at": 1767225607800,
+              "ref": {
+                "seq": 2,
+                "row": 5
+              },
+              "refs": [
+                {
+                  "seq": 2,
+                  "row": 5
+                }
+              ],
+              "attrs": {
+                "returned_value": "unavailable"
+              },
+              "text": "It is used in server.go.",
+              "state": "available",
+              "bytes": 24,
+              "flags": [
+                "finished"
+              ],
+              "edges": [
+                {
+                  "type": "ends_with",
+                  "other": "stream/a02ba01b82410e9e3",
+                  "dir": "in",
+                  "quality": "exact_unique",
+                  "via": "the last response in the child stream, and what it returned"
+                }
+              ]
+            }
+          ]
+        }
+      ],
+      "edges": [
+        {
+          "type": "in_segment",
+          "other": "segment/at_1_2",
+          "dir": "out",
+          "quality": "strong_inference",
+          "via": "inside the window of the talk that delegated it"
+        }
+      ]
+    }
+  ],
+  "loose": [
+    {
+      "id": "boundary/1/11",
+      "kind": "epoch.boundary",
+      "parent": "epoch/main/s7-boundary",
+      "stream": "main",
+      "at": 1767225606400,
+      "ref": {
+        "seq": 1,
+        "row": 11
+      },
+      "text": "{\"compactMetadata\":{\"preservedMessages\":{\"allUuids\":[\"s6-call-f1\"]},\"trigger\":\"auto\"},\"logicalParentUuid\":\"s6-call-f1\",\"subtype\":\"compact_boundary\",\"type\":\"system\"}",
+      "state": "available",
+      "bytes": 164,
+      "flags": [
+        "context_reset"
+      ],
+      "edges": [
+        {
+          "type": "summarizes",
+          "other": "summary/1/12",
+          "dir": "in",
+          "quality": "exact_unique",
+          "via": "containment parent"
+        }
+      ]
+    },
+    {
+      "id": "summary/1/12",
+      "kind": "epoch.summary",
+      "parent": "epoch/main/s7-boundary",
+      "stream": "main",
+      "at": 1767225606000,
+      "ref": {
+        "seq": 1,
+        "row": 12
+      },
+      "text": "The configuration sets a 30 second timeout, used in server.go.",
+      "state": "available",
+      "bytes": 62,
+      "flags": [
+        "reset_summary"
+      ],
+      "edges": [
+        {
+          "type": "summarizes",
+          "other": "boundary/1/11",
+          "dir": "out",
+          "quality": "exact_unique",
+          "via": "containment parent"
+        }
+      ]
+    }
+  ],
+  "relations": [
+    {
+      "id": "rel/ends_with/stream_a02ba01b82410e9e3/output_a02ba01b82410e9e3",
+      "type": "ends_with",
+      "from": "stream/a02ba01b82410e9e3",
+      "to": "output/a02ba01b82410e9e3",
+      "quality": "exact_unique",
+      "via": "the last response in the child stream, and what it returned",
+      "evidence": [
+        {
+          "seq": 2,
+          "row": 5
+        }
+      ]
+    },
+    {
+      "id": "rel/follows/epoch_main_s7-boundary/epoch_main_0",
+      "type": "follows",
+      "from": "epoch/main/s7-boundary",
+      "to": "epoch/main/0",
+      "quality": "exact_unique",
+      "via": "explicit context reset",
+      "evidence": [
+        {
+          "seq": 1,
+          "row": 11
+        }
+      ]
+    },
+    {
+      "id": "rel/in_segment/talk_a02ba01b82410e9e3/segment_at_1_2",
+      "type": "in_segment",
+      "from": "talk/a02ba01b82410e9e3",
+      "to": "segment/at_1_2",
+      "quality": "strong_inference",
+      "via": "inside the window of the talk that delegated it",
+      "evidence": [
+        {
+          "seq": 2,
+          "row": 1
+        }
+      ]
+    },
+    {
+      "id": "rel/in_segment/talk_main_s1-cycle/segment_at_1_2",
+      "type": "in_segment",
+      "from": "talk/main/s1-cycle",
+      "to": "segment/at_1_2",
+      "quality": "exact_unique",
+      "via": "activity window",
+      "evidence": [
+        {
+          "seq": 1,
+          "row": 2
+        }
+      ]
+    },
+    {
+      "id": "rel/in_segment/talk_main_s4-cycle/segment_at_1_2",
+      "type": "in_segment",
+      "from": "talk/main/s4-cycle",
+      "to": "segment/at_1_2",
+      "quality": "exact_unique",
+      "via": "activity window",
+      "evidence": [
+        {
+          "seq": 1,
+          "row": 7
+        }
+      ]
+    },
+    {
+      "id": "rel/in_segment/talk_main_s7-cycle-compact/segment_at_1_2",
+      "type": "in_segment",
+      "from": "talk/main/s7-cycle-compact",
+      "to": "segment/at_1_2",
+      "quality": "exact_unique",
+      "via": "activity window",
+      "evidence": [
+        {
+          "seq": 1,
+          "row": 12
+        }
+      ]
+    },
+    {
+      "id": "rel/in_segment/talk_main_s8-cycle/segment_at_1_2",
+      "type": "in_segment",
+      "from": "talk/main/s8-cycle",
+      "to": "segment/at_1_2",
+      "quality": "exact_unique",
+      "via": "activity window",
+      "evidence": [
+        {
+          "seq": 1,
+          "row": 13
+        }
+      ]
+    },
+    {
+      "id": "rel/starts/tool_s5-tool/stream_a02ba01b82410e9e3",
+      "type": "starts",
+      "from": "tool/s5-tool",
+      "to": "stream/a02ba01b82410e9e3",
+      "quality": "exact_unique",
+      "via": "parent tool result",
+      "evidence": [
+        {
+          "seq": 1,
+          "row": 9
+        }
+      ]
+    },
+    {
+      "id": "rel/summarizes/summary_1_12/boundary_1_11",
+      "type": "summarizes",
+      "from": "summary/1/12",
+      "to": "boundary/1/11",
+      "quality": "exact_unique",
+      "via": "containment parent",
+      "evidence": [
+        {
+          "seq": 1,
+          "row": 12
+        }
+      ]
+    }
+  ],
+  "unresolved": [],
+  "workspace_changes": []
+}
diff --git a/packages/conversation-view/test/fixtures/provider-bodies.sd b/packages/conversation-view/test/fixtures/provider-bodies.sd
new file mode 100644
index 00000000..ae2ea551
--- /dev/null
+++ b/packages/conversation-view/test/fixtures/provider-bodies.sd
@@ -0,0 +1,16 @@
+{"h":1,"schema":"sd/1","seq":4,"at":"2026-01-01T00:00:00Z","kind":"provider_body","adapter":"mock/0.2.0","dialect":"mock/1","src":".","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d"}
+{"ord":1,"off":0,"sha":"25a4cf0c2c2a","bytes":8155,"id":"62c5142f-0b52-4b3d-8f5b-5ba0a44cb6b7.request","run":"s1-cycle","model":"claude-opus-5","parts":[{"k":"data","data":"<system-reminder>\nCodebase and user instructions are shown below.\nFollow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. [...]
+{"ord":1,"off":0,"sha":"6854dac2921c","bytes":268,"id":"s2-req-fdae022ac306.response","call":"s2-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s2-req-fdae022ac306.response.json","sha256":"6854dac2921cbbb9de25c2e7b640b45a0148e9870102a47fe719476ff37997d2","bytes":268,"depth":0,"model":"claude-opus-5","call":"s2-call-fdae022ac306","request":"s2-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"Readin [...]
+{"ord":1,"off":0,"sha":"60c205135751","bytes":8476,"id":"e6250647-cc72-4db4-87f8-35d5d8532118.request","run":"s1-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"e6250647-cc72-4db4-87f8-35d5d8532118.request.json","sha256":"60c20513575143a9f0166a04f236954c6d0831eb62418ff105c6f30f86151505","bytes":8476,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s1-cycle"," [...]
+{"ord":1,"off":0,"sha":"07626e6a5551","bytes":194,"id":"s3-req-fdae022ac306.response","call":"s3-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s3-req-fdae022ac306.response.json","sha256":"07626e6a55516660d4045b4bd4615992f71539ed94b9e38aee5b5c1fc563ed68","bytes":194,"depth":0,"model":"claude-opus-5","call":"s3-call-fdae022ac306","request":"s3-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"The ti [...]
+{"ord":1,"off":0,"sha":"fcf4469b068b","bytes":8636,"id":"2e0ed3d7-b6d2-46eb-8c3f-c06a030dd7d5.request","run":"s4-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"2e0ed3d7-b6d2-46eb-8c3f-c06a030dd7d5.request.json","sha256":"fcf4469b068bc21e97d6e4754a3b2454c0876da594b6f0fb8b00e3c7eb056eab","bytes":8636,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s4-cycle"," [...]
+{"ord":1,"off":0,"sha":"f66b868a1f3b","bytes":252,"id":"s5-req-fdae022ac306.response","call":"s5-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s5-req-fdae022ac306.response.json","sha256":"f66b868a1f3b5d2c80e28a8314f7ce57f5f55e8e4625ba403fff5ab0e22b613e","bytes":252,"depth":0,"model":"claude-opus-5","call":"s5-call-fdae022ac306","request":"s5-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"id\":\"s5-tool\ [...]
+{"ord":1,"off":0,"sha":"faa5ca836651","bytes":8866,"id":"935833f9-330c-4a2f-84e7-90072ac2f4db.request","run":"s4-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"935833f9-330c-4a2f-84e7-90072ac2f4db.request.json","sha256":"faa5ca836651fe57bf3b41d154a72d46560bba35dfc15d872cf86606ad195b27","bytes":8866,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s4-cycle"," [...]
+{"ord":1,"off":0,"sha":"c4f1645ec661","bytes":187,"id":"s6-req-fdae022ac306.response","call":"s6-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s6-req-fdae022ac306.response.json","sha256":"c4f1645ec661dd0072bccb3c9addc19563fc22d140f5e66e3bfd7d5c96a436e2","bytes":187,"depth":0,"model":"claude-opus-5","call":"s6-call-fdae022ac306","request":"s6-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"The se [...]
+{"ord":1,"off":0,"sha":"a564142b6ea8","bytes":3753,"id":"32c40efc-db2a-4811-85e8-0d40b51c6fe7.request","run":"a02ba01b82410e9e3-cycle","model":"claude-opus-5","parts":[{"k":"data","data":"You are an agent that searches a repository. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you f [...]
+{"ord":1,"off":0,"sha":"db0df7cae147","bytes":270,"id":"searcher-s1-req-fdae022ac306.response","call":"searcher-s1-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"searcher-s1-req-fdae022ac306.response.json","sha256":"db0df7cae14773759eb967e78b5999c47377673475c09e0da2cd44d81356ef56","bytes":270,"depth":0,"model":"claude-opus-5","call":"searcher-s1-call-fdae022ac306","request":"searcher-s1-req-fdae022ac306","segmen [...]
+{"ord":1,"off":0,"sha":"0047b6fad9bb","bytes":4089,"id":"c58ece40-0a74-4e25-8426-53f9ccca9203.request","run":"a02ba01b82410e9e3-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"c58ece40-0a74-4e25-8426-53f9ccca9203.request.json","sha256":"0047b6fad9bbe463e67ed5ed18a023e62959433c12d427b14cce04b8de492248","bytes":4089,"depth":0,"chain":"13f2eed00f2e44d1","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","ru [...]
+{"ord":1,"off":0,"sha":"9aa0936b42dc","bytes":185,"id":"searcher-s2-req-fdae022ac306.response","call":"searcher-s2-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"searcher-s2-req-fdae022ac306.response.json","sha256":"9aa0936b42dc784b266936891a85359e54500e1f3fb9e490cc53b192b313d499","bytes":185,"depth":0,"model":"claude-opus-5","call":"searcher-s2-call-fdae022ac306","request":"searcher-s2-req-fdae022ac306","segmen [...]
+{"ord":1,"off":0,"sha":"c98fdf59718d","bytes":8281,"id":"89c950ad-d61b-47bb-84da-bf0f39990473.request","run":"s8-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"89c950ad-d61b-47bb-84da-bf0f39990473.request.json","sha256":"c98fdf59718dda1877f1107a8a3eae19ec0c55d0989ce6d03d6b33aaf20758ff","bytes":8281,"depth":0,"chain":"d4550b67cba4cf93","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s8-cycle"," [...]
+{"ord":1,"off":0,"sha":"b61d2be4bb7b","bytes":172,"id":"s9-req-fdae022ac306.response","call":"s9-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s9-req-fdae022ac306.response.json","sha256":"b61d2be4bb7bc571ab39192cfe0219474fa688a9690861226d5f2b67509e325e","bytes":172,"depth":0,"model":"claude-opus-5","call":"s9-call-fdae022ac306","request":"s9-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"There  [...]
+{"t":"end","records":14,"digest":"979442eae01734ce54d96f1bbec3df5a1382295a8cd80880475b0df3ba4510c0"}
diff --git a/packages/conversation-view/test/prompt-guards.test.ts b/packages/conversation-view/test/prompt-guards.test.ts
new file mode 100644
index 00000000..f26aa350
--- /dev/null
+++ b/packages/conversation-view/test/prompt-guards.test.ts
@@ -0,0 +1,116 @@
+/*
+ * 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.
+ */
+
+/** What the reader must refuse, and what it must not lose. Every case here is one a real file could
+ *  carry: a server is not trusted to send only what the Sessionizer wrote. */
+
+import { describe, expect, it } from 'vitest';
+import { PromptError, PromptStore, readProviderFile } from '../src/prompt/store.js';
+import { deltaOf, readBody, type ReadRequest } from '../src/prompt/messages.js';
+
+const HEADER = '{"h":1,"schema":"sd/1","seq":1,"at":"2026-01-01T00:00:00Z","kind":"provider_body","adapter":"mock/0.2.0","dialect":"mock/1","src":".","session":"s"}';
+const END = '{"t":"end"}';
+
+/** One record: the body's own parts, then the manifest as the last part. */
+function record(id: string, manifest: Record<string, unknown>, parts: string[] = []): string {
+  const all = [...parts.map((p) => `{"k":"data","data":${p}}`), `{"k":"data","data":${JSON.stringify(manifest)}}`];
+  return `{"ord":1,"id":${JSON.stringify(id)},"parts":[${all.join(',')}]}`;
+}
+
+function file(seq: number, lines: string[]): { seq: number; file: string; bytes: Uint8Array } {
+  return { seq, file: `s/provider_body/x-${seq}.sd`, bytes: new TextEncoder().encode([HEADER, ...lines, END].join('\n') + '\n') };
+}
+
+/** The sha256 of `{}`, which is what the well-formed records below rebuild to. */
+const EMPTY_OBJECT = '44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
+
+describe('what the body reader refuses', () => {
+  it('takes a manifest by its types, not by comparing whatever it holds', () => {
+    // `bytes` as an object compares as under the budget and over zero, and would be handed to
+    // `new Uint8Array` — the Sessionizer's own decoder refuses the record instead
+    const lines = [
+      record('a', { schema: 'provider_body/1', role: 'request', sha256: EMPTY_OBJECT, bytes: { length: 1073741824 }, depth: 0, segments: [{ lit: '{}' }] }),
+      record('b', { schema: 'provider_body/1', role: 'request', sha256: EMPTY_OBJECT, bytes: 2, depth: '0', segments: [{ lit: '{}' }] }),
+      record('c', { schema: 'provider_body/1', role: 'request', sha256: EMPTY_OBJECT, bytes: 2, depth: 0, segments: 'all of it' }),
+      record('d', { schema: 'provider_body/1', role: 'request', sha256: EMPTY_OBJECT, bytes: 2, depth: 0, segments: [{ lit: '{}' }] }),
+    ];
+    const records = readProviderFile(1, file(1, lines).bytes);
+    expect(records.map((r) => r.id)).toEqual(['d']);
+  });
+
+  it('rebuilds a record that landed twice before it stands for the earlier one', () => {
+    // the duplicate claims the digest of the record already held, but holds something else; a digest a
+    // record claims is not evidence of itself
+    const good = { schema: 'provider_body/1', role: 'request', sha256: EMPTY_OBJECT, bytes: 2, depth: 0, segments: [{ lit: '{}' }] };
+    const store = new PromptStore();
+    store.addFile(file(1, [record('same', good), record('same', { ...good, segments: [{ lit: '[]' }] })]));
+    expect(new TextDecoder().decode(store.bodyAt(1, 1))).toBe('{}');
+    // row 2 is not the earlier record's position: the duplicate did not rebuild to what it claimed
+    expect(() => store.bodyAt(1, 2)).toThrow(PromptError);
+  });
+
+  it('reads a file again when an earlier one it waited for arrives', () => {
+    const first = { schema: 'provider_body/1', role: 'request', sha256: EMPTY_OBJECT, bytes: 2, depth: 0, segments: [{ lit: '{}' }] };
+    const later = {
+      schema: 'provider_body/1',
+      role: 'request',
+      sha256: EMPTY_OBJECT,
+      bytes: 2,
+      depth: 1,
+      segments: [{ copy: { from: 'one', sha256: EMPTY_OBJECT, len: 2 } }],
+    };
+    const store = new PromptStore();
+    // seq 2 arrives on its own: it refers to seq 1, so its record is not held and the file is not held
+    store.addFile(file(2, [record('two', later)]));
+    expect(store.has(2)).toBe(false);
+    expect(() => store.bodyAt(2, 1)).toThrow(PromptError);
+    // the file it waited for arrives, and reading it again brings the record back
+    store.addFile(file(1, [record('one', first)]));
+    store.addFile(file(2, [record('two', later)]));
+    expect(store.has(2)).toBe(true);
+    expect(new TextDecoder().decode(store.bodyAt(2, 1))).toBe('{}');
+  });
+
+  it('shows a body that is not JSON as the text it is', () => {
+    const read = readBody(new TextEncoder().encode('upstream connect error'), 'response');
+    expect(read.kind).toBe('other');
+    expect(read.kind === 'other' && read.text).toBe('upstream connect error');
+  });
+});
+
+describe('what the delta compares', () => {
+  const request = (messages: unknown[]): ReadRequest =>
+    readBody(new TextEncoder().encode(JSON.stringify({ model: 'm', messages })), 'request') as ReadRequest;
+
+  it('leaves out the cache marker the runtime moves, and nothing else named like it', () => {
+    const before = request([{ role: 'user', content: [{ type: 'text', text: 'hi', cache_control: { type: 'ephemeral' } }] }]);
+    const after = request([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]);
+    expect(deltaOf(before, after).sharedMessages).toBe(1);
+    // the same name inside a tool's own input is the agent's data, and two calls that differ there differ
+    const one = request([{ role: 'assistant', content: [{ type: 'tool_use', name: 'Run', input: { cache_control: 'a' } }] }]);
+    const two = request([{ role: 'assistant', content: [{ type: 'tool_use', name: 'Run', input: { cache_control: 'b' } }] }]);
+    expect(deltaOf(one, two).sharedMessages).toBe(0);
+  });
+
+  it('sees a difference under a key named __proto__', () => {
+    const one = request([JSON.parse('{"role":"user","content":[{"type":"tool_use","name":"R","input":{"__proto__":{"x":1}}}]}') as unknown]);
+    const two = request([JSON.parse('{"role":"user","content":[{"type":"tool_use","name":"R","input":{"__proto__":{"x":2}}}]}') as unknown]);
+    expect(deltaOf(one, two).sharedMessages).toBe(0);
+  });
+});
diff --git a/packages/conversation-view/test/prompt-panel.test.ts b/packages/conversation-view/test/prompt-panel.test.ts
new file mode 100644
index 00000000..99b3e796
--- /dev/null
+++ b/packages/conversation-view/test/prompt-panel.test.ts
@@ -0,0 +1,172 @@
+/*
+ * 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.
+ */
+
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { mountConversationView, type AszViewDocument, type ConversationView, type StoredFile } from '../src/index.js';
+
+/** The Sessionizer's provider-bodies scenario: the document, and the one file its bodies landed in. */
+const doc = JSON.parse(readFileSync(resolve('test/fixtures/provider-bodies.json'), 'utf8')) as AszViewDocument;
+const file = new Uint8Array(readFileSync(resolve('test/fixtures/provider-bodies.sd')));
+/** The second call of the main stream: it has a request, a response, and a call before it. */
+const CALL = 'call/s3-call-fdae022ac306';
+
+let view: ConversationView | null = null;
+let host: HTMLElement | null = null;
+let asked: Array<{ session: string; seqs: number[] }> = [];
+
+function mount(loader = true): HTMLElement {
+  asked = [];
+  host = document.createElement('div');
+  document.body.appendChild(host);
+  view = mountConversationView(host, {
+    document: doc,
+    ...(loader
+      ? {
+          loadFiles: async (request: { session: string; seqs: number[] }, onFile: (f: StoredFile) => void) => {
+            asked.push({ session: request.session, seqs: request.seqs });
+            for (const seq of request.seqs) onFile({ seq, file: `${request.session}/provider_body/x.sd`, bytes: file });
+          },
+        }
+      : {}),
+    state: { step: CALL },
+  });
+  return host;
+}
+
+function tab(root: HTMLElement, name: string): HTMLButtonElement | null {
+  return root.querySelector<HTMLButtonElement>(`[data-tab="${name}"]`);
+}
+
+function panel(root: HTMLElement): HTMLElement {
+  return root.querySelector<HTMLElement>('.acv-inspector-body')!;
+}
+
+afterEach(() => {
+  view?.destroy();
+  view = null;
+  host?.remove();
+  host = null;
+});
+
+describe('the prompt panel', () => {
+  it('is offered only for a call whose bodies landed, and only to a host that can read files', () => {
+    const withLoader = mount();
+    expect(tab(withLoader, 'prompt')?.hidden).toBe(false);
+    view?.destroy();
+    host?.remove();
+    const without = mount(false);
+    expect(tab(without, 'prompt')?.hidden).toBe(true);
+  });
+
+  it('loads nothing until the reader asks, then shows the request as it was sent', async () => {
+    const root = mount();
+    tab(root, 'prompt')!.click();
+    const body = panel(root);
+    expect(body.textContent).toContain('request this call sent');
+    expect(asked).toEqual([]);
+    const load = body.querySelector<HTMLButtonElement>('[data-load-prompt]')!;
+    load.click();
+    await new Promise((r) => setTimeout(r, 0));
+    // one read, of this session's provider files up to the call's own
+    expect(asked).toEqual([{ session: doc.conversation, seqs: [4] }]);
+    const after = panel(root);
+    expect(after.textContent).toContain('System prompt');
+    expect(after.textContent).toContain('Tools (');
+    expect(after.textContent).toContain('Messages (');
+    // the messages section is open, and its text is the runtime's own
+    expect(after.textContent).toContain('read the configuration');
+  });
+
+  it('shows what the call added, against the request before it', async () => {
+    const root = mount();
+    tab(root, 'prompt')!.click();
+    panel(root).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
+    await new Promise((r) => setTimeout(r, 0));
+    panel(root).querySelector<HTMLButtonElement>('[data-prompt-whole="0"]')!.click();
+    const body = panel(root);
+    expect(body.textContent).toContain('Added by this call');
+    // the shared history is named, not repeated
+    expect(body.textContent).toMatch(/message\(s\) before are as the call before sent them/);
+  });
+
+  it('shows the response on its own, with what stopped it', async () => {
+    const root = mount();
+    tab(root, 'prompt')!.click();
+    panel(root).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
+    await new Promise((r) => setTimeout(r, 0));
+    panel(root).querySelector<HTMLButtonElement>('[data-prompt-side="response"]')!.click();
+    const body = panel(root);
+    expect(body.textContent).toContain('stopped:');
+    expect(body.textContent).toContain('out ');
+  });
+
+  it('copies the whole text, not the preview and not the button', async () => {
+    const root = mount();
+    const written: string[] = [];
+    Object.defineProperty(navigator, 'clipboard', {
+      configurable: true,
+      value: { writeText: (t: string) => (written.push(t), Promise.resolve()) },
+    });
+    tab(root, 'prompt')!.click();
+    panel(root).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
+    await new Promise((r) => setTimeout(r, 0));
+    const copy = panel(root).querySelector<HTMLButtonElement>('.acv-block .acv-copy')!;
+    const whole = copy.parentElement!.querySelector('.acv-copy-src')!.textContent!;
+    copy.click();
+    await new Promise((r) => setTimeout(r, 0));
+    expect(written).toEqual([whole]);
+    expect(written[0]).not.toBe('copy');
+  });
+
+  it('draws what it could rebuild when the server left a file out', async () => {
+    asked = [];
+    host = document.createElement('div');
+    document.body.appendChild(host);
+    view = mountConversationView(host, {
+      document: doc,
+      // the server answers with nothing: a file past its retention, say
+      loadFiles: async (request: { session: string; seqs: number[] }) => {
+        asked.push({ session: request.session, seqs: request.seqs });
+      },
+      state: { step: CALL },
+    });
+    tab(host, 'prompt')!.click();
+    panel(host).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
+    await new Promise((r) => setTimeout(r, 0));
+    const body = panel(host);
+    // the offer is gone — asking again brings the same answer — and the panel says which file is not there
+    expect(body.querySelector('[data-load-prompt]')).toBeNull();
+    expect(body.textContent).toContain('4');
+    expect(body.querySelector('.acv-warning')).not.toBeNull();
+  });
+
+  it('keeps what it read: a second call of the same session asks for nothing more', async () => {
+    const root = mount();
+    tab(root, 'prompt')!.click();
+    panel(root).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
+    await new Promise((r) => setTimeout(r, 0));
+    view!.setState({ step: 'call/s5-call-fdae022ac306' });
+    const body = panel(root);
+    expect(body.querySelector('[data-load-prompt]')).toBeNull();
+    expect(body.textContent).toContain('Messages (');
+    expect(asked).toHaveLength(1);
+  });
+});
diff --git a/packages/conversation-view/test/prompt-store.test.ts b/packages/conversation-view/test/prompt-store.test.ts
new file mode 100644
index 00000000..52041027
--- /dev/null
+++ b/packages/conversation-view/test/prompt-store.test.ts
@@ -0,0 +1,128 @@
+/*
+ * 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.
+ */
+
+import { readFileSync } from 'node:fs';
+import { createHash } from 'node:crypto';
+import { resolve } from 'node:path';
+import { describe, expect, it } from 'vitest';
+import { PromptError, PromptStore, rawPartData, readProviderFile } from '../src/prompt/store.js';
+import { sha256Hex } from '../src/prompt/sha256.js';
+import type { AszViewDocument } from '../src/types.js';
+
+/** The Sessionizer's provider-bodies scenario, landed: one file of fourteen bodies, and the document
+ *  the Sessionizer prints for it, which the OAP's own tests hold too. */
+const file = new Uint8Array(readFileSync(resolve('test/fixtures/provider-bodies.sd')));
+const doc = JSON.parse(readFileSync(resolve('test/fixtures/provider-bodies.json'), 'utf8')) as AszViewDocument;
+const SEQ = 4;
+
+function store(): PromptStore {
+  const s = new PromptStore();
+  s.addFile({ seq: SEQ, file: 'provider_body.sd', bytes: file });
+  return s;
+}
+
+describe('the provider body reader', () => {
+  it('rebuilds every body of the file to its own digest', () => {
+    const s = store();
+    const records = readProviderFile(SEQ, file);
+    expect(records).toHaveLength(14);
+    for (const r of records) {
+      const body = s.bodyAt(SEQ, r.row);
+      expect(body.length).toBe(r.manifest.bytes);
+      expect(createHash('sha256').update(body).digest('hex')).toBe(r.manifest.sha256);
+    }
+  });
+
+  it('rebuilds what the document points at, and reads as the runtime wrote it', () => {
+    const s = store();
+    const refs: Array<{ role: string; seq: number; row: number }> = [];
+    const walk = (nodes: AszViewDocument['talks']): void => {
+      for (const n of nodes) {
+        for (const b of (n as { provider_bodies?: Array<{ role: string; ref: { seq: number; row: number } }> }).provider_bodies ?? []) {
+          refs.push({ role: b.role, seq: b.ref.seq, row: b.ref.row });
+        }
+        walk(n.children ?? []);
+      }
+    };
+    walk(doc.talks);
+    walk(doc.loose ?? []);
+    expect(refs).toHaveLength(14);
+    for (const ref of refs) {
+      const body = JSON.parse(new TextDecoder().decode(s.bodyAt(ref.seq, ref.row))) as Record<string, unknown>;
+      expect(typeof body['model']).toBe('string');
+      // a request carries what it sent, a response what came back
+      if (ref.role === 'request') expect(Array.isArray(body['messages'])).toBe(true);
+      else expect(typeof body['id']).toBe('string');
+    }
+  });
+
+  it('carries the keys a reader joins by', () => {
+    const s = store();
+    const first = s.manifestAt(SEQ, 1)!;
+    expect(first.role).toBe('request');
+    expect(first.run).toBeTruthy();
+    const response = s.manifestAt(SEQ, 2)!;
+    expect(response.role).toBe('response');
+    expect(response.call).toBeTruthy();
+    expect(s.responseOfRequestId(response.request!)?.manifest.call).toBe(response.call);
+  });
+
+  it('says what it cannot rebuild, and holds nothing a reference cannot answer', () => {
+    const s = store();
+    expect(() => s.bodyAt(SEQ, 99)).toThrow(PromptError);
+    expect(() => s.bodyAt(9, 1)).toThrow(PromptError);
+    // a body that refers to an earlier record is not held when that record is not there: the file it
+    // came from is what a reader must load, in landing order
+    const lines = new TextDecoder().decode(file).split('\n');
+    const records = readProviderFile(SEQ, file);
+    const refers = records.findIndex((r) => r.manifest.segments.some((seg) => seg.copy || seg.piece));
+    expect(refers).toBeGreaterThan(0);
+    const alone = new PromptStore();
+    alone.addFile({
+      seq: 9,
+      file: 'x.sd',
+      bytes: new TextEncoder().encode([lines[0], lines[records[refers]!.row], lines[lines.length - 2]].join('\n') + '\n'),
+    });
+    expect(() => alone.bodyAt(9, 1)).toThrow(PromptError);
+  });
+
+  it('takes a part as the line spells it, escapes and all', () => {
+    const line =
+      '{"ord":1,"id":"r","parts":[{"k":"data","data":{"a":"caf\\u00e9 \\"x\\"","n":1.50}},{"k":"data","data":{"schema":"provider_body/1"}}]}';
+    expect(rawPartData(line)).toEqual(['{"a":"caf\\u00e9 \\"x\\"","n":1.50}', '{"schema":"provider_body/1"}']);
+  });
+
+  it('hashes as the Sessionizer does', () => {
+    for (const text of ['', 'a', 'hello world', 'café 😀', 'x'.repeat(1000)]) {
+      const bytes = new TextEncoder().encode(text);
+      expect(sha256Hex(bytes)).toBe(createHash('sha256').update(bytes).digest('hex'));
+    }
+    expect(sha256Hex(file)).toBe(createHash('sha256').update(file).digest('hex'));
+  });
+
+  it('hashes every length around a block boundary', () => {
+    // the padding takes one more block only when the nine bytes it adds do not fit in the last one,
+    // and a length of 55, 119, 183 … is exactly the case where they do
+    for (let n = 0; n <= 260; n++) {
+      const bytes = new Uint8Array(n);
+      for (let i = 0; i < n; i++) bytes[i] = (i * 7 + 13) & 0xff;
+      expect([n, sha256Hex(bytes)]).toEqual([n, createHash('sha256').update(bytes).digest('hex')]);
+    }
+  });
+});