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 11:50:47 UTC

(skywalking-horizon-ui) branch main updated: feat(ai-conversation): show the prompt a model call sent and the answer it got (#165)

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

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


The following commit(s) were added to refs/heads/main by this push:
     new 0ce1f8d6 feat(ai-conversation): show the prompt a model call sent and the answer it got (#165)
0ce1f8d6 is described below

commit 0ce1f8d6a8484b127abf83897b0aa4f5f567f363
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Wed Sep 16 19:50:40 2026 +0800

    feat(ai-conversation): show the prompt a model call sent and the answer it got (#165)
---
 .github/workflows/e2e.yaml                         |   15 +
 .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     |   97 +-
 apps/ui/src/api/scopes/ai-conversation.ts          |  208 +++
 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   |   42 +
 docs/changelog/1.1.0.md                            |    4 +-
 docs/operate/ai-agent-conversations.md             |   21 +-
 packages/api-client/src/ai-conversation.ts         |    4 +
 packages/api-client/src/index.ts                   |    2 +
 packages/conversation-view/src/index.ts            |   28 +-
 packages/conversation-view/src/model.ts            |    4 +
 packages/conversation-view/src/prompt/cache.ts     |  121 ++
 packages/conversation-view/src/prompt/messages.ts  |  286 +++++
 packages/conversation-view/src/prompt/sha256.ts    |   92 ++
 packages/conversation-view/src/prompt/store.ts     |  505 ++++++++
 packages/conversation-view/src/strings.ts          |   85 ++
 packages/conversation-view/src/styles.css          |   48 +-
 packages/conversation-view/src/types.ts            |   15 +
 packages/conversation-view/src/view/changes.ts     |    7 +
 packages/conversation-view/src/view/chrome.ts      |    5 +-
 packages/conversation-view/src/view/context.ts     |   17 +-
 packages/conversation-view/src/view/inspector.ts   |   33 +-
 packages/conversation-view/src/view/prompt.ts      |  531 ++++++++
 .../test/fixtures/provider-bodies.json             | 1318 ++++++++++++++++++++
 .../test/fixtures/provider-bodies.sd               |   16 +
 .../conversation-view/test/prompt-guards.test.ts   |  159 +++
 .../conversation-view/test/prompt-panel.test.ts    |  292 +++++
 .../conversation-view/test/prompt-store.test.ts    |  128 ++
 test/e2e/cases/ai-agent/docker-compose.yml         |  122 ++
 test/e2e/cases/ai-agent/e2e.yaml                   |  100 ++
 .../cases/ai-agent/expected/ai-agent-service.yml   |   18 +
 .../ai-agent/expected/conversation-listed.yml      |   25 +
 .../cases/ai-agent/expected/conversation-whole.yml |   20 +
 test/e2e/cases/ai-agent/expected/passed.yml        |   18 +
 test/e2e/cases/ai-agent/provider-bodies.yaml       |   50 +
 test/e2e/playwright/playwright.config.ts           |    8 +
 .../playwright/specs/ai-agent/conversation.spec.ts |  170 +++
 test/e2e/playwright/specs/fixture.ts               |   19 +
 test/e2e/script/docker-compose/base-compose.yml    |    3 +
 test/e2e/script/env                                |    9 +-
 test/e2e/script/prepare/horizon-get.sh             |   12 +-
 55 files changed, 5226 insertions(+), 38 deletions(-)

diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml
index 2fbf3e50..b65687c8 100644
--- a/.github/workflows/e2e.yaml
+++ b/.github/workflows/e2e.yaml
@@ -104,6 +104,19 @@
 #             EvaluationRecordStreamPanel, TracePopout, ZipkinTracePopout,
 #             LayerWidgetTile's card branch
 #
+#   ai-agent  plus a real AI Sessionizer, pushing one conversation   ACTIVE
+#             the `provider-bodies` scenario built and pushed once, which is
+#             a chain of rounds rather than traffic: talks with an input and
+#             a reply, a shell command run by a child agent, and the request
+#             and response bodies behind every model call
+#             tests: the AI_AGENT layer's Conversations tab and one
+#             conversation drawn in full — the transcript both ways, a Bash
+#             call as the lines it ran and what it returned, and the Prompt
+#             tab rebuilding a provider body from the files route, reading
+#             the session once however many calls are opened
+#             components: LayerConversationsView, ConversationPageView,
+#             ConversationViewHost, the conversation-view renderer
+#
 #   audit-banyandb  OAP + BanyanDB, and the audit stored there   ACTIVE
 #             The same four audit assertions `core` runs against PostgreSQL,
 #             against the OTHER backend — one suite, two deployments, so a
@@ -223,6 +236,8 @@ jobs:
             config: test/e2e/cases/browser/e2e.yaml
           - name: genai (+ LLM judge, native and OTLP traces)
             config: test/e2e/cases/genai/e2e.yaml
+          - name: ai-agent (+ AI Sessionizer conversation)
+            config: test/e2e/cases/ai-agent/e2e.yaml
           - name: admin (isolated, mutating)
             config: test/e2e/cases/admin/e2e.yaml
           - name: audit-banyandb (audit on BanyanDB)
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..67fc52c4 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,98 @@ 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('ends the body when it refuses a frame, rather than leaving it streaming', async () => {
+    // A body nobody will read must not stay open: the browser keeps receiving it and the relay
+    // behind it keeps asking OAP for the rest.
+    const bytes = new TextEncoder().encode('not a naming line\n');
+    let cancelled = false;
+    const stream = new ReadableStream<Uint8Array>({
+      start(c) {
+        c.enqueue(bytes);
+      },
+      cancel() {
+        cancelled = true;
+      },
+    });
+    await expect(readFiles(stream, () => undefined)).rejects.toThrow();
+    expect(cancelled).toBe(true);
+    expect(stream.locked).toBe(false);
+  });
+
+  it('reads a file of megabytes without copying it again for every chunk', async () => {
+    // Joining each chunk onto the whole would copy about 130 MB for these 2 MB, so a regression
+    // shows as this test running out of time rather than as a stopwatch reading, which under a
+    // parallel suite says more about the machine than about the code.
+    const big = `${'y'.repeat(2 * 1024 * 1024)}\n`;
+    const files = await read(frame('big.sd', 9, big), 16 * 1024);
+    expect(files[0]!.text.length).toBe(big.length);
+  });
+
+  it('reads a file that arrives in very many small chunks', async () => {
+    // Dropping a consumed chunk by shifting the array moves every chunk still held, which is the
+    // same quadratic cost the copying had, just paid on references. These are 131,000 chunks, which
+    // is seconds of moving them, so a regression runs the test out of time rather than being timed.
+    const big = `${'z'.repeat(2 * 1024 * 1024)}\n`;
+    const files = await read(frame('many.sd', 3, big), 16);
+    expect(files[0]!.text.length).toBe(big.length);
+  });
+});
diff --git a/apps/ui/src/api/scopes/ai-conversation.ts b/apps/ui/src/api/scopes/ai-conversation.ts
index 0026495b..ebd0c6d5 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,163 @@ 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();
+  try {
+    await frame(reader, onFile);
+  } catch (err) {
+    // A body this reader refuses is a body nobody will read: the request is ended rather than left
+    // running, so the browser stops receiving it and the relay behind it stops asking OAP for more.
+    // Cancelling stops the stream; the lock is the reader's own and is given back after it.
+    await reader.cancel().catch(() => undefined);
+    try {
+      reader.releaseLock();
+    } catch {
+      // already released
+    }
+    throw err;
+  }
+}
+
+async function frame(
+  reader: ReadableStreamDefaultReader<Uint8Array>,
+  onFile: (file: { seq: number; file: string; bytes: Uint8Array }) => void,
+): Promise<void> {
+  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[] = [];
+  /** The chunk being read, and how far into it has been handed over. Shifting the array instead
+   *  would move every chunk still held on each one consumed, which on a file arriving in thousands
+   *  of small chunks is the same quadratic cost the copying had. */
+  private head = 0;
+  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 (let i = this.head; i < this.chunks.length; i++) {
+      const c = this.chunks[i]!;
+      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 (let i = this.head; i < this.chunks.length; i++) {
+      const c = this.chunks[i]!;
+      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[this.head]!;
+      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[this.head]!.length - this.from));
+  }
+
+  private advance(step: number): number {
+    this.from += step;
+    this.length -= step;
+    if (this.from >= this.chunks[this.head]!.length) {
+      this.chunks[this.head] = EMPTY; // let the bytes go before the array is tidied
+      this.head++;
+      this.from = 0;
+      // Tidy once the read chunks outnumber the held ones, so the array never grows without bound
+      // and no chunk is moved more than once.
+      if (this.head > 32 && this.head * 2 > this.chunks.length) {
+        this.chunks = this.chunks.slice(this.head);
+        this.head = 0;
+      }
+    }
+    return step;
+  }
+}
+
+const EMPTY = new Uint8Array(0);
+
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..d1169659 100644
--- a/apps/ui/src/layer/ai-conversation/conversationStrings.ts
+++ b/apps/ui/src/layer/ai-conversation/conversationStrings.ts
@@ -228,6 +228,48 @@ 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}' }),
+    promptMessageOf: t('{n} of {total}', { n: '{n}', total: '{total}' }),
+    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/changelog/1.1.0.md b/docs/changelog/1.1.0.md
index fc015a65..738a560f 100644
--- a/docs/changelog/1.1.0.md
+++ b/docs/changelog/1.1.0.md
@@ -22,9 +22,11 @@
 
 **The Conversations tab says what each conversation did.** Its columns are now Talks, **Model calls**, **Subagents**, **Bash runs** and **Changes**: the change records the Sessionizer captured as of the newest round, one per observation (a watched tool call per workspace root, the agent's own patch of an edit, a scan that found nothing, a change seen between calls that no call made), with the plus-over-minus mark and the lines those records added and removed, summed over the records, bes [...]
 
+**The conversation page shows the prompt each model call sent, and the answer it got.** When the AI Sessionizer captured the bodies a call exchanged with its model provider, the call's step gains a **Prompt** tab: the exact request as it was sent and the exact response as it came back, neither of which a transcript holds. The bodies are not part of the conversation document — they are megabytes per call — so the tab opens on an offer stating how many stored files of the session it needs  [...]
+
 **The inspector says where a step sits in words.** Its Details tab opens with the step's place: the activity window and its number, the agent, the talk by its opening line and its place in the stream, the run's place in the talk, and the step by kind and name, instead of a chain of clipped identifiers; the identifiers stay in the tooltip.
 
-**Tool inputs and results read as fields.** On the conversation page, a tool call's card and its Details tab draw the input the agent recorded as the fields it holds instead of one escaped JSON string: a shell command as the lines that were run, an edit as its path and the text before and after, a result's output and error streams apart. A long text is cut short with a *show all* link that opens it in place, and where the document clipped the text a note says how much of it is there. Eve [...]
+**Tool inputs and results read as fields.** On the conversation page, a tool call's card and its Details tab draw the input the agent recorded as the fields it holds instead of one escaped JSON string: a shell command as the lines that were run, an edit as its path and the text before and after, a result's output and error streams apart. A long text is cut short with a *show all* link that opens it in place, and where the document clipped the text a note says how much of it is there. Eve [...]
 
 ## GenAI evaluation
 
diff --git a/docs/operate/ai-agent-conversations.md b/docs/operate/ai-agent-conversations.md
index 5046608c..6d296578 100644
--- a/docs/operate/ai-agent-conversations.md
+++ b/docs/operate/ai-agent-conversations.md
@@ -66,7 +66,7 @@ The page has three parts, and a header with the conversation's title, agent, age
 
 - **Transcript** — the talks of one execution stream in reading order. What came from outside sits on the right; the agent's replies sit on the left; the work between an input and its reply (model calls, reasoning, tool uses, agent launches, context resets) is folded under a *show what the agent did* row so a long run reads as a conversation until you open it. Long pauses are marked. A tool call's input and result are drawn as the fields the runtime recorded rather than as one escaped st [...]
 - **Flow timeline** — the same stream on a time axis, one lane per kind of activity (external input, responses, context put in, model calls, tools, agent activity, runtime notices, nested streams). Busy stretches take the width; a long pause is cut to a marked gap. Selecting a step draws the relations that touch it: a solid line is an exact join, a dashed one was inferred, a faint curve is ownership (the model call that produced a step). A child agent appears as a nested stream you can s [...]
-- **Inspector** — the selected step's **Details**, opening with where the step sits in words a reader knows (the activity window and its place among them, the agent, the talk by its opening line, the run's place in it, and the step by kind and name; the document's own ids sit in the tooltip), then its kind, lane, stream, segment, run, parent, time, token counts, request-to-result interval where the runtime recorded it, and its input and result drawn as fields the way the transcript draws [...]
+- **Inspector** — the selected step's **Details**, opening with where the step sits in words a reader knows (the activity window and its place among them, the agent, the talk by its opening line, the run's place in it, and the step by kind and name; the document's own ids sit in the tooltip), then its kind, lane, stream, segment, run, parent, time, token counts, request-to-result interval where the runtime recorded it, and its input and result drawn as fields the way the transcript draws [...]
 
 ### Changes
 
@@ -82,10 +82,25 @@ 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  [...]
+
+Each message is drawn in a box of its own, headed by its role and its place in the request — *user, 1 of 3* — because one message can hold several blocks (an injected reminder and the text beside it, or an answer and the tool it then used) and the boxes are what keep the count honest. Inside a message every block is drawn the same way, labelled with what it is over an edge in the page's colour for that kind, so a label belongs to the block under it and never to the message: the text a ru [...]
+
+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. A tool result the provider marked failed says so. A long text is cut short with a *show all* link that opens it in place, as elsewhere on the page, and 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 +110,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..9fd0bc1d 100644
--- a/packages/conversation-view/src/index.ts
+++ b/packages/conversation-view/src/index.ts
@@ -31,11 +31,13 @@
 
 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';
 import type { AszRef, AszViewDocument, Glossary, LandedRecord } from './types.js';
-import { drawChangesPanel, symbolDefs } from './view/changes.js';
+import { drawChangesPanel, icon, ICON_POP_OUT, symbolDefs } from './view/changes.js';
 import {
   drawOverview,
   drawStatus,
@@ -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;
 }
@@ -119,7 +131,7 @@ function skeleton(s: ViewStrings): string {
         <div class="acv-inspector-head">
           <div class="acv-inspector-headrow">
             <div class="acv-heading"><span class="acv-kicker">${esc(s.inspector)}</span><h2 class="acv-inspector-title">—</h2></div>
-            <button type="button" class="acv-btn acv-pop-btn" aria-pressed="false" title="${esc(s.popOutInspector)}">⤢</button>
+            <button type="button" class="acv-btn acv-pop-btn" aria-pressed="false" title="${esc(s.popOutInspector)}">${icon(ICON_POP_OUT)}</button>
           </div>
           <div class="acv-inspector-meta"></div>
         </div>
@@ -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..37321a36
--- /dev/null
+++ b/packages/conversation-view/src/prompt/cache.ts
@@ -0,0 +1,121 @@
+/*
+ * 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[];
+  /**
+   * The seqs answered but still not held: their records refer to a file that is not there, so the store
+   * kept none of them. Asking again brings the same bytes and the same answer, so the panel must not
+   * offer the read again - but a later read that brings the missing file makes them readable, which is
+   * why they are named apart from the ones the server never sent.
+   */
+  unresolved: 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: [], unresolved: [] };
+    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);
+          } else if (!store.has(seq)) {
+            outcome.unresolved.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..282745eb
--- /dev/null
+++ b/packages/conversation-view/src/prompt/messages.ts
@@ -0,0 +1,286 @@
+/*
+ * 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 {
+  // Object.create(null), not {}: a body may carry a field called __proto__, and assigning that into
+  // an ordinary object changes the object's prototype instead of keeping the value.
+  const rest: Record<string, unknown> = Object.create(null) as 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> = Object.create(null) as 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> = Object.create(null) as 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..12e4c86e
--- /dev/null
+++ b/packages/conversation-view/src/prompt/store.ts
@@ -0,0 +1,505 @@
+/*
+ * 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();
+// Fatal, so an invalid byte ends the file instead of becoming a replacement character. A part's
+// bytes are what its digest covers, and a decoder that mends them would verify bytes the record
+// never held.
+const decoder = new TextDecoder('utf-8', { fatal: true });
+
+/** 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
+    }
+    let text: string;
+    let node: Record<string, unknown>;
+    try {
+      text = decoder.decode(line);
+      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;
+  // Every other field the panel shows or joins by is a string where it is there at all. A number or
+  // an object under one of these reaches the page as whatever printing it produces, and the
+  // Sessionizer's own decoder refuses the record rather than carry it.
+  for (const name of ['src', 'chain', 'why', 'model', 'session', 'run', 'call', 'request', 'previous_request'] as const) {
+    if (m[name] !== undefined && typeof m[name] !== 'string') 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() === '[') {
+        // A record naming `parts` twice is what its last one says, which is what a JSON reader
+        // keeps and what the record's own manifest was written against.
+        out.length = 0;
+        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..a47302f0 100644
--- a/packages/conversation-view/src/strings.ts
+++ b/packages/conversation-view/src/strings.ts
@@ -234,6 +234,49 @@ 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;
+  promptMessageOf: 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 +539,48 @@ 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})',
+  promptMessageOf: '{n} of {total}',
+  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..12e4478c 100644
--- a/packages/conversation-view/src/styles.css
+++ b/packages/conversation-view/src/styles.css
@@ -345,7 +345,8 @@ select.acv-stream-btn { border: 1px solid var(--acv-line); background: var(--acv
 /* ---------- inspector ---------- */
 .acv-inspector-head { padding: 8px 10px 7px; border-bottom: 1px solid var(--acv-line); }
 .acv-inspector-headrow { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
-.acv-pop-btn { flex: none; padding: 2px 7px; font-size: 12px; line-height: 1.2; }
+.acv-pop-btn { display: inline-flex; align-items: center; justify-content: center; flex: none; padding: 4px 7px; line-height: 1; }
+.acv-pop-btn .acv-ico { width: 13px; height: 13px; vertical-align: 0; }
 .acv-main { position: relative; }
 .acv-popped .acv-main { grid-template-columns: minmax(0, 1fr) 0 0; }
 .acv-popped .acv-split-inspector { display: none; }
@@ -483,3 +484,48 @@ 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; flex-wrap: wrap; margin: 2px 0 4px; }
+/* The panel's own segmented controls: which side of the call is shown, and how a
+   request is shown. Every colour is a theme token, so they follow the theme the
+   host set; a button with no rule of its own falls back to the browser's grey,
+   which is the one thing that cannot follow it. */
+.acv-chip { min-height: 26px; padding: 3px 11px; border: 1px solid var(--acv-line); border-radius: 999px; background: var(--acv-surface-2); color: var(--acv-muted); cursor: pointer; font-size: 11px; }
+.acv-chip:hover { color: var(--acv-text); background: var(--acv-surface-3); }
+.acv-chip.on { border-color: color-mix(in srgb, var(--acv-focus) 50%, var(--acv-line)); background: color-mix(in srgb, var(--acv-focus) 14%, var(--acv-surface-2)); color: var(--acv-text); }
+.acv-chip.on:hover { background: color-mix(in srgb, var(--acv-focus) 22%, var(--acv-surface-2)); }
+.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: 10px 0; border: 1px solid var(--acv-line); border-radius: 7px; overflow: hidden; }
+.acv-prompt-message-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; padding: 5px 9px; border-bottom: 1px solid var(--acv-line); background: var(--acv-surface-2); }
+.acv-prompt-message-body { padding: 2px 9px 8px; }
+.acv-prompt-role { color: var(--acv-faint); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
+.acv-prompt-block { margin: 6px 0; }
+/* Every block of a message is the same card, whether it holds prose or JSON; only
+   the edge changes, and it takes the page's own colour for that kind of step. A
+   block that looked like loose text beside a card read as part of its neighbour. */
+.acv-prompt-block > .acv-block { margin: 4px 0 0; }
+.acv-prompt-block > .acv-raw { margin: 4px 0 0; padding: 9px 11px; border-left: 3px solid var(--acv-line); background: var(--acv-surface-2); }
+.acv-prompt-block.tool > .acv-raw,
+.acv-prompt-block.tool > .acv-block { border-left-color: var(--acv-kind-tool); }
+.acv-prompt-block.tool > .acv-kicker { color: var(--acv-kind-tool); }
+.acv-prompt-block.injected > .acv-block { border-left-color: var(--acv-kind-instruction); }
+.acv-prompt-block.injected > .acv-kicker { color: var(--acv-kind-instruction); }
+.acv-prompt-block.thinking > .acv-block { border-left-color: var(--acv-kind-annotation); }
+.acv-prompt-block.thinking > .acv-kicker { color: var(--acv-kind-annotation); }
+.acv-prompt-block.failed > .acv-raw,
+.acv-prompt-block.failed > .acv-block { border-left-color: var(--sw-warn); }
+.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/changes.ts b/packages/conversation-view/src/view/changes.ts
index aceeb492..08ae0d7a 100644
--- a/packages/conversation-view/src/view/changes.ts
+++ b/packages/conversation-view/src/view/changes.ts
@@ -44,6 +44,11 @@ import type { ViewContext } from './context.js';
 
 export const ICON_CHANGES = 'acv-i-changes';
 export const ICON_READONLY = 'acv-i-readonly';
+/** The inspector's two states. They are one icon each, not one glyph turned
+ *  around: ⤡ and ⤢ are the same double-headed arrow on the other diagonal, so a
+ *  reader cannot tell from them whether the button opens the panel or closes it. */
+export const ICON_POP_OUT = 'acv-i-pop-out';
+export const ICON_DOCK = 'acv-i-dock';
 
 /** The two marks, defined once and referenced everywhere they appear: a
  *  plus over a minus for a call that changed files, an eye for one the
@@ -52,6 +57,8 @@ export function symbolDefs(): string {
   return `<svg class="acv-defs" aria-hidden="true" focusable="false">
     <symbol id="${ICON_CHANGES}" viewBox="0 0 16 16"><path d="M3 4.5h10M8 1.5v6M3 12h10" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></symbol>
     <symbol id="${ICON_READONLY}" viewBox="0 0 16 16"><path d="M1.5 8s2.6-4.5 6.5-4.5S14.5 8 14.5 8s-2.6 4.5-6.5 4.5S1.5 8 1.5 8z" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="2.1" fill="currentColor"/></symbol>
+    <symbol id="${ICON_POP_OUT}" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2.5h4v4"/><path d="M6.5 13.5h-4v-4"/><path d="M13.5 2.5 9 7"/><path d="M2.5 13.5 7 9"/></g></symbol>
+    <symbol id="${ICON_DOCK}" viewBox="0 0 16 16"><g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 6.5h-4v-4"/><path d="M2.5 9.5h4v4"/><path d="M9.5 6.5 14 2"/><path d="M6.5 9.5 2 14"/></g></symbol>
   </svg>`;
 }
 
diff --git a/packages/conversation-view/src/view/chrome.ts b/packages/conversation-view/src/view/chrome.ts
index 5e75dfbb..f38d1253 100644
--- a/packages/conversation-view/src/view/chrome.ts
+++ b/packages/conversation-view/src/view/chrome.ts
@@ -23,7 +23,7 @@
 
 import { esc } from '../dom.js';
 import { fill } from '../strings.js';
-import { icon, ICON_CHANGES } from './changes.js';
+import { icon, ICON_CHANGES, ICON_DOCK, ICON_POP_OUT } from './changes.js';
 import { streamName, type ViewContext } from './context.js';
 
 export function drawStatus(ctx: ViewContext): void {
@@ -119,7 +119,8 @@ export function setInspectorPopped(ctx: ViewContext, on: boolean): void {
   const btn = ctx.q('.acv-pop-btn');
   btn.setAttribute('aria-pressed', String(on));
   btn.title = on ? ctx.s.dockInspector : ctx.s.popOutInspector;
-  btn.textContent = on ? '⤡' : '⤢';
+  // Two icons, not one glyph turned around: popped out, the button docks.
+  btn.innerHTML = icon(on ? ICON_DOCK : ICON_POP_OUT);
 }
 
 export function drawOverview(ctx: ViewContext): void {
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..3c916de2
--- /dev/null
+++ b/packages/conversation-view/src/view/prompt.ts
@@ -0,0 +1,531 @@
+/*
+ * 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, and the same for a JSON value, which
+ *  is read in its shape rather than as prose and takes more of it. */
+const PREVIEW = 600;
+const JSON_PREVIEW = 4000;
+
+/** 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, and what has been asked is asked of each file, not of the
+  // session: a call further on needs files the read before it never covered, and those are offered.
+  // A file the read already covered is not offered again, whether the server left it out or answered
+  // it with records that refer to a file which is not there: asking brings the same answer either way,
+  // and the panel draws the bodies that did rebuild over a note naming what is not there. A read that
+  // ended in an error is offered again whatever it covered, with what went wrong.
+  const covered = new Set([...(outcome?.missing ?? []), ...(outcome?.unresolved ?? [])]);
+  const unasked = outcome?.failed ? absent : absent.filter((seq) => !covered.has(seq));
+  if (unasked.length) {
+    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;
+  try {
+    panel = drawSide(ctx, e, read, sides, store, ref, failure);
+  } catch (err) {
+    // A body that verified can still be one this panel cannot draw - nested past what JSON.stringify
+    // will walk, say. It says so, rather than leaving the tab empty.
+    panel = `<div class="acv-warning">${esc(s.promptRebuildFailed)}<br>${esc(err instanceof Error ? err.message : String(err))}</div>`;
+  }
+
+  body.innerHTML = `${missing}${head}${panel}`;
+  wire(ctx, body);
+}
+
+/** The side the reader is on, drawn. Separated so that anything it throws is caught in one place. */
+function drawSide(
+  ctx: ViewContext,
+  e: Step,
+  read: ReadBody | null,
+  sides: Sides,
+  store: ReturnType<ViewContext['prompts']['store']>,
+  ref: AszRef | undefined,
+  failure: string,
+): string {
+  const { s, state } = ctx;
+  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, e, 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 }, `${e.id}|${state.promptSide}|raw`), true);
+  } else {
+    panel = section(ctx, e, 'raw', s.promptWholeBody, '', json(ctx, read.raw, `${e.id}|${state.promptSide}|raw`), true);
+  }
+  return panel;
+}
+
+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, e, deltaOf(previous, read), read)}`;
+  }
+  const system = read.system.length
+    ? section(ctx, e, 'system', s.promptSystem, blockSummary(ctx, read.system), read.system.map((b, i) => block(ctx, b, `${e.id}|req|sys|${i}`)).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, i) => tool(ctx, t, `${e.id}|req|tool|${i}`)).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, i) => message(ctx, m, i + 1, read.messages.length, `${e.id}|req|msg|${i}`)).join(''),
+    true,
+  );
+  const settings = section(ctx, e, 'settings', s.promptSettings, Object.keys(read.rest).join(', '), json(ctx, read.rest, `${e.id}|req|set`));
+  const whole = section(ctx, e, 'request-raw', s.promptWholeBody, '', json(ctx, read.raw, `${e.id}|req|raw`));
+  return `${modes}${system}${tools}${messages}${settings}${whole}`;
+}
+
+/** 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, e: Step, 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, i) => message(ctx, m, delta.sharedMessages + i + 1, read.messages.length, `${e.id}|req|msg|${delta.sharedMessages + i}`))
+        .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, e: Step, 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(' ');
+  // Every count the provider reported, not only the whole numbers among them: a nested one, such as
+  // what a cache write cost, is shown as the JSON it is rather than left out.
+  const usage = read.usage
+    ? `<div class="acv-provenance">${Object.entries(read.usage)
+        .map(
+          ([k, v]) =>
+            `<span class="acv-source-badge">${esc(k)} ${esc(
+              typeof v === 'number' || typeof v === 'string' ? String(v) : JSON.stringify(v) ?? '',
+            )}</span>`,
+        )
+        .join('')}</div>`
+    : '';
+  const blocks = read.blocks.map((b, i) => block(ctx, b, `${e.id}|res|${i}`)).join('');
+  // What the sections above do not show: the model, the type, a stop sequence, and whatever else the
+  // provider sent. A body that verified is shown whole or not at all.
+  const rest = Object.keys(read.rest).length
+    ? section(ctx, e, 'response-settings', s.promptSettings, Object.keys(read.rest).join(', '), json(ctx, read.rest, `${e.id}|res|rest`))
+    : '';
+  const whole = section(ctx, e, 'response-raw', s.promptWholeBody, '', json(ctx, read.raw, `${e.id}|res|raw`));
+  return `<div class="acv-prompt-facts">${facts}</div>${usage}${blocks}${rest}${whole}`;
+}
+
+/** 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>`;
+}
+
+/**
+ * One message, in a box of its own with its place in the request on it.
+ *
+ * A message holds one block or several - a reminder and the text beside it, an answer and the tool
+ * it then used - and without the box a reader counts the blocks and finds more of them than the
+ * section says there are messages.
+ */
+function message(ctx: ViewContext, m: PromptMessage, n: number, total: number, key: string): string {
+  const { s } = ctx;
+  return `
+    <div class="acv-prompt-message">
+      <div class="acv-prompt-message-head">
+        <span class="acv-prompt-role">${esc(m.role || s.promptUnknownRole)}</span>
+        <span class="acv-faint">${esc(fill(s.promptMessageOf, { n: String(n), total: String(total) }))}</span>
+      </div>
+      <div class="acv-prompt-message-body">${m.blocks.map((b, i) => block(ctx, b, `${key}|${i}`)).join('')}</div>
+    </div>`;
+}
+
+/**
+ * One block of a message.
+ *
+ * Every block is drawn the same way, whatever it holds: a label saying what it is, then the content
+ * in a card of its own with a coloured edge naming its kind, the page's own colour for that kind.
+ * A message holds several of these, and a reader has to be able to see where one ends — which text
+ * the runtime injected, and which of them the person wrote.
+ */
+function block(ctx: ViewContext, b: PromptBlock, key: string): 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 : '';
+    // The label is the block's own, never the message's: a message whose first block the runtime
+    // injected usually carries the person's text as its second, and the two must not read alike.
+    const mark = b.kind === 'thinking' ? ' thinking' : b.reminder ? ' injected' : '';
+    return `
+      <div class="acv-prompt-block${mark}">
+        ${label ? `<div class="acv-kicker">${esc(label)}</div>` : ''}
+        ${textBlock(ctx, text, key)}
+      </div>`;
+  }
+  if (b.kind === 'tool_use') {
+    return `
+      <div class="acv-prompt-block tool">
+        <div class="acv-kicker">${esc(fill(s.promptToolUse, { name: b.name ?? '' }))}${blockId(ctx, b)}</div>
+        ${json(ctx, b.json, key)}
+      </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 tool${b.failed ? ' failed' : ''}">
+        <div class="acv-kicker">${esc(b.failed ? s.promptToolFailed : s.promptToolResult)}${blockId(ctx, b)}</div>
+        ${b.text != null ? textBlock(ctx, b.text, key) : json(ctx, b.json, key)}
+      </div>`;
+  }
+  return `<div class="acv-prompt-block"><div class="acv-kicker">${esc(b.kind)}</div>${json(ctx, b.json, key)}</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 }, key: string): string {
+  const first = t.description.split('\n')[0] ?? '';
+  return `
+    <div class="acv-prompt-tool">
+      <div class="acv-prompt-tool-name mono">${esc(t.name)}</div>
+      <div class="acv-faint">${esc(first.length > 200 ? `${first.slice(0, 200)}…` : first)}</div>
+      ${json(ctx, t.raw, key)}
+    </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) });
+}
+
+/**
+ * A text, cut short until the reader asks for the rest.
+ *
+ * The whole text is always in the page, hidden beside the button that copies it, so the copy is of
+ * everything whatever is shown. What the reader opened is remembered per text, the way the
+ * transcript remembers it, so it survives moving between the request and the response.
+ */
+function textBlock(ctx: ViewContext, text: string, key: string): string {
+  const { s, state } = ctx;
+  const long = text.length > PREVIEW;
+  const open = long && state.openTexts.has(key);
+  const more = long && !open ? `<button type="button" class="acv-linkish acv-text-more" data-text-toggle="${esc(key)}">${esc(s.showAllLines)}</button>` : '';
+  return `<div class="acv-block" data-copy-scope>${esc(open || !long ? text : `${text.slice(0, PREVIEW)}…`)}${copyButton(
+    s,
+  )}<span class="acv-copy-src" hidden>${esc(text)}</span></div>${more}`;
+}
+
+function json(ctx: ViewContext, value: unknown, key: string): string {
+  const { s, state } = ctx;
+  const text = JSON.stringify(value, null, 2) ?? '';
+  const long = text.length > JSON_PREVIEW;
+  const open = long && state.openTexts.has(key);
+  const more = long && !open ? `<button type="button" class="acv-linkish acv-text-more" data-text-toggle="${esc(key)}">${esc(s.showAllLines)}</button>` : '';
+  return `<pre class="acv-raw" data-copy-scope>${esc(open || !long ? text : `${text.slice(0, JSON_PREVIEW)}…`)}${copyButton(
+    s,
+  )}<span class="acv-copy-src" hidden>${esc(text)}</span></pre>${more}`;
+}
+
+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();
+    };
+  });
+  body.querySelectorAll<HTMLElement>('[data-text-toggle]').forEach((b) => {
+    b.onclick = () => {
+      ctx.state.openTexts.add(b.dataset.textToggle!);
+      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..908c84a0
--- /dev/null
+++ b/packages/conversation-view/test/prompt-guards.test.ts
@@ -0,0 +1,159 @@
+/*
+ * 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('takes the last parts array, as every JSON reader does', () => {
+    // A record naming `parts` twice parses to the last one, so the manifest beside it describes that
+    // body; taking the parts of both would rebuild and verify a body the record does not hold.
+    const good = { schema: 'provider_body/1', role: 'request', sha256: EMPTY_OBJECT, bytes: 2, depth: 0, segments: [{ part: 0 }] };
+    const line =
+      `{"ord":1,"id":"two","parts":[{"k":"data","data":{"a":1}},{"k":"data","data":${JSON.stringify(good)}}],` +
+      `"parts":[{"k":"data","data":{}},{"k":"data","data":${JSON.stringify(good)}}]}`;
+    const store = new PromptStore();
+    store.addFile({ seq: 1, file: 'x.sd', bytes: new TextEncoder().encode([HEADER, line, END].join('\n') + '\n') });
+    expect(new TextDecoder().decode(store.bodyAt(1, 1))).toBe('{}');
+  });
+
+  it('refuses a line whose bytes are not the text they claim to be', () => {
+    // A decoder that mends an invalid byte hands the reader bytes the record never held, and the
+    // digest is over the record's own bytes.
+    const good = { schema: 'provider_body/1', role: 'request', sha256: EMPTY_OBJECT, bytes: 2, depth: 0, segments: [{ lit: '{}' }] };
+    const head = new TextEncoder().encode(`${HEADER}\n`);
+    const line = new TextEncoder().encode(record('a', good));
+    line[line.indexOf(0x7b) + 1] = 0xff; // an invalid byte inside the line
+    const tail = new TextEncoder().encode(`\n${END}\n`);
+    const bytes = new Uint8Array([...head, ...line, ...tail]);
+    const store = new PromptStore();
+    store.addFile({ seq: 1, file: 'x.sd', bytes });
+    expect(() => store.bodyAt(1, 1)).toThrow(PromptError);
+  });
+
+  it('refuses a manifest whose fields are not the types it reads them as', () => {
+    const odd = {
+      schema: 'provider_body/1', role: 'request', sha256: EMPTY_OBJECT, bytes: 2, depth: 0,
+      model: { toString: 0 }, segments: [{ lit: '{}' }],
+    };
+    const store = new PromptStore();
+    store.addFile({ seq: 1, file: 'x.sd', bytes: new TextEncoder().encode([HEADER, record('a', odd), END].join('\n') + '\n') });
+    expect(store.manifestAt(1, 1)).toBeNull();
+  });
+
+  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 top-level __proto__, not only a nested one', () => {
+    const one = readBody(new TextEncoder().encode('{"model":"m","messages":[],"__proto__":{"x":1}}'), 'request') as ReadRequest;
+    const two = readBody(new TextEncoder().encode('{"model":"m","messages":[],"__proto__":{"x":2}}'), 'request') as ReadRequest;
+    expect(Object.keys(one.rest)).toContain('__proto__');
+    expect(deltaOf(one, two).settingsChanged).toBe(true);
+  });
+
+  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..a3bb57be
--- /dev/null
+++ b/packages/conversation-view/test/prompt-panel.test.ts
@@ -0,0 +1,292 @@
+/*
+ * 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('draws each message in a box that says its place, and opens a long one on request', async () => {
+    const root = mount();
+    tab(root, 'prompt')!.click();
+    panel(root).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
+    await new Promise((r) => setTimeout(r, 0));
+    const boxes = panel(root).querySelectorAll('.acv-prompt-message');
+    // the three messages of this request, however many blocks each holds
+    expect(boxes).toHaveLength(3);
+    expect(boxes[0]!.querySelector('.acv-prompt-message-head')!.textContent).toContain('user');
+    expect(boxes[0]!.querySelector('.acv-prompt-message-head')!.textContent).toContain('1 of 3');
+    expect(boxes[2]!.querySelector('.acv-prompt-message-head')!.textContent).toContain('3 of 3');
+    // the injected reminder is long, so it is cut short with a way to read the rest
+    const more = boxes[0]!.querySelector<HTMLButtonElement>('[data-text-toggle]')!;
+    const clipped = boxes[0]!.querySelector('.acv-block')!.textContent!;
+    expect(clipped).toContain('…');
+    more.click();
+    const opened = panel(root).querySelector('.acv-prompt-message .acv-block')!.textContent!;
+    expect(opened.length).toBeGreaterThan(clipped.length);
+    expect(opened).not.toContain('…');
+    expect(panel(root).querySelector('.acv-prompt-message [data-text-toggle]')).toBeNull();
+  });
+
+  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('offers the files a later call needs, even after a read that went well', async () => {
+    // A read covers the files up to the call it was made for. A call further on needs the ones after
+    // them, which were never asked for, so it must offer them - not report them as not stored.
+    const later = JSON.parse(JSON.stringify(doc)) as AszViewDocument;
+    const first = later.files!.find((f) => f.kind === 'provider_body')!;
+    later.files!.push({ ...first, file: first.file.replace('000004', '000005'), seq: 5, bytes: 100 });
+    const walk = (nodes: AszViewDocument['talks']): boolean => {
+      for (const n of nodes) {
+        const bodies = (n as { provider_bodies?: Array<{ role: string; ref: { seq: number; row: number } }> }).provider_bodies;
+        if (bodies?.length && n.id !== CALL) {
+          for (const b of bodies) b.ref = { seq: 5, row: b.ref.row };
+          return true;
+        }
+        if (walk(n.children ?? [])) return true;
+      }
+      return false;
+    };
+    expect(walk(later.talks)).toBe(true);
+    const moved = later.talks.flatMap(function ids(n: AszViewDocument['talks'][number]): string[] {
+      const bodies = (n as { provider_bodies?: unknown[] }).provider_bodies;
+      return [...(bodies?.length && n.id !== CALL ? [n.id] : []), ...(n.children ?? []).flatMap(ids)];
+    })[0]!;
+
+    asked = [];
+    host = document.createElement('div');
+    document.body.appendChild(host);
+    view = mountConversationView(host, {
+      document: later,
+      loadFiles: async (request: { session: string; seqs: number[] }, onFile: (f: StoredFile) => void) => {
+        asked.push({ session: request.session, seqs: request.seqs });
+        // only the first file is stored; the second is answered when it is asked for
+        for (const seq of request.seqs) onFile({ seq, file: `${request.session}/provider_body/x.sd`, bytes: file });
+      },
+      state: { step: CALL },
+    });
+    tab(host, 'prompt')!.click();
+    panel(host).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
+    await new Promise((r) => setTimeout(r, 0));
+    expect(asked).toEqual([{ session: later.conversation, seqs: [4] }]);
+
+    view.setState({ step: moved });
+    const offer = panel(host).querySelector<HTMLButtonElement>('[data-load-prompt]');
+    expect(offer, 'the call needs seq 5, which no read has asked for').not.toBeNull();
+    offer!.click();
+    await new Promise((r) => setTimeout(r, 0));
+    expect(asked[1]).toEqual({ session: later.conversation, seqs: [5] });
+  });
+
+  it('does not offer a file again when it arrived but could not be resolved', async () => {
+    // The server answers, but the record refers to a body of a file that is not there, so the store
+    // keeps nothing. Offering the read again would bring the same bytes and the same answer, for ever.
+    const HEADER =
+      '{"h":1,"schema":"sd/1","seq":5,"at":"2026-01-01T00:00:00Z","kind":"provider_body",' +
+      '"adapter":"mock/0.2.0","dialect":"mock/1","src":".","session":"s"}';
+    const EMPTY_OBJECT = '44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
+    const manifest = {
+      schema: 'provider_body/1', role: 'request', sha256: EMPTY_OBJECT, bytes: 2, depth: 1,
+      segments: [{ copy: { from: 'a-body-that-never-landed', sha256: EMPTY_OBJECT, len: 2 } }],
+    };
+    const orphan = new TextEncoder().encode(
+      [HEADER, `{"ord":1,"id":"late","parts":[{"k":"data","data":${JSON.stringify(manifest)}}]}`, '{"t":"end"}'].join('\n') + '\n',
+    );
+
+    const later = JSON.parse(JSON.stringify(doc)) as AszViewDocument;
+    const provider = later.files!.find((f) => f.kind === 'provider_body')!;
+    provider.seq = 5;
+    provider.file = provider.file.replace('000004', '000005');
+    const walk = (nodes: AszViewDocument['talks']): void => {
+      for (const n of nodes) {
+        for (const b of (n as { provider_bodies?: Array<{ ref: { seq: number; row: number } }> }).provider_bodies ?? []) {
+          b.ref = { seq: 5, row: 1 };
+        }
+        walk(n.children ?? []);
+      }
+    };
+    walk(later.talks);
+    walk(later.loose ?? []);
+
+    asked = [];
+    host = document.createElement('div');
+    document.body.appendChild(host);
+    view = mountConversationView(host, {
+      document: later,
+      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: orphan });
+      },
+      state: { step: CALL },
+    });
+    tab(host, 'prompt')!.click();
+    panel(host).querySelector<HTMLButtonElement>('[data-load-prompt]')!.click();
+    await new Promise((r) => setTimeout(r, 0));
+    expect(asked).toHaveLength(1);
+    // the offer is gone - asking again brings the same answer - and the panel says what is not there
+    expect(panel(host).querySelector('[data-load-prompt]')).toBeNull();
+    expect(panel(host).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')]);
+    }
+  });
+});
diff --git a/test/e2e/cases/ai-agent/docker-compose.yml b/test/e2e/cases/ai-agent/docker-compose.yml
new file mode 100644
index 00000000..90b419cc
--- /dev/null
+++ b/test/e2e/cases/ai-agent/docker-compose.yml
@@ -0,0 +1,122 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# One AI agent conversation, pushed by a real AI Sessionizer, for the pages that read it.
+#
+# The `provider-bodies` scenario is the one that carries everything the conversation page draws:
+# talks with an input and a reply, a shell command run by a child agent, and the request and
+# response bodies behind every model call. OAP stores what the Sessionizer pushes and serves it
+# back through its conversation routes; Horizon draws it. Nothing here is a fixture of Horizon's
+# own - the bytes on the page came out of a Sessionizer.
+#
+# The asz image is distroless and has no shell, so the one-shot steps chain through
+# service_completed_successfully rather than through a script.
+
+x-asz: &asz
+  image: ghcr.io/apache/skywalking-ai-sessionizer:${SW_AI_SESSIONIZER_COMMIT}
+  volumes:
+    - aszdata:/asz/data
+    - ./provider-bodies.yaml:/asz/provider-bodies.yaml:ro
+  networks:
+    - e2e
+
+services:
+  banyandb:
+    extends:
+      file: ../../script/docker-compose/base-compose.yml
+      service: banyandb
+    networks:
+      - e2e
+
+  oap:
+    extends:
+      file: ../../script/docker-compose/base-compose.yml
+      service: oap
+    ports:
+      - 12800
+      - 17128
+    environment:
+      SW_STORAGE: banyandb
+      # SW_LOG_LAL_FILES REPLACES OAP's bundled list, and base-compose sets it to
+      # the fixture's own file. `ai-agent` is the bundled rule that turns a pushed
+      # file into a stored conversation, so without it every push is accepted and
+      # dropped and the list stays empty. Both files are named here; a LAL rule
+      # only sees records of the layer it declares, so neither reaches the other's.
+      SW_LOG_LAL_FILES: ai-agent,horizon-e2e
+    depends_on:
+      banyandb:
+        condition: service_healthy
+    networks:
+      - e2e
+
+  # The asz image runs as a non-root user; a fresh named volume is root-owned.
+  asz-volume:
+    image: busybox:1.36
+    command: ["sh", "-c", "chown -R 65532:65532 /asz/data"]
+    volumes:
+      - aszdata:/asz/data
+    networks:
+      - e2e
+
+  asz-build:
+    <<: *asz
+    command: ["scenario", "build", "/asz/provider-bodies.yaml", "--format", "sd", "--out", "/asz/data"]
+    depends_on:
+      asz-volume:
+        condition: service_completed_successfully
+
+  # The receiver and this sender's identity, appended to the asz.yaml the build wrote. The names
+  # here are what the page shows as the agent and the agent runtime, and what the spec asserts.
+  asz-config:
+    image: busybox:1.36
+    command:
+      - sh
+      - -c
+      - "printf 'export:\\n  otlp:\\n    protocol: grpc\\n    endpoint: oap:11800\\n    service_name: e2e-ai-agent\\n    instance_id: e2e-sender\\n' >> /asz/data/asz.yaml"
+    volumes:
+      - aszdata:/asz/data
+    depends_on:
+      asz-build:
+        condition: service_completed_successfully
+    networks:
+      - e2e
+
+  # One pass of the pipeline: parse what is landed and send it, then exit.
+  asz-collect:
+    <<: *asz
+    command: ["collect", "-once", "-config", "/asz/data/asz.yaml"]
+    depends_on:
+      asz-config:
+        condition: service_completed_successfully
+      oap:
+        condition: service_healthy
+
+  horizon:
+    extends:
+      file: ../../script/docker-compose/base-compose.yml
+      service: horizon
+    ports:
+      - 8081
+    depends_on:
+      oap:
+        condition: service_healthy
+      asz-collect:
+        condition: service_completed_successfully
+
+networks:
+  e2e:
+
+volumes:
+  aszdata:
diff --git a/test/e2e/cases/ai-agent/e2e.yaml b/test/e2e/cases/ai-agent/e2e.yaml
new file mode 100644
index 00000000..81f0898f
--- /dev/null
+++ b/test/e2e/cases/ai-agent/e2e.yaml
@@ -0,0 +1,100 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# AI agent conversations — the AI_AGENT layer, its Conversations tab, and one
+# conversation drawn in full: the talk, a shell command a child agent ran, and
+# the provider bodies behind a model call.
+#
+# The data is not Horizon's. A real AI Sessionizer builds the `provider-bodies`
+# scenario and pushes it to OAP, which stores it and serves it back through the
+# conversation routes. So this case proves the whole path, which the unit tests
+# cannot: they read a landed file as a fixture, through a stubbed route.
+#
+# Run it:  make -C test/e2e run CASE=ai-agent
+
+setup:
+  env: compose
+  file: docker-compose.yml
+  timeout: 30m
+  init-system-environment: ../../script/env
+  steps:
+    - name: coverage
+      command: bash test/e2e/script/prepare/echo-coverage.sh ai-agent
+    - name: set PATH
+      command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH
+    - name: install yq
+      command: bash test/e2e/script/prepare/install.sh yq
+    - name: install swctl
+      command: bash test/e2e/script/prepare/install.sh swctl
+    - name: prepare browser image
+      command: bash test/e2e/script/prepare/install.sh playwright
+
+# No `trigger`: the Sessionizer pushes once, in `asz-collect`, before Horizon
+# starts. A conversation is not traffic — it is a chain of rounds already
+# written, and nothing about it changes while the case runs.
+
+verify:
+  # The push completes before Horizon starts, but OAP persists on its own cycle
+  # and the list is folded from stored rounds, so the first read can be early.
+  retry:
+    count: 30
+    interval: 5s
+  cases:
+    # 1. CHECK FIRST: OAP took what the Sessionizer pushed and resolved the
+    #    agent as a service of the AI_AGENT layer. If this fails the push or the
+    #    receiver is broken, and Horizon has nothing to show.
+    - query: |
+        swctl --display json --base-url=http://${oap_host}:${oap_12800}/graphql service ls \
+          | yq -P '{"aiAgentService": [.[] | select(.name == "e2e-ai-agent") | .layers[] | select(. == "AI_AGENT")] | length}'
+      expected: expected/ai-agent-service.yml
+
+    # 2. The conversation reaches Horizon's own list route, with the counts the
+    #    Sessionizer wrote into its newest round. The page's table is drawn from
+    #    exactly this, so a failure here is the BFF or OAP, not the browser.
+    - query: |
+        bash test/e2e/script/prepare/horizon-get.sh \
+          "http://${horizon_host}:${horizon_8081}" /api/layer/ai_agent/ai-conversations \
+          '{"service":"e2e-ai-agent","windowMinutes":129600}' \
+          | yq -P '{"reachable": .reachable,
+                    "conversation": [.rows[] | select(.title == "provider bodies beside the calls")
+                    | {"title": .title, "instance": .serviceInstanceName, "llmCalls": .llmCalls, "bashRuns": .bashRuns}][0]}'
+      expected: expected/conversation-listed.yml
+
+    # 3. READINESS: the conversation itself reads, with its files. The list above
+    #    is folded from the round records alone, so it answers as soon as a round
+    #    is stored - while the transcript and the provider bodies are other records
+    #    still landing. Opening the page before them draws a document with holes,
+    #    and the browser suite does not reload one. This asks for the same document
+    #    the page will, and retries until it is whole.
+    - query: |
+        conversation=$(bash test/e2e/script/prepare/horizon-get.sh \
+          "http://${horizon_host}:${horizon_8081}" /api/layer/ai_agent/ai-conversations \
+          '{"service":"e2e-ai-agent","windowMinutes":129600}' \
+          | yq -P '[.rows[] | select(.title == "provider bodies beside the calls")][0].conversation')
+        bash test/e2e/script/prepare/horizon-get.sh \
+          "http://${horizon_host}:${horizon_8081}" \
+          "/api/ai-conversation/${conversation}/view?service=e2e-ai-agent&instance=e2e-sender" \
+          | yq -P '{"state": .summary.state, "providerBodies": .summary.provider_bodies, "capturedPrompts": .summary.captured_prompts}'
+      expected: expected/conversation-whole.yml
+
+    # 4. The browser suite: the list, the conversation, the shell command and
+    #    the prompt a model call sent, all on screen.
+    - query: |
+        bash test/e2e/script/prepare/playwright.sh ai-agent
+      expected: expected/passed.yml
+
+cleanup:
+  on: always
diff --git a/test/e2e/cases/ai-agent/expected/ai-agent-service.yml b/test/e2e/cases/ai-agent/expected/ai-agent-service.yml
new file mode 100644
index 00000000..d09c2b8f
--- /dev/null
+++ b/test/e2e/cases/ai-agent/expected/ai-agent-service.yml
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+aiAgentService: 1
diff --git a/test/e2e/cases/ai-agent/expected/conversation-listed.yml b/test/e2e/cases/ai-agent/expected/conversation-listed.yml
new file mode 100644
index 00000000..aaee7736
--- /dev/null
+++ b/test/e2e/cases/ai-agent/expected/conversation-listed.yml
@@ -0,0 +1,25 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# `reachable` is the BFF's word for "OAP answered": it separates a broken hop
+# from a conversation that simply is not stored.
+reachable: true
+conversation:
+  title: provider bodies beside the calls
+  instance: e2e-sender
+  llmCalls: 7
+  bashRuns: 1
diff --git a/test/e2e/cases/ai-agent/expected/conversation-whole.yml b/test/e2e/cases/ai-agent/expected/conversation-whole.yml
new file mode 100644
index 00000000..bc5c5115
--- /dev/null
+++ b/test/e2e/cases/ai-agent/expected/conversation-whole.yml
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+state: verified
+providerBodies: 14
+capturedPrompts: 7
diff --git a/test/e2e/cases/ai-agent/expected/passed.yml b/test/e2e/cases/ai-agent/expected/passed.yml
new file mode 100644
index 00000000..d23d763b
--- /dev/null
+++ b/test/e2e/cases/ai-agent/expected/passed.yml
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+passed: true
diff --git a/test/e2e/cases/ai-agent/provider-bodies.yaml b/test/e2e/cases/ai-agent/provider-bodies.yaml
new file mode 100644
index 00000000..1e8d591c
--- /dev/null
+++ b/test/e2e/cases/ai-agent/provider-bodies.yaml
@@ -0,0 +1,50 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Provider bodies beside the steps: every call writes its request and its
+# response, as Claude Code does when OTEL_LOG_RAW_API_BODIES names a
+# directory. A child's calls land between two calls of the main stream, and
+# a compaction starts the main stream's message list again. The bodies land in
+# two passes, into files cut small, so a body refers to bodies and pieces in
+# earlier files. Each body lands in the session, rebuilds from the files up to
+# its own, and leaves the fold as it is.
+title: provider bodies beside the calls
+provider_bodies: true
+steps:
+  - input: read the configuration and summarise it
+  - call:
+      text: Reading it.
+      tool:
+        name: Read
+        input: {file_path: /Users/dev/scenario/config.yaml}
+        result: {text: "timeout: 30\nretries: 3\n", after: 200ms}
+  - call: {text: "The timeout is 30 seconds, with 3 retries."}
+    checkpoint: read
+  - input: find where the timeout is used
+  - call:
+      agent:
+        name: searcher
+        prompt: find every use of the timeout
+        steps:
+          - call:
+              text: Searching.
+              tool: {name: Bash, input: {command: grep -rn timeout .}, result: {text: "server.go:3: var timeout = 30", after: 300ms}}
+          - call: {text: It is used in server.go.}
+  - call: {text: The searcher found it in server.go.}
+  - reset: {summary: "The configuration sets a 30 second timeout, used in server.go."}
+  - input: and the retries?
+  - call: {text: There are 3 retries.}
diff --git a/test/e2e/playwright/playwright.config.ts b/test/e2e/playwright/playwright.config.ts
index c2adef8f..17ede59a 100644
--- a/test/e2e/playwright/playwright.config.ts
+++ b/test/e2e/playwright/playwright.config.ts
@@ -116,6 +116,14 @@ export default defineConfig({
       dependencies: ['auth'],
       use: { ...devices['Desktop Chrome'], storageState: AUTH_STATE },
     },
+    {
+      // AI agent conversations — its own project because the AI_AGENT layer, and the conversations
+      // behind it, exist only where a Sessionizer has pushed one.
+      name: 'ai-agent',
+      testMatch: '**/specs/ai-agent/*.spec.ts',
+      dependencies: ['auth'],
+      use: { ...devices['Desktop Chrome'], storageState: AUTH_STATE },
+    },
     {
       // ElasticSearch's browser half — one spec, the pre-v2 trace path, which
       // is the only thing that deployment proves.
diff --git a/test/e2e/playwright/specs/ai-agent/conversation.spec.ts b/test/e2e/playwright/specs/ai-agent/conversation.spec.ts
new file mode 100644
index 00000000..d5478cde
--- /dev/null
+++ b/test/e2e/playwright/specs/ai-agent/conversation.spec.ts
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { test, expect } from '../support/diagnostics.js';
+import {
+  AI_AGENT_LAYER,
+  AI_AGENT_SENDER,
+  AI_AGENT_SERVICE,
+  AI_CONVERSATION_BASH_COMMAND,
+  AI_CONVERSATION_BASH_RESULT,
+  AI_CONVERSATION_BASH_RUNS,
+  AI_CONVERSATION_BASH_STEP,
+  AI_CONVERSATION_LLM_CALLS,
+  AI_CONVERSATION_TITLE,
+} from '../fixture.js';
+import type { Locator, Page } from '@playwright/test';
+
+// One AI agent conversation, from the list to the bodies a model call exchanged with its provider.
+//
+// Everything here is read off the page against a real OAP holding what the Sessionizer pushed, which is
+// the one thing the unit tests cannot prove: they use a landed file as a fixture and a stubbed route.
+// A row opens the conversation in a browser tab of its own, outside the AppShell, so every test that
+// reads the conversation works on the page that tab carries.
+
+/** The value of one column of a row, found by the header above it rather than by position. */
+async function cell(page: Page, row: Locator, header: string): Promise<string> {
+  // innerText is what the reader sees, and the table's heads are uppercased by the stylesheet
+  const headers = await page.getByRole('columnheader').allInnerTexts();
+  const at = headers.findIndex((h) => h.trim().toLowerCase() === header.toLowerCase());
+  expect(at, `the table has a ${header} column`).toBeGreaterThanOrEqual(0);
+  return (await row.getByRole('cell').nth(at).innerText()).trim();
+}
+
+/** The Conversations tab, queried for the agent the case pushes. */
+async function openList(page: Page): Promise<void> {
+  await page.goto(`/layer/${AI_AGENT_LAYER}/conversations`);
+  // The tab owns its time range and runs nothing until asked, as the Traces and Logs tabs do.
+  await page.getByRole('button', { name: /run query/i }).click();
+  await expect(page.getByRole('row', { name: new RegExp(AI_CONVERSATION_TITLE) })).toBeVisible({ timeout: 30_000 });
+}
+
+/** The conversation page, in the tab its row opens. */
+async function openConversation(page: Page): Promise<Page> {
+  await openList(page);
+  const [opened] = await Promise.all([
+    page.context().waitForEvent('page'),
+    page.getByRole('row', { name: new RegExp(AI_CONVERSATION_TITLE) }).click(),
+  ]);
+  await expect(opened).toHaveURL(/\/ai-conversation\//);
+  // the whole document is read before a byte is drawn, and a conversation is megabytes
+  await expect(opened.locator('.acv-transcript-list')).toBeVisible({ timeout: 120_000 });
+  return opened;
+}
+
+test.describe('AI agent conversations', () => {
+  test('lists a conversation with what it did, and opens it', async ({ page }) => {
+    await openList(page);
+    const row = page.getByRole('row', { name: new RegExp(AI_CONVERSATION_TITLE) });
+    // the counts come from the newest round as the Sessionizer wrote them, each in its own column:
+    // asserted anywhere in the row, two columns holding the same number would prove nothing
+    await expect(row).toContainText(AI_AGENT_SENDER);
+    expect(await cell(page, row, 'Model calls')).toBe(String(AI_CONVERSATION_LLM_CALLS));
+    expect(await cell(page, row, 'Bash runs')).toBe(String(AI_CONVERSATION_BASH_RUNS));
+
+    const view = await openConversation(page);
+    // the page names the conversation, its agent and the sender that pushed it
+    await expect(view.locator('body')).toContainText(AI_CONVERSATION_TITLE);
+    await expect(view.locator('body')).toContainText(AI_AGENT_SERVICE);
+    await expect(view.locator('body')).toContainText(AI_AGENT_SENDER);
+    // and states what it made of the chain
+    await expect(view.locator('.acv-integrity')).toContainText(/verified/i);
+  });
+
+  test('draws the talk: what came in, what the agent answered', async ({ page }) => {
+    const view = await openConversation(page);
+    const transcript = view.locator('.acv-transcript-list');
+    // the scenario's own words, both directions, as the runtime recorded them
+    await expect(transcript).toContainText('read the configuration and summarise it');
+    await expect(transcript).toContainText('The timeout is 30 seconds, with 3 retries.');
+    // the agent's work is folded under the reply until a reader opens it
+    await expect(transcript.getByText(/show what the agent did/i).first()).toBeVisible();
+  });
+
+  test('shows a shell command as the lines that were run, and what it returned', async ({ page }) => {
+    const view = await openConversation(page);
+    // The one shell command of this conversation was run by the child agent it started, so it is not in
+    // the main stream's transcript. The address carries the position, which is what a reader shares, and
+    // the page keeps it current as the reader moves - so the step is set on the address, never appended.
+    const at = new URL(view.url());
+    at.searchParams.set('step', AI_CONVERSATION_BASH_STEP);
+    at.searchParams.delete('talk');
+    at.searchParams.delete('stream');
+    await view.goto(at.toString());
+    await expect(view.locator('.acv-inspector-title')).toContainText('Bash', { timeout: 60_000 });
+
+    // the input is drawn as the lines that were run, not as one escaped JSON string
+    const inspector = view.locator('.acv-inspector-body');
+    await expect(inspector).toContainText(AI_CONVERSATION_BASH_COMMAND);
+    await expect(inspector).not.toContainText('{"command"');
+    // and the result beside it
+    await expect(inspector).toContainText(AI_CONVERSATION_BASH_RESULT);
+  });
+
+  test('rebuilds the prompt a model call sent, and the answer it got', async ({ page }) => {
+    const view = await openConversation(page);
+    // a model call carries the bodies; the flow timeline is where every one of them is reachable
+    await view.locator('[data-node^="call/"]').first().click();
+    const prompt = view.locator('[data-tab="prompt"]');
+    await expect(prompt, 'a call whose bodies landed offers the tab').toBeVisible();
+    await prompt.click();
+
+    // nothing is read until the reader asks, and the offer says what it costs
+    const body = view.locator('.acv-inspector-body');
+    await expect(body).toContainText(/file\(s\) of this session/i);
+    await body.getByRole('button', { name: /load the prompt/i }).click();
+
+    // the request as it was sent: the system prompt, the tools, and the messages in their boxes
+    await expect(body).toContainText('System prompt', { timeout: 30_000 });
+    await expect(body).toContainText('Tools (');
+    await expect(body).toContainText('Messages (');
+    await expect(body.locator('.acv-prompt-message').first()).toBeVisible();
+    // the text the runtime injected is marked as injected, beside the text a person wrote
+    await expect(body).toContainText(/injected by the runtime/i);
+    await expect(body).toContainText('read the configuration and summarise it');
+
+    // and the response on its own, with what stopped it
+    await body.getByRole('button', { name: /^response$/i }).click();
+    await expect(body).toContainText(/stopped:/i);
+  });
+
+  test('reads the files once for a session, however many calls are opened', async ({ page }) => {
+    const reads: string[] = [];
+    page.context().on('request', (r) => {
+      if (r.url().includes('/files?')) {
+        reads.push(r.url());
+      }
+    });
+    const view = await openConversation(page);
+    await view.locator('[data-node^="call/"]').first().click();
+    await view.locator('[data-tab="prompt"]').click();
+    await view.locator('.acv-inspector-body').getByRole('button', { name: /load the prompt/i }).click();
+    await expect(view.locator('.acv-inspector-body')).toContainText('Messages (', { timeout: 30_000 });
+
+    // every other call of the same session is drawn from what that read already brought back
+    for (const call of await view.locator('[data-node^="call/"]').all()) {
+      await call.click();
+      await expect(view.locator('[data-tab="prompt"]')).toHaveAttribute('aria-selected', 'true');
+      await expect(view.locator('.acv-inspector-body').getByRole('button', { name: /load the prompt/i })).toHaveCount(0);
+      // and the body is drawn from what that read brought back: an empty panel, or one saying it could
+      // not rebuild, would satisfy the two assertions above on its own
+      await expect(view.locator('.acv-inspector-body')).toContainText('Messages (');
+      await expect(view.locator('.acv-inspector-body .acv-prompt-message').first()).toBeVisible();
+    }
+    expect(reads, 'one read of the session, not one per call').toHaveLength(1);
+  });
+});
diff --git a/test/e2e/playwright/specs/fixture.ts b/test/e2e/playwright/specs/fixture.ts
index 21a314d5..9126c79a 100644
--- a/test/e2e/playwright/specs/fixture.ts
+++ b/test/e2e/playwright/specs/fixture.ts
@@ -183,3 +183,22 @@ export const NATIVE_GENAI_CALLER = 'e2e-spring-ai';
 export const OTLP_GENAI_CALLER = 'e2e-otel-python';
 /** The judge's tasks, from cases/genai/ai-evaluation.yml. */
 export const GENAI_TASKS = ['Faithfulness', 'TaskCompletion'];
+
+/**
+ * AI agent conversations, from the Sessionizer scenarios the `ai-agent` case pushes.
+ *
+ * The conversation named here is the one that carries everything the page draws: talks with input and
+ * a reply, a shell command, and the provider bodies behind its model calls. Its title is the
+ * scenario's own, rendered verbatim in every locale, so it is matched literally.
+ */
+export const AI_AGENT_LAYER = 'ai_agent';
+export const AI_AGENT_SERVICE = 'e2e-ai-agent';
+export const AI_AGENT_SENDER = 'e2e-sender';
+/** The scenario whose calls carry provider bodies, and which runs one shell command. */
+export const AI_CONVERSATION_TITLE = 'provider bodies beside the calls';
+export const AI_CONVERSATION_BASH_RUNS = 1;
+/** Its one shell command, run by the child agent it started, and what came back. */
+export const AI_CONVERSATION_BASH_STEP = 'tool/searcher-s1-tool';
+export const AI_CONVERSATION_BASH_COMMAND = 'grep -rn timeout .';
+export const AI_CONVERSATION_BASH_RESULT = 'server.go:3: var timeout = 30';
+export const AI_CONVERSATION_LLM_CALLS = 7;
diff --git a/test/e2e/script/docker-compose/base-compose.yml b/test/e2e/script/docker-compose/base-compose.yml
index 398b6266..972a0a38 100644
--- a/test/e2e/script/docker-compose/base-compose.yml
+++ b/test/e2e/script/docker-compose/base-compose.yml
@@ -67,6 +67,9 @@ services:
       # tab is empty even though the agent is reporting correctly. Enabled for
       # every case: the demo app already emits logs, so this costs nothing and
       # spares a whole extra stack just to see them.
+      #
+      # This REPLACES the bundled list rather than adding to it, so a case that
+      # needs one of OAP's own rules names it here too — see the ai-agent case.
       SW_LOG_LAL_FILES: horizon-e2e
     volumes:
       - ./lal.yaml:/skywalking/config/lal/horizon-e2e.yaml
diff --git a/test/e2e/script/env b/test/e2e/script/env
index a2e3063e..91ecb69d 100644
--- a/test/e2e/script/env
+++ b/test/e2e/script/env
@@ -50,7 +50,14 @@
 # rule, dropping that round's samples without a log line, and OAP accepted
 # requests on 11800/12800 before its rule sets were set up — so a fresh
 # fixture lost its first round of metrics.
-SW_OAP_COMMIT=476afadecdc0a410bf5c64de48f6ead8e6030b00
+# Must also be at or after 289b4b3bd2 (#14084), which serves an AI agent
+# conversation and its stored files: the `ai-agent` case reads both.
+SW_OAP_COMMIT=289b4b3bd2e271ef6b617cfb3f64987fe29a8265
+
+# The AI Sessionizer, which builds a conversation and pushes it to OAP. Only
+# the `ai-agent` case runs it, and only once: a conversation is a chain of
+# rounds already written, not traffic.
+SW_AI_SESSIONIZER_COMMIT=747d83c9615b19a1edc7d79606f2d4dfbefc153b
 
 # BanyanDB — the storage every case runs on. OAP's own default selector is
 # banyandb, so no SW_STORAGE override is needed anywhere.
diff --git a/test/e2e/script/prepare/horizon-get.sh b/test/e2e/script/prepare/horizon-get.sh
index f1d55787..96e9ce36 100755
--- a/test/e2e/script/prepare/horizon-get.sh
+++ b/test/e2e/script/prepare/horizon-get.sh
@@ -37,13 +37,19 @@ PASSWORD="${HORIZON_E2E_PASSWORD:-e2e-passw0rd}"
 JAR=$(mktemp)
 trap 'rm -f "${JAR}"' EXIT
 
-curl -sSf -c "${JAR}" -X POST "${BASE}/api/auth/login" \
+# A verify case retries a command that FAILS; one that never returns stops the
+# run instead, and infra-e2e reports nothing at all. So every call gets a
+# deadline far above any honest answer — the largest route here is a whole
+# conversation document, which OAP itself gives up on long before this.
+DEADLINE=120
+
+curl -sSf --max-time "${DEADLINE}" -c "${JAR}" -X POST "${BASE}/api/auth/login" \
   -H 'Content-Type: application/json' \
   -d "{\"username\":\"${USER}\",\"password\":\"${PASSWORD}\"}" > /dev/null
 
 if [ -n "${BODY}" ]; then
-  curl -sSf -b "${JAR}" -X POST "${BASE}${PATH_}" \
+  curl -sSf --max-time "${DEADLINE}" -b "${JAR}" -X POST "${BASE}${PATH_}" \
     -H 'Content-Type: application/json' -d "${BODY}"
 else
-  curl -sSf -b "${JAR}" "${BASE}${PATH_}"
+  curl -sSf --max-time "${DEADLINE}" -b "${JAR}" "${BASE}${PATH_}"
 fi