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/10/14 15:48:39 UTC
(skywalking-horizon-ui) branch conversation-view-reader-shapes created (now 39619b03)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a change to branch conversation-view-reader-shapes
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
at 39619b03 fix(conversation-view): read what the Sessionizer writes for shortened and removed records
This branch includes the following new commits:
new 39619b03 fix(conversation-view): read what the Sessionizer writes for shortened and removed records
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(skywalking-horizon-ui) 01/01: fix(conversation-view): read what the Sessionizer writes for shortened and removed records
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch conversation-view-reader-shapes
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 39619b031ccdac838d676a951d4affd1a15d089b
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Wed Oct 14 23:14:24 2026 +0800
fix(conversation-view): read what the Sessionizer writes for shortened and removed records
A review of every reader against what the AI Sessionizer writes found the
renderer stricter than the writer, or reading its data as something else.
- A provider body manifest whose segment list is left out or null is read as
an empty one, as the Sessionizer's own decoder reads it. The prompt store
dropped such a record and marked its file held.
- A change record made smaller to fit its stored file keeps changed_files
when its file list goes. The tool card, the Changes tab and the
conversation's Changes panel said "no files changed", or counted none, for
it; they now count its files, list it by its count under its root, and say
the list was taken out. A count without paths cannot be told apart from
another record's files, so beside any other record it is shown as the
least the records changed. A file whose diff was taken out the same way
keeps its line counts, and says so.
- An execution record the Sessionizer's retention removed keeps its time and
outcome. The Execution tab drew its other fields as "not reported"; it now
says the record was removed and shows what it keeps.
- AszWorkspaceChange and AszToolExecution gain the state the Sessionizer
writes. The app carries the three new texts in every locale.
---
apps/ui/src/i18n/locales/de.json | 3 ++
apps/ui/src/i18n/locales/en.json | 3 ++
apps/ui/src/i18n/locales/es.json | 3 ++
apps/ui/src/i18n/locales/fr.json | 3 ++
apps/ui/src/i18n/locales/ja.json | 3 ++
apps/ui/src/i18n/locales/ko.json | 3 ++
apps/ui/src/i18n/locales/pt.json | 3 ++
apps/ui/src/i18n/locales/zh-CN.json | 3 ++
.../layer/ai-conversation/conversationStrings.ts | 3 ++
docs/changelog/1.1.0.md | 2 +
packages/conversation-view/src/model.ts | 33 ++++++++++--
packages/conversation-view/src/prompt/store.ts | 2 +
packages/conversation-view/src/strings.ts | 6 +++
packages/conversation-view/src/types.ts | 8 +++
packages/conversation-view/src/view/changes.ts | 51 ++++++++++++++----
packages/conversation-view/src/view/executions.ts | 10 ++++
packages/conversation-view/test/changes.test.ts | 61 ++++++++++++++++++++++
packages/conversation-view/test/mcp.test.ts | 15 ++++++
.../conversation-view/test/prompt-removed.test.ts | 14 +++++
19 files changed, 216 insertions(+), 13 deletions(-)
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index fc1796ba..5d471c04 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -3164,6 +3164,7 @@
"found by a later scan; no observed tool window covers it": "von einem späteren Scan gefunden; kein beobachtetes Werkzeugfenster deckt es ab",
"how many files changed is unknown": "wie viele Dateien geändert wurden, ist unbekannt",
"a complete observation found no changed file": "eine vollständige Beobachtung fand keine geänderte Datei",
+ "{n} file(s) changed; the list was taken out to fit the record in its stored file": "{n} Datei(en) geändert; die Liste wurde entfernt, damit der Datensatz in seine gespeicherte Datei passt",
"Partial coverage: a scan stopped early, so changes may be missing. Gaps:": "Unvollständige Abdeckung: ein Scan endete vorzeitig, Änderungen können fehlen. Lücken:",
"Other windows were open on this root at the same time:": "Gleichzeitig waren weitere Fenster auf diesem Root offen:",
"shared with {windows}": "geteilt mit {windows}",
@@ -3171,6 +3172,7 @@
"outside any observed window": "außerhalb jedes beobachteten Fensters",
"binary — path and hashes only, no diff": "binär — nur Pfad und Hashes, kein Diff",
"too large to diff — path and hashes only": "zu groß für ein Diff — nur Pfad und Hashes",
+ "diff taken out to fit its stored file — line counts, path and hashes only": "Diff entfernt, damit er in seine gespeicherte Datei passt – nur Zeilenzahlen, Pfad und Hashes",
"no diff available": "kein Diff verfügbar",
"before: no newline at end of file": "vorher: kein Zeilenumbruch am Dateiende",
"after: no newline at end of file": "nachher: kein Zeilenumbruch am Dateiende",
@@ -3425,6 +3427,7 @@
"Measured time": "Gemessene Zeit",
"The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "Die Zeit, die die Laufzeit um den Aufruf gemessen hat. Sie enthält jedes Warten vor dem Start des Aufrufs und ist daher nicht die eigene Zeit des Servers.",
"not reported": "nicht gemeldet",
+ "Retention removed this record from the stored files; its time and outcome are kept.": "Die Retention hat diesen Datensatz aus den gespeicherten Dateien entfernt; Zeit und Ergebnis sind erhalten.",
"Sent to the server": "An den Server gesendet",
"Came back": "Zurückgekommen",
"{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 2d72c2be..f6e758d7 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -3164,6 +3164,7 @@
"found by a later scan; no observed tool window covers it": "found by a later scan; no observed tool window covers it",
"how many files changed is unknown": "how many files changed is unknown",
"a complete observation found no changed file": "a complete observation found no changed file",
+ "{n} file(s) changed; the list was taken out to fit the record in its stored file": "{n} file(s) changed; the list was taken out to fit the record in its stored file",
"Partial coverage: a scan stopped early, so changes may be missing. Gaps:": "Partial coverage: a scan stopped early, so changes may be missing. Gaps:",
"Other windows were open on this root at the same time:": "Other windows were open on this root at the same time:",
"shared with {windows}": "shared with {windows}",
@@ -3171,6 +3172,7 @@
"outside any observed window": "outside any observed window",
"binary — path and hashes only, no diff": "binary — path and hashes only, no diff",
"too large to diff — path and hashes only": "too large to diff — path and hashes only",
+ "diff taken out to fit its stored file — line counts, path and hashes only": "diff taken out to fit its stored file — line counts, path and hashes only",
"no diff available": "no diff available",
"before: no newline at end of file": "before: no newline at end of file",
"after: no newline at end of file": "after: no newline at end of file",
@@ -3425,6 +3427,7 @@
"Measured time": "Measured time",
"The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.",
"not reported": "not reported",
+ "Retention removed this record from the stored files; its time and outcome are kept.": "Retention removed this record from the stored files; its time and outcome are kept.",
"Sent to the server": "Sent to the server",
"Came back": "Came back",
"{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index f017a890..264e1d7a 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -3164,6 +3164,7 @@
"found by a later scan; no observed tool window covers it": "encontrado por un escaneo posterior; ninguna ventana de herramienta observada lo cubre",
"how many files changed is unknown": "no se sabe cuántos archivos cambiaron",
"a complete observation found no changed file": "una observación completa no encontró ningún archivo cambiado",
+ "{n} file(s) changed; the list was taken out to fit the record in its stored file": "{n} archivo(s) cambiado(s); la lista se quitó para que el registro cupiera en su archivo almacenado",
"Partial coverage: a scan stopped early, so changes may be missing. Gaps:": "Cobertura parcial: un escaneo se detuvo antes de tiempo, así que pueden faltar cambios. Huecos:",
"Other windows were open on this root at the same time:": "Otras ventanas estaban abiertas en esta raíz al mismo tiempo:",
"shared with {windows}": "compartido con {windows}",
@@ -3171,6 +3172,7 @@
"outside any observed window": "fuera de cualquier ventana observada",
"binary — path and hashes only, no diff": "binario — solo ruta y hashes, sin diff",
"too large to diff — path and hashes only": "demasiado grande para diff — solo ruta y hashes",
+ "diff taken out to fit its stored file — line counts, path and hashes only": "diff quitado para caber en su archivo almacenado — solo recuentos de líneas, ruta y hashes",
"no diff available": "no hay diff disponible",
"before: no newline at end of file": "antes: sin salto de línea al final del archivo",
"after: no newline at end of file": "después: sin salto de línea al final del archivo",
@@ -3425,6 +3427,7 @@
"Measured time": "Tiempo medido",
"The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "El tiempo que el runtime midió alrededor de la llamada. Incluye cualquier espera antes de que empezara la llamada, así que no es el tiempo propio del servidor.",
"not reported": "no informado",
+ "Retention removed this record from the stored files; its time and outcome are kept.": "La retención eliminó este registro de los archivos almacenados; se conservan su hora y su resultado.",
"Sent to the server": "Enviado al servidor",
"Came back": "Devuelto",
"{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 1970c3eb..abb113b6 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -3164,6 +3164,7 @@
"found by a later scan; no observed tool window covers it": "trouvé par un scan ultérieur ; aucune fenêtre d’outil observée ne le couvre",
"how many files changed is unknown": "le nombre de fichiers modifiés est inconnu",
"a complete observation found no changed file": "une observation complète n’a trouvé aucun fichier modifié",
+ "{n} file(s) changed; the list was taken out to fit the record in its stored file": "{n} fichier(s) modifié(s) ; la liste a été retirée pour que l’enregistrement tienne dans son fichier stocké",
"Partial coverage: a scan stopped early, so changes may be missing. Gaps:": "Couverture partielle : un scan s’est arrêté trop tôt, des modifications peuvent manquer. Lacunes :",
"Other windows were open on this root at the same time:": "D’autres fenêtres étaient ouvertes sur cette racine en même temps :",
"shared with {windows}": "partagé avec {windows}",
@@ -3171,6 +3172,7 @@
"outside any observed window": "hors de toute fenêtre observée",
"binary — path and hashes only, no diff": "binaire — chemin et empreintes seulement, pas de diff",
"too large to diff — path and hashes only": "trop volumineux pour un diff — chemin et empreintes seulement",
+ "diff taken out to fit its stored file — line counts, path and hashes only": "diff retiré pour tenir dans son fichier stocké — nombres de lignes, chemin et empreintes seulement",
"no diff available": "aucun diff disponible",
"before: no newline at end of file": "avant : pas de saut de ligne en fin de fichier",
"after: no newline at end of file": "après : pas de saut de ligne en fin de fichier",
@@ -3425,6 +3427,7 @@
"Measured time": "Temps mesuré",
"The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "Le temps que le runtime a mesuré autour de l’appel. Il inclut toute attente avant le début de l’appel, ce n’est donc pas le temps propre du serveur.",
"not reported": "non rapporté",
+ "Retention removed this record from the stored files; its time and outcome are kept.": "La rétention a supprimé cet enregistrement des fichiers stockés ; son heure et son résultat sont conservés.",
"Sent to the server": "Envoyé au serveur",
"Came back": "Retour",
"{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 5ebecfa8..de4cdba5 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -3164,6 +3164,7 @@
"found by a later scan; no observed tool window covers it": "後のスキャンで検出。観測されたツール時間窓のいずれにも含まれません",
"how many files changed is unknown": "変更されたファイル数は不明です",
"a complete observation found no changed file": "完全な観測で変更されたファイルは見つかりませんでした",
+ "{n} file(s) changed; the list was taken out to fit the record in its stored file": "{n} 個のファイルが変更されました。記録を保存ファイルに収めるため、一覧は省かれています",
"Partial coverage: a scan stopped early, so changes may be missing. Gaps:": "部分的な網羅:スキャンが途中で停止したため、変更が欠けている可能性があります。欠落:",
"Other windows were open on this root at the same time:": "同時にこのルートで開いていた他の時間窓:",
"shared with {windows}": "{windows} と共有",
@@ -3171,6 +3172,7 @@
"outside any observed window": "観測された時間窓の外",
"binary — path and hashes only, no diff": "バイナリ — パスとハッシュのみ、差分なし",
"too large to diff — path and hashes only": "差分を取るには大きすぎます — パスとハッシュのみ",
+ "diff taken out to fit its stored file — line counts, path and hashes only": "保存ファイルに収めるため差分は省かれました — 行数、パス、ハッシュのみ",
"no diff available": "差分はありません",
"before: no newline at end of file": "変更前:ファイル末尾に改行なし",
"after: no newline at end of file": "変更後:ファイル末尾に改行なし",
@@ -3425,6 +3427,7 @@
"Measured time": "計測時間",
"The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "ランタイムが呼び出しの前後で測った時間です。呼び出しが始まる前の待ち時間も含むため、サーバー自身の時間ではありません。",
"not reported": "報告なし",
+ "Retention removed this record from the stored files; its time and outcome are kept.": "保持期間の設定により、この記録は保存済みファイルから削除されました。時刻と結果は残っています。",
"Sent to the server": "サーバーへ送信",
"Came back": "返ってきた値",
"{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 68818d5d..d3afd79d 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -3164,6 +3164,7 @@
"found by a later scan; no observed tool window covers it": "이후 스캔에서 발견됨. 관측된 어떤 도구 창에도 속하지 않음",
"how many files changed is unknown": "변경된 파일 수를 알 수 없음",
"a complete observation found no changed file": "완전한 관측에서 변경된 파일을 찾지 못함",
+ "{n} file(s) changed; the list was taken out to fit the record in its stored file": "{n}개 파일이 변경되었습니다. 기록을 저장 파일에 맞추기 위해 목록은 제외되었습니다",
"Partial coverage: a scan stopped early, so changes may be missing. Gaps:": "부분 커버리지: 스캔이 일찍 중단되어 변경이 누락되었을 수 있습니다. 공백:",
"Other windows were open on this root at the same time:": "같은 시간에 이 루트에 열려 있던 다른 창:",
"shared with {windows}": "{windows}과(와) 공유",
@@ -3171,6 +3172,7 @@
"outside any observed window": "관측된 창 밖",
"binary — path and hashes only, no diff": "바이너리 — 경로와 해시만, diff 없음",
"too large to diff — path and hashes only": "diff하기에 너무 큼 — 경로와 해시만",
+ "diff taken out to fit its stored file — line counts, path and hashes only": "저장 파일에 맞추기 위해 diff를 제외했습니다 — 줄 수, 경로, 해시만 있습니다",
"no diff available": "diff 없음",
"before: no newline at end of file": "변경 전: 파일 끝에 줄바꿈 없음",
"after: no newline at end of file": "변경 후: 파일 끝에 줄바꿈 없음",
@@ -3425,6 +3427,7 @@
"Measured time": "측정 시간",
"The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "런타임이 호출 전후로 측정한 시간입니다. 호출이 시작되기 전의 대기 시간도 포함하므로 서버 자체의 시간이 아닙니다.",
"not reported": "보고되지 않음",
+ "Retention removed this record from the stored files; its time and outcome are kept.": "보존 정책에 따라 이 기록이 저장된 파일에서 제거되었습니다. 시간과 결과는 남아 있습니다.",
"Sent to the server": "서버로 전송",
"Came back": "돌아온 값",
"{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index d3c265c4..eef8f6fc 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -3164,6 +3164,7 @@
"found by a later scan; no observed tool window covers it": "encontrado por uma varredura posterior; nenhuma janela de ferramenta observada o cobre",
"how many files changed is unknown": "não se sabe quantos arquivos mudaram",
"a complete observation found no changed file": "uma observação completa não encontrou arquivo alterado",
+ "{n} file(s) changed; the list was taken out to fit the record in its stored file": "{n} arquivo(s) alterado(s); a lista foi retirada para que o registro coubesse no seu arquivo armazenado",
"Partial coverage: a scan stopped early, so changes may be missing. Gaps:": "Cobertura parcial: uma varredura parou cedo, então alterações podem estar faltando. Lacunas:",
"Other windows were open on this root at the same time:": "Outras janelas estavam abertas nesta raiz ao mesmo tempo:",
"shared with {windows}": "compartilhado com {windows}",
@@ -3171,6 +3172,7 @@
"outside any observed window": "fora de qualquer janela observada",
"binary — path and hashes only, no diff": "binário — apenas caminho e hashes, sem diff",
"too large to diff — path and hashes only": "grande demais para diff — apenas caminho e hashes",
+ "diff taken out to fit its stored file — line counts, path and hashes only": "diff retirado para caber no seu arquivo armazenado — só contagens de linhas, caminho e hashes",
"no diff available": "nenhum diff disponível",
"before: no newline at end of file": "antes: sem nova linha no fim do arquivo",
"after: no newline at end of file": "depois: sem nova linha no fim do arquivo",
@@ -3425,6 +3427,7 @@
"Measured time": "Tempo medido",
"The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "O tempo que o runtime mediu em torno da chamada. Inclui qualquer espera antes de a chamada começar, portanto não é o tempo do próprio servidor.",
"not reported": "não informado",
+ "Retention removed this record from the stored files; its time and outcome are kept.": "A retenção removeu este registro dos arquivos armazenados; sua hora e seu resultado são mantidos.",
"Sent to the server": "Enviado ao servidor",
"Came back": "Retornado",
"{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 886f90de..8383683e 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -3164,6 +3164,7 @@
"found by a later scan; no observed tool window covers it": "由后续扫描发现;不在任何已观测的工具时间窗内",
"how many files changed is unknown": "不知道有多少文件被更改",
"a complete observation found no changed file": "完整观测未发现被更改的文件",
+ "{n} file(s) changed; the list was taken out to fit the record in its stored file": "{n} 个文件已更改;为使记录装入其存储文件,文件列表已被移除",
"Partial coverage: a scan stopped early, so changes may be missing. Gaps:": "覆盖不完整:某次扫描提前终止,可能遗漏变更。缺口:",
"Other windows were open on this root at the same time:": "同一时间在该根目录上打开的其他时间窗:",
"shared with {windows}": "与 {windows} 共享",
@@ -3171,6 +3172,7 @@
"outside any observed window": "不在任何已观测的时间窗内",
"binary — path and hashes only, no diff": "二进制文件 — 仅有路径和哈希,无差异",
"too large to diff — path and hashes only": "文件过大无法比较 — 仅有路径和哈希",
+ "diff taken out to fit its stored file — line counts, path and hashes only": "为装入存储文件已移除差异 — 仅保留行数、路径和哈希",
"no diff available": "没有可用的差异",
"before: no newline at end of file": "变更前:文件末尾无换行",
"after: no newline at end of file": "变更后:文件末尾无换行",
@@ -3425,6 +3427,7 @@
"Measured time": "测得时间",
"The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "运行时在调用前后测得的时间。其中包含调用开始前的等待,因此不是服务器自身的时间。",
"not reported": "未报告",
+ "Retention removed this record from the stored files; its time and outcome are kept.": "保留策略已将该记录从存储的文件中删除;其时间和结果仍保留。",
"Sent to the server": "发送给服务器",
"Came back": "返回",
"{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
diff --git a/apps/ui/src/layer/ai-conversation/conversationStrings.ts b/apps/ui/src/layer/ai-conversation/conversationStrings.ts
index ef1a34ca..95068b83 100644
--- a/apps/ui/src/layer/ai-conversation/conversationStrings.ts
+++ b/apps/ui/src/layer/ai-conversation/conversationStrings.ts
@@ -244,6 +244,7 @@ export function conversationStrings(t: Translate): ViewStrings {
measuredTime: t('Measured time'),
measuredTimeText: t("The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time."),
notReported: t('not reported'),
+ executionRemoved: t('Retention removed this record from the stored files; its time and outcome are kept.'),
sentToServer: t('Sent to the server'),
cameBack: t('Came back'),
sizeAndDigest: t('{bytes} B · SHA-256 {digest}', { bytes: '{bytes}', digest: '{digest}' }),
@@ -311,6 +312,7 @@ export function conversationStrings(t: Translate): ViewStrings {
basisUnattributed: t('found by a later scan; no observed tool window covers it'),
changedFilesUnknown: t('how many files changed is unknown'),
changedFilesNone: t('a complete observation found no changed file'),
+ changedFilesNotKept: t('{n} file(s) changed; the list was taken out to fit the record in its stored file', { n: '{n}' }),
coveragePartial: t('Partial coverage: a scan stopped early, so changes may be missing. Gaps:'),
overlapsNote: t('Other windows were open on this root at the same time:'),
sharedWith: t('shared with {windows}', { windows: '{windows}' }),
@@ -318,6 +320,7 @@ export function conversationStrings(t: Translate): ViewStrings {
outsideWindow: t('outside any observed window'),
diffBinary: t('binary — path and hashes only, no diff'),
diffTooLarge: t('too large to diff — path and hashes only'),
+ diffTooLargeCounted: t('diff taken out to fit its stored file — line counts, path and hashes only'),
diffUnavailable: t('no diff available'),
noNewlineBefore: t('before: no newline at end of file'),
noNewlineAfter: t('after: no newline at end of file'),
diff --git a/docs/changelog/1.1.0.md b/docs/changelog/1.1.0.md
index 79eac8df..50615e9b 100644
--- a/docs/changelog/1.1.0.md
+++ b/docs/changelog/1.1.0.md
@@ -44,6 +44,8 @@
### Fixes
+**A tool call whose change record was made smaller no longer says it changed no file.** The AI Sessionizer takes the file list out of a change record too large for its stored file, keeping how many files changed. The tool card's mark and its Changes tab said "no files changed" for such a record; they now count its files and say the list was taken out to fit. A file whose diff was taken out the same way says its line counts were kept, rather than that it was too large to diff.
+
**The Prompt tab names a body that cites a piece too large to be stored.** The AI Sessionizer leaves a piece larger than its file budget, such as a pasted screenshot, out of the stored file, keeping its size and SHA-256, and a later body refers to that piece by its digest. The tab took such a body for one waiting on a file it had not read, so the panel kept saying a file of the session was not stored, and a body that holds or cites such a piece said only that it did not come back whole. [...]
## GenAI evaluation
diff --git a/packages/conversation-view/src/model.ts b/packages/conversation-view/src/model.ts
index a10c2785..0b650c50 100644
--- a/packages/conversation-view/src/model.ts
+++ b/packages/conversation-view/src/model.ts
@@ -105,6 +105,8 @@ export type Observation = 'skipped' | 'unknown' | 'none' | 'files';
export function observationOf(r: AszWorkspaceChange): Observation {
if (r.basis === 'skipped_read_only') return 'skipped';
if (r.changes && r.changes.length > 0) return 'files';
+ // A record made smaller to fit its file keeps its count when its file list goes.
+ if (r.changed_files !== null && r.changed_files > 0) return 'files';
// A scan that stopped early and found nothing has not found "nothing".
if (r.changed_files === null || r.coverage === 'partial') return 'unknown';
return 'none';
@@ -141,8 +143,32 @@ export interface ChangeTally {
observation: Observation;
}
-export function tallyChanges(records: AszWorkspaceChange[]): ChangeTally {
+/**
+ * How many distinct files records changed, by root and path. A record whose file list was taken out
+ * to fit its stored file names a count and no path, so its files cannot be told from another
+ * record's: alone, its count is exact; beside any other record, the larger of the counts is the
+ * least the records changed, and `floor` says so.
+ */
+export function countFiles(records: AszWorkspaceChange[]): { files: number; floor: boolean } {
const seen = new Set<string>();
+ let listed = 0;
+ let listless = 0;
+ let most = 0;
+ for (const r of records) {
+ if (r.changes && r.changes.length > 0) {
+ listed++;
+ for (const c of r.changes) seen.add(`${r.root?.path ?? ''}|${c.path}`);
+ } else if (r.changed_files !== null && r.changed_files > 0) {
+ listless++;
+ most = Math.max(most, r.changed_files);
+ }
+ }
+ if (listless === 0) return { files: seen.size, floor: false };
+ if (listless === 1 && listed === 0) return { files: most, floor: false };
+ return { files: Math.max(seen.size, most), floor: true };
+}
+
+export function tallyChanges(records: AszWorkspaceChange[]): ChangeTally {
let observation: Observation = 'skipped';
let incomplete = false;
const rank: Record<Observation, number> = { skipped: 0, none: 1, unknown: 2, files: 3 };
@@ -150,8 +176,9 @@ export function tallyChanges(records: AszWorkspaceChange[]): ChangeTally {
const o = observationOf(r);
if (rank[o] > rank[observation]) observation = o;
if (incompleteObservation(r)) incomplete = true;
- for (const c of r.changes ?? []) seen.add(`${r.root?.path ?? ''}|${c.path}`);
}
+ const counted = countFiles(records);
+ if (counted.floor) incomplete = true;
const withFiles = records.filter((r) => (r.changes?.length ?? 0) > 0);
let additions: number | null = null;
let deletions: number | null = null;
@@ -166,7 +193,7 @@ export function tallyChanges(records: AszWorkspaceChange[]): ChangeTally {
deletions = (deletions ?? 0) + c.deletions;
}
}
- return { files: seen.size, incomplete, additions, deletions, observation };
+ return { files: counted.files, incomplete, additions, deletions, observation };
}
/** One file as the conversation-level panel lists it: every record that
diff --git a/packages/conversation-view/src/prompt/store.ts b/packages/conversation-view/src/prompt/store.ts
index d691b618..26b6069b 100644
--- a/packages/conversation-view/src/prompt/store.ts
+++ b/packages/conversation-view/src/prompt/store.ts
@@ -192,6 +192,8 @@ 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;
+ // A list left out or written as null is an empty one, as the Sessionizer's own decoder reads it.
+ if (m.segments === undefined || m.segments === null) m.segments = [];
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
diff --git a/packages/conversation-view/src/strings.ts b/packages/conversation-view/src/strings.ts
index 9f15833c..832d92e4 100644
--- a/packages/conversation-view/src/strings.ts
+++ b/packages/conversation-view/src/strings.ts
@@ -252,6 +252,7 @@ export interface ViewStrings {
measuredTime: string;
measuredTimeText: string;
notReported: string;
+ executionRemoved: string;
sentToServer: string;
cameBack: string;
sizeAndDigest: string;
@@ -320,6 +321,7 @@ export interface ViewStrings {
basisUnattributed: string;
changedFilesUnknown: string;
changedFilesNone: string;
+ changedFilesNotKept: string;
coveragePartial: string;
overlapsNote: string;
sharedWith: string;
@@ -327,6 +329,7 @@ export interface ViewStrings {
outsideWindow: string;
diffBinary: string;
diffTooLarge: string;
+ diffTooLargeCounted: string;
diffUnavailable: string;
noNewlineBefore: string;
noNewlineAfter: string;
@@ -581,6 +584,7 @@ export const ENGLISH: ViewStrings = {
measuredTime: 'Measured time',
measuredTimeText: "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.",
notReported: 'not reported',
+ executionRemoved: 'Retention removed this record from the stored files; its time and outcome are kept.',
sentToServer: 'Sent to the server',
cameBack: 'Came back',
sizeAndDigest: '{bytes} B · SHA-256 {digest}',
@@ -648,6 +652,7 @@ export const ENGLISH: ViewStrings = {
basisUnattributed: 'found by a later scan; no observed tool window covers it',
changedFilesUnknown: 'how many files changed is unknown',
changedFilesNone: 'a complete observation found no changed file',
+ changedFilesNotKept: '{n} file(s) changed; the list was taken out to fit the record in its stored file',
coveragePartial: 'Partial coverage: a scan stopped early, so changes may be missing. Gaps:',
overlapsNote: 'Other windows were open on this root at the same time:',
sharedWith: 'shared with {windows}',
@@ -655,6 +660,7 @@ export const ENGLISH: ViewStrings = {
outsideWindow: 'outside any observed window',
diffBinary: 'binary — path and hashes only, no diff',
diffTooLarge: 'too large to diff — path and hashes only',
+ diffTooLargeCounted: 'diff taken out to fit its stored file — line counts, path and hashes only',
diffUnavailable: 'no diff available',
noNewlineBefore: 'before: no newline at end of file',
noNewlineAfter: 'after: no newline at end of file',
diff --git a/packages/conversation-view/src/types.ts b/packages/conversation-view/src/types.ts
index f67bf52f..af3c8be6 100644
--- a/packages/conversation-view/src/types.ts
+++ b/packages/conversation-view/src/types.ts
@@ -152,6 +152,9 @@ export interface AszToolExecution {
duration_ms?: number;
/** `returned` | `failed` | `interrupted`. */
outcome: string;
+ /** `removed` when the Sessionizer's retention removed the record from its own root, keeping its
+ * id, tool, time and outcome; absent otherwise. */
+ state?: string;
arguments?: AszExecutionValue;
/** Only on a call that returned. */
result?: AszExecutionValue;
@@ -244,6 +247,11 @@ export interface AszWorkspaceChange {
/** Null is unknown, which is not zero. */
changed_files: number | null;
changes: AszFileChange[] | null;
+ /** `truncated` when the record was made smaller to fit its stored file: its hunks, or its file
+ * list, were taken out, and `changed_files` stays. `removed` when the Sessionizer's retention
+ * removed it from its own root, keeping each file's path, operation and line counts. Absent when
+ * it landed whole. */
+ state?: string;
}
export interface AszOrigin {
diff --git a/packages/conversation-view/src/view/changes.ts b/packages/conversation-view/src/view/changes.ts
index 08ae0d7a..2364e18f 100644
--- a/packages/conversation-view/src/view/changes.ts
+++ b/packages/conversation-view/src/view/changes.ts
@@ -34,6 +34,7 @@ import {
changeRoots,
groupChanges,
incompleteObservation,
+ countFiles,
observationOf,
tallyChanges,
type Step,
@@ -120,7 +121,10 @@ function sizes(ctx: ViewContext, c: AszFileChange): string {
function diffHtml(ctx: ViewContext, key: string, c: AszFileChange): string {
const { s, f, state } = ctx;
if (c.diff !== 'available') {
- const why = c.diff === 'binary' ? s.diffBinary : c.diff === 'too_large' ? s.diffTooLarge : s.diffUnavailable;
+ // A diff taken out to fit its record keeps the file's line counts; one too large to compute has none.
+ const counted = c.additions != null || c.deletions != null;
+ const why =
+ c.diff === 'binary' ? s.diffBinary : c.diff === 'too_large' ? (counted ? s.diffTooLargeCounted : s.diffTooLarge) : s.diffUnavailable;
const sha = (side: AszChangeSide): string =>
side.sha256 ? `<span title="${esc(side.sha256)}">${esc(side.sha256.slice(0, 12))}…</span>` : '∅';
return `<div class="acv-diff-note">${esc(why)}<span class="acv-hashes">sha256 ${sha(c.before)} → ${sha(c.after)}</span></div>`;
@@ -172,6 +176,10 @@ function observationNote(s: ViewStrings, r: AszWorkspaceChange): string {
case 'none':
return `<div class="acv-change-note">${esc(s.changedFilesNone)}</div>`;
default:
+ // Files changed, and their list was taken out to fit the record in its stored file.
+ if (!(r.changes?.length ?? 0) && r.changed_files) {
+ return `<div class="acv-change-note">${esc(fill(s.changedFilesNotKept, { n: String(r.changed_files) }))}</div>`;
+ }
return '';
}
}
@@ -323,36 +331,57 @@ export function drawChangesPanel(ctx: ViewContext): void {
const { s, f, model: m } = ctx;
const body = ctx.q('.acv-changes-body');
const all = m.workspaceChanges;
- const files = new Set<string>();
- for (const r of all) for (const c of r.changes ?? []) files.add(`${r.root?.path ?? ''}|${c.path}`);
- const floor = all.some(incompleteObservation) ? '+' : '';
- ctx.q('.acv-changes-title').textContent = `${s.changesPanelTitle} · ${fill(s.recordsAndFiles, { records: f.number(all.length), files: `${f.number(files.size)}${floor}` })}`;
+ const counted = countFiles(all);
+ const floor = counted.floor || all.some(incompleteObservation) ? '+' : '';
+ ctx.q('.acv-changes-title').textContent = `${s.changesPanelTitle} · ${fill(s.recordsAndFiles, { records: f.number(all.length), files: `${f.number(counted.files)}${floor}` })}`;
// What a tool made is listed under its root whether or not the fold knew
// the step; only a record the plugin itself could not attribute goes below.
const attributed = all
.filter((r) => r.basis !== 'unattributed')
.map((r) => ({ ...r, changes: (r.changes ?? []).filter((c) => c.attribution !== 'outside_any_window') }));
- const entry = (r: AszWorkspaceChange, c: AszFileChange): string => {
+ const link = (r: AszWorkspaceChange): string => {
const step = m.step(r.step);
const when = Number.isFinite(Date.parse(r.time)) ? f.time(Date.parse(r.time)) : r.time;
// A record whose step the fold does not know still names its tool; the
// missing step is said, not implied by a label that cannot be clicked.
const who = step ? (step.name ?? step.kind) : [r.tool_name, s.noStepForRecord].filter(Boolean).join(' · ');
const label = `${who} · ${r.captured_by} · ${when}`;
- const target = step
+ return step
? `<button type="button" class="acv-linkish" data-goto="${esc(step.id)}" title="${esc(s.goToStep)}">${esc(label)} →</button>`
: `<span class="acv-faint">${esc(label)}</span>`;
- return `<div class="acv-panel-entry">${target} ${counts(ctx, c.additions, c.deletions)}${marks(s, c)}</div>`;
};
+ const entry = (r: AszWorkspaceChange, c: AszFileChange): string =>
+ `<div class="acv-panel-entry">${link(r)} ${counts(ctx, c.additions, c.deletions)}${marks(s, c)}</div>`;
+ // A record whose file list was taken out to fit its stored file names no file, so it is listed by
+ // its count, linked to its step, and never left out of the panel: under its root, or with what no
+ // observed window made when no window explains it.
+ const allListless = all.filter((r) => !(r.changes?.length ?? 0) && r.changed_files !== null && r.changed_files > 0);
+ const listless = allListless.filter((r) => r.basis !== 'unattributed');
+ const countOnly = (r: AszWorkspaceChange, prefix = ''): string =>
+ `<div class="acv-panel-file"><div class="acv-panel-file-head"><span class="acv-faint">${esc(prefix)}${esc(
+ fill(s.changedFilesNotKept, { n: String(r.changed_files) }),
+ )}</span></div><div class="acv-panel-entry">${link(r)}</div></div>`;
+ const shortened = (root: string): string =>
+ listless
+ .filter((r) => (r.root?.path ?? '') === root)
+ .map((r) => countOnly(r))
+ .join('');
+ const heading = (root: string): string => `<div class="acv-change-root">${esc(s.workspaceRoot)} <span class="mono">${esc(root || '—')}</span></div>`;
+ const drawn = new Set<string>();
let html = attributed.some((r) => m.step(r.step)) ? `<div class="acv-panel-hint">${esc(s.changesPanelHint)}</div>` : '';
for (const root of changeRoots(attributed)) {
- html += `<div class="acv-change-root">${esc(s.workspaceRoot)} <span class="mono">${esc(root.root || '—')}</span></div>`;
+ drawn.add(root.root);
+ html += heading(root.root);
for (const fl of root.files) {
const op = fl.entries[fl.entries.length - 1]!.change.operation;
html += `<div class="acv-panel-file"><div class="acv-panel-file-head"><span class="acv-op ${esc(op)}">${esc(op)}</span><span class="acv-file-path">${esc(fl.path)}</span></div>${fl.entries
.map((e) => entry(e.record, e.change))
.join('')}</div>`;
}
+ html += shortened(root.root);
+ }
+ for (const root of new Set(listless.map((r) => r.root?.path ?? ''))) {
+ if (!drawn.has(root)) html += heading(root) + shortened(root);
}
const outside: Array<{ record: AszWorkspaceChange; change: AszFileChange }> = [];
for (const r of all) {
@@ -360,13 +389,15 @@ export function drawChangesPanel(ctx: ViewContext): void {
if (r.basis === 'unattributed' || c.attribution === 'outside_any_window') outside.push({ record: r, change: c });
}
}
- if (outside.length) {
+ const outsideListless = allListless.filter((r) => r.basis === 'unattributed');
+ if (outside.length || outsideListless.length) {
html += `<div class="acv-change-root acv-warn">${esc(s.outsideWindowsHeading)}</div><div class="acv-loose-note">${esc(s.outsideWindowsNote)}</div>`;
for (const e of outside) {
html += `<div class="acv-panel-file"><div class="acv-panel-file-head"><span class="acv-op ${esc(e.change.operation)}">${esc(e.change.operation)}</span><span class="acv-file-path">${esc(
e.record.root?.path ? `${e.record.root.path}/` : '',
)}${esc(e.change.path)}</span></div>${entry(e.record, e.change)}</div>`;
}
+ for (const r of outsideListless) html += countOnly(r, r.root?.path ? `${r.root.path} · ` : '');
}
body.innerHTML = html || `<div class="acv-empty">—</div>`;
body.querySelectorAll<HTMLElement>('[data-goto]').forEach(
diff --git a/packages/conversation-view/src/view/executions.ts b/packages/conversation-view/src/view/executions.ts
index 97f104f0..88d4096d 100644
--- a/packages/conversation-view/src/view/executions.ts
+++ b/packages/conversation-view/src/view/executions.ts
@@ -54,6 +54,16 @@ export function drawExecutionTab(ctx: ViewContext, body: HTMLElement, step: Step
let html = '';
for (const r of m.executionsOf(step.id)) {
const ended = Date.parse(r.time);
+ // A record the Sessionizer's retention removed keeps its call, its time and its outcome; the rest
+ // was not left unreported, it was removed.
+ if (r.state === 'removed') {
+ html += `<div class="acv-kicker" style="margin-top:14px">${esc(s.executionRemoved)}</div>`;
+ html += `<dl class="acv-definition">`;
+ html += row(s.outcomeWord, esc(r.outcome), true);
+ html += row(s.observedAt, Number.isFinite(ended) ? esc(f.dateTime(ended)) : esc(r.time));
+ html += `</dl>`;
+ continue;
+ }
html += `<div class="acv-kicker" style="margin-top:14px">${esc(s.observedBy)} <span class="mono">${esc(r.observed_by)} · ${esc(r.boundary)}</span></div>`;
html += `<dl class="acv-definition">`;
html += row(s.mcpServer, r.server ? esc(r.server.name) : '—', true);
diff --git a/packages/conversation-view/test/changes.test.ts b/packages/conversation-view/test/changes.test.ts
index edc47a91..b49c2264 100644
--- a/packages/conversation-view/test/changes.test.ts
+++ b/packages/conversation-view/test/changes.test.ts
@@ -365,3 +365,64 @@ describe('the inspector', () => {
expect(root.querySelector('[data-tab="details"]')!.getAttribute('aria-selected')).toBe('true');
});
});
+
+describe('a change record made smaller to fit its stored file', () => {
+ const truncated: AszWorkspaceChange = { ...bashRecord, changes: [], changed_files: 3, state: 'truncated' };
+
+ it('counts the files its list no longer names, and never says none changed', () => {
+ expect(observationOf(truncated)).toBe('files');
+ const t = tallyChanges([truncated]);
+ expect(t.files).toBe(3);
+ expect(t.observation).toBe('files');
+ const root = mount(withRecords(fixture, [truncated]));
+ openFold(root);
+ const pill = root.querySelector<HTMLButtonElement>(`[data-card="${BASH}"] .acv-change-pill`)!;
+ expect(pill.textContent).toContain('3 files');
+ expect(pill.textContent).not.toContain('no files changed');
+ pill.click();
+ expect(root.textContent).toContain('3 file(s) changed; the list was taken out to fit the record in its stored file');
+ });
+
+ it('counts a shortened record alone exactly, and beside another record as the least they changed', () => {
+ // Two producers saw the same two files. Shortening either, or both, cannot make them four.
+ const plugin: AszWorkspaceChange = { ...bashRecord, id: `${bashRecord.id}/plugin`, captured_by: 'asz-plugin' };
+ const short = (r: AszWorkspaceChange): AszWorkspaceChange => ({ ...r, changes: [], changed_files: r.changes!.length, state: 'truncated' });
+ expect(tallyChanges([bashRecord, plugin])).toMatchObject({ files: 2, incomplete: false });
+ expect(tallyChanges([short(bashRecord), plugin])).toMatchObject({ files: 2, incomplete: true });
+ expect(tallyChanges([short(bashRecord), short(plugin)])).toMatchObject({ files: 2, incomplete: true });
+ expect(tallyChanges([short(bashRecord)])).toMatchObject({ files: 2, incomplete: false });
+ });
+
+ it('lists a shortened record in the conversation panel by its count, linked to its step', () => {
+ const root = mount(withRecords(fixture, [truncated]));
+ root.querySelector<HTMLButtonElement>('.acv-changes-toggle')!.click();
+ const panel = root.querySelector('.acv-changes')!;
+ expect(root.querySelector('.acv-changes-title')!.textContent).toContain('1 records · 3 files');
+ expect(panel.textContent).toContain('3 file(s) changed; the list was taken out to fit the record in its stored file');
+ expect(panel.querySelector(`[data-goto="${BASH}"]`)).not.toBeNull();
+ });
+
+ it('lists a shortened record no window explains with what no window made, by its count', () => {
+ const unattributed: AszWorkspaceChange = { ...truncated, id: 'gap/r/1', step: '', tool: undefined, basis: 'unattributed' };
+ const root = mount(withRecords(fixture, [unattributed]));
+ root.querySelector<HTMLButtonElement>('.acv-changes-toggle')!.click();
+ const panel = root.querySelector('.acv-changes')!;
+ expect(root.querySelector('.acv-changes-title')!.textContent).toContain('1 records · 3 files');
+ expect(panel.textContent).toContain('outside observed tool windows');
+ expect(panel.textContent).toContain('3 file(s) changed; the list was taken out to fit the record in its stored file');
+ });
+
+ it('says a diff taken out kept its line counts, and one too large to compute did not', () => {
+ const counted = { ...bashRecord.changes![0]!, diff: 'too_large', hunks: undefined, additions: 4, deletions: 1 };
+ const uncounted = { ...bashRecord.changes![1]!, diff: 'too_large', hunks: undefined, additions: null, deletions: null };
+ const root = mount(withRecords(fixture, [{ ...bashRecord, changes: [counted, uncounted], state: 'truncated' }]));
+ openFold(root);
+ root.querySelector<HTMLButtonElement>(`[data-card="${BASH}"] .acv-change-pill`)!.click();
+ // Opening a file draws the panel again, so each row is found again before it is opened.
+ const keys = new Set([...root.querySelectorAll<HTMLButtonElement>('[data-change-file]')].map((b) => b.dataset.changeFile!));
+ for (const key of keys) [...root.querySelectorAll<HTMLButtonElement>('[data-change-file]')].find((b) => b.dataset.changeFile === key)!.click();
+ const text = root.textContent!;
+ expect(text).toContain('diff taken out to fit its stored file — line counts, path and hashes only');
+ expect(text).toContain('too large to diff — path and hashes only');
+ });
+});
diff --git a/packages/conversation-view/test/mcp.test.ts b/packages/conversation-view/test/mcp.test.ts
index 6caa035d..886e60ae 100644
--- a/packages/conversation-view/test/mcp.test.ts
+++ b/packages/conversation-view/test/mcp.test.ts
@@ -214,3 +214,18 @@ describe('an MCP call on the page', () => {
expect(root.querySelector<HTMLButtonElement>('[data-tab="execution"]')!.hidden).toBe(true);
});
});
+
+describe('an execution record retention removed', () => {
+ it('says it was removed, with the time and outcome it keeps, and never that the rest was not reported', () => {
+ const doc = JSON.parse(JSON.stringify(fixture)) as AszViewDocument;
+ const r = doc.tool_executions!.find((e) => e.step === LOOKUP)!;
+ Object.assign(r, { state: 'removed', observed_by: '', boundary: '', server: undefined, duration_ms: undefined, arguments: undefined, result: undefined });
+ const root = mount(doc);
+ openFold(root);
+ root.querySelector<HTMLButtonElement>(`[data-card="${LOOKUP}"] .acv-execution-pill`)!.click();
+ const body = root.querySelector('.acv-inspector-body')!.textContent!;
+ expect(body).toContain('Retention removed this record from the stored files; its time and outcome are kept.');
+ expect(body).toContain('returned');
+ expect(body).not.toContain('not reported');
+ });
+});
diff --git a/packages/conversation-view/test/prompt-removed.test.ts b/packages/conversation-view/test/prompt-removed.test.ts
index 35905634..4e8f6dd2 100644
--- a/packages/conversation-view/test/prompt-removed.test.ts
+++ b/packages/conversation-view/test/prompt-removed.test.ts
@@ -258,3 +258,17 @@ describe('the prompt panel over a removal', () => {
expect(body.textContent).toContain('Whole request');
});
});
+
+describe('a manifest with no segment list', () => {
+ it('reads a list left out or written as null as an empty one, as the Sessionizer does', () => {
+ const empty = digest(new Uint8Array());
+ for (const segments of [', "segments": null', '']) {
+ const line = `{"ord":1,"id":"empty","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","sha256":"${empty}","bytes":0,"depth":0${segments}}}]}`;
+ const file = new TextEncoder().encode(`{"h":1,"kind":"provider_body"}\n${line}\n{"t":"end","records":1}\n`);
+ const s = new PromptStore();
+ s.addFile({ seq: 1, file: 'provider_body.sd', bytes: file });
+ expect(readProviderFile(1, file)).toHaveLength(1);
+ expect(s.bodyAt(1, 1)).toHaveLength(0);
+ }
+ });
+});