Просмотр исходного кода

fix(web): address review findings on producer-declared context forms

- `catalogHistory` validated its durable read. `agent.session.events` is a
  JSONL/SQLite seed on resume or fork, and seed validation guarantees only a
  source object with a non-empty `kind`; a `skill-catalog` record with missing
  or wrongly shaped `entries` threw inside the step listener, failing every
  later turn of that session. It is now skipped as an unrecognizable record,
  the posture the replaced content digest had, with a regression test over six
  malformed shapes.
- The headless keyless smoke still filtered catalogs by the old plugin source,
  so the `built-bin-smoke` gate would not have found the catalog message.
- Entries record the published description unescaped. The pseudo-XML escaping
  belongs to the `<available_skills>` frame and is applied when rendering it,
  so a description containing `<` no longer reaches the card as `&lt;`.
  `escapeText` is injective, so republish semantics and the model-facing text
  are unchanged.
- Adjacent text blocks join with no separator, matching how provider adapters
  flatten them; the body no longer shows a line break the model never saw.
- Provenance fields are bounded like the text: an unknown producer may record
  an arbitrarily large value.
- Both readers are all-or-nothing, and the row's form marker reports what
  rendered rather than what was declared, so a partly unreadable record cannot
  present a confident but incomplete account.
- The catalog body consumes `update` as a replacement notice; the digest
  canonicalizes per entry as JSON, since every separator character is itself
  legal in a description.
- `core.md` documents the form axis with a `ContextForm` type-equiv block, and
  both projections assert the wiring they duplicate.
creatixchu 1 месяц назад
Родитель
Сommit
ebe6ca20ad

+ 2 - 2
.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md
-2026-08-05-context-form-vocabulary.md: c2369a21a474e677eb324b6f8d2457091ee44e30
-2026-08-05-context-form-vocabulary.zh.md: 3c16279a4e36742eb7c5d60aa1c5ad9a2ae07cd6
+2026-08-05-context-form-vocabulary.md: 20577b0ad0eaa9eb8dc8528cedd18d15439ad299
+2026-08-05-context-form-vocabulary.zh.md: 154e8ae54fc6b27ce782533b4246d48190639128

+ 8 - 2
.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md

@@ -23,10 +23,16 @@ The vocabulary is semantic, never visual. A value states that the content is a f
 
 **`instructions`** — instructions read out of workspace files. `workspace-context` declares it on both the startup baseline and later deltas; its existing `changes[]` already carried the paths, actions, and digests the presentation needs, so no field was added. The body lists the reconciled files above the text, and keeps the `<system-reminder>` framing verbatim: the framing is part of what the model read, so hiding it would misreport the request.
 
-**`catalog`** — a catalog of items available this session, republished as it changes. `dsh-tool-skill` moves off the shared `plugin` kind to its own `skill-catalog` source carrying `entries` (the exact `name`/`description` pairs published) and `update` on a replacement. The body lists those entries instead of re-parsing the `<available_skills>` block out of the prose.
+**`catalog`** — a catalog of items available this session, republished as it changes. `dsh-tool-skill` moves off the shared `plugin` kind to its own `skill-catalog` source carrying `entries` (the exact `name`/`description` pairs published) and `update` on a replacement, which the body renders as a replacement notice. The body lists those entries instead of re-parsing the `<available_skills>` block out of the prose.
+
+Entries record the published fact **unescaped**. The pseudo-XML escaping belongs to the `<available_skills>` frame, which exists for the model, so it is applied when rendering that frame and never stored; otherwise a consumer would have to know the frame's encoding to display a description containing `<`, and the same frame knowledge this decision removes would leak back in another shape. `escapeText` is deterministic and injective, so digesting the unescaped entries preserves republish semantics exactly, and the model-facing text stays byte-identical.
 
 That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits.
 
+Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared.
+
+The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point.
+
 Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining provenance as fields. Opaque is the documented default, not a leftover bin. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer.
 
 ## Why not a presenter registry
@@ -47,7 +53,7 @@ The tool seam pairs its vocabulary with `presentCall(args)`, a host-side pure fu
 
 - `packages/client/runtime` pins the form projection, including the unknown, empty, wrongly-typed, and absent values that must degrade to opaque.
 - `packages/client/ui-conversation` pins each body: the opaque body's preserved line breaks and provenance fields, the instructions body's file list and verbatim framing, the catalog body's entry list, and a catalog with unusable entries falling back to opaque.
-- `packages/skill/tool-skill` pins the new source on first publication and replacement, and republish behavior driven by the durable entries.
+- `packages/skill/tool-skill` pins the new source on first publication and replacement, republish behavior driven by the durable entries, and a malformed durable catalog leaving step observation intact.
 - The keyless assembled-Web seeded-history scenario expands a real `instructions` context in Chromium and asserts its file list, verbatim framing, and the unchanged disclosure geometry. `catalog` has no assembled coverage: the hermetic scaffold publishes no skills, so no catalog reaches a browser scenario.
 
 ## Consequences

+ 8 - 2
.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md

@@ -23,10 +23,16 @@ Status: implemented
 
 **`instructions`**——从工作区文件中读出的指令。`workspace-context` 在启动基线与后续增量上都声明它;其既有的 `changes[]` 已经携带了呈现所需的路径、动作与 digest,因此没有新增字段。内容区在正文之上列出对账过的文件,并原样保留 `<system-reminder>` 包装:那层包装本就是模型读到的一部分,隐藏它会歪曲这次请求。
 
-**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`。内容区直接列出这些条目,不再从散文里反解 `<available_skills>` 块。
+**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`,后者由内容区渲染成替换提示。内容区直接列出这些条目,不再从散文里反解 `<available_skills>` 块。
+
+条目记录的是**未转义**的发布事实。伪 XML 转义属于 `<available_skills>` 这层为模型而设的框架,因此只在渲染该框架时施加、从不存储;否则消费方要正确展示含 `<` 的描述就得知道框架的编码方式,本决策刚移除的框架知识会换一种形式泄漏回来。`escapeText` 确定且单射,故对未转义条目取 digest 与此前完全等价,重新发布语义不变,面向模型的文本逐字节不变。
 
 这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。
 
+两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。
+
+生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.events` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。
+
 其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源信息列成字段。opaque 是有文档的默认,不是兜底垃圾桶。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。
 
 ## 为什么不做 presenter 注册表
@@ -47,7 +53,7 @@ Status: implemented
 
 - `packages/client/runtime` 钉住形态投影,包括必须降级为 opaque 的未知值、空值、类型不符与缺失。
 - `packages/client/ui-conversation` 逐个钉住内容区:opaque 的换行留存与来源字段、instructions 的文件列表与原样包装、catalog 的条目列表,以及条目不可用的 catalog 回落到 opaque。
-- `packages/skill/tool-skill` 钉住首次发布与替换时的新来源,以及由持久条目驱动的重新发布行为
+- `packages/skill/tool-skill` 钉住首次发布与替换时的新来源、由持久条目驱动的重新发布行为,以及畸形持久目录不打断步骤观察
 - 无密钥的组装 Web seeded-history 场景在 Chromium 中展开一条真实的 `instructions` 上下文,断言其文件列表、原样包装与未改动的展开项几何。`catalog` 没有组装态覆盖:隔离脚手架不发布任何 skill,因此没有目录能进入浏览器场景。
 
 ## Consequences

+ 2 - 2
docs/core-data-structures/core.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/core-data-structures/core.md
-core.md: 6c12b8af07498590bb8eb5b92c3c8f6bbef313f3
-core.zh.md: f83e8873adfae5ab81c02678605f4014214b96b6
+core.md: d91e84a7066b004567ed9f668ded52ee486a1c99
+core.zh.md: f9cf2c14fe1021d7862d8bb14696044b7d8f7fbb

+ 26 - 0
docs/core-data-structures/core.md

@@ -167,6 +167,32 @@ interface MessageSourceMap {
 }
 ```
 
+Provenance and shape are two independent axes. `kind` answers *who produced this*; the optional `form` a producer mixes in answers *what shape of information it is*, so several producers may share one presentation and one producer may emit more than one shape over a session. The vocabulary is semantic and grows one value at a time; an absent or unrecognized value is the documented default, presented as opaque content:
+
+```ts type-equiv
+/**
+ * What SHAPE of information a producer-supplied context carries, declared by
+ * the producer beside its provenance.
+ *
+ * `MessageSource.kind` answers *who produced this*; `form` answers *what kind
+ * of thing it is*, and the two axes are deliberately independent — several
+ * producers share one form (three snapshot producers today), and one producer
+ * may emit more than one form over a session.
+ *
+ * The vocabulary is SEMANTIC, never visual: a value states that the content is
+ * a file's instructions or a catalog of available items, and a consumer decides
+ * what that looks like. Colors, icons, ordering, and collapse defaults are the
+ * consumer's business and must not enter this union. It grows one value at a
+ * time as producers gain the structured fields their form needs; an absent or
+ * unknown value is the documented default, presented as opaque content.
+ */
+type ContextForm =
+  /** Instructions read out of workspace files the model is expected to follow. */
+  | 'instructions'
+  /** A catalog of items available in this session, republished as it changes. */
+  | 'catalog'
+```
+
 ## Streaming
 
 Adapters emit a raw **chunk** protocol; the loop logs the chunks (replay fidelity) while feeding the same chunks through a `BlockAssembler` to rebuild blocks and messages. `StreamChunk` is a closed discriminated union over `type` — `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`.

+ 26 - 0
docs/core-data-structures/core.zh.md

@@ -173,6 +173,32 @@ interface MessageSourceMap {
 }
 ```
 
+溯源与形态是相互独立的两根轴。`kind` 回答「由谁产生」;生产方可选混入的 `form` 回答「这是何种形态的信息」,因此多个生产方可以共用一种呈现,一个生产方在一次会话中也可以发出多种形态。该词汇表是语义的,逐个取值增长;未声明或无法识别的取值是有文档的默认,按不透明内容呈现:
+
+```ts type-equiv
+/**
+ * What SHAPE of information a producer-supplied context carries, declared by
+ * the producer beside its provenance.
+ *
+ * `MessageSource.kind` answers *who produced this*; `form` answers *what kind
+ * of thing it is*, and the two axes are deliberately independent — several
+ * producers share one form (three snapshot producers today), and one producer
+ * may emit more than one form over a session.
+ *
+ * The vocabulary is SEMANTIC, never visual: a value states that the content is
+ * a file's instructions or a catalog of available items, and a consumer decides
+ * what that looks like. Colors, icons, ordering, and collapse defaults are the
+ * consumer's business and must not enter this union. It grows one value at a
+ * time as producers gain the structured fields their form needs; an absent or
+ * unknown value is the documented default, presented as opaque content.
+ */
+type ContextForm =
+  /** Instructions read out of workspace files the model is expected to follow. */
+  | 'instructions'
+  /** A catalog of items available in this session, republished as it changes. */
+  | 'catalog'
+```
+
 ## 流式输出
 
 适配器发出原始**分片**协议;循环记录分片(回放保真度),同时将同一批分片送入 `BlockAssembler` 以重建块和消息。`StreamChunk` 是基于 `type` 的封闭判别联合——`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`。

+ 1 - 2
examples/headless-agent/tests/keyless-smoke.e2e.ts

@@ -39,8 +39,7 @@ describe('headless-agent keyless smoke', () => {
     expect(stderr).toBe('')
     expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true)
     const catalogMessage = events.find(event => event.type === 'user/message'
-      && event.data.source.kind === 'plugin'
-      && event.data.source.plugin === 'dsh-tool-skill')
+      && event.data.source.kind === 'skill-catalog')
     const catalog = catalogMessage?.type === 'user/message'
       ? catalogMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('\n')
       : ''

+ 5 - 2
packages/client/runtime/tests/history-fold.spec.ts

@@ -19,12 +19,15 @@ describe('projectConversationHistory', () => {
         // A plugin source, because the client program does not see the host
         // packages that merge richer source kinds; those arms are pinned in
         // context-provenance.spec.ts.
-        source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
+        source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
       }),
     })
     const { contexts } = projectConversationHistory([{ event: injected }])
     expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
-      kind: 'context', seq: 0, provenance: { role: 'inject', label: 'dsh-tool-skill' },
+      kind: 'context',
+      seq: 0,
+      provenance: { role: 'inject', label: 'dsh-tool-skill' },
+      form: 'catalog',
     }])
   })
 

+ 5 - 2
packages/client/runtime/tests/transcript-adapter.spec.ts

@@ -205,11 +205,14 @@ describe('TranscriptAdapter', () => {
       adapter.reset([
         at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
           content: [{ type: 'text', text: '注入的上下文' }],
-          source: { kind: 'plugin', plugin: 'compact' },
+          source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
         }) }),
       ])
       expect(adapter.nodes()).toMatchObject([{
-        kind: 'context', seq: 0, provenance: { role: 'inject', label: 'compact' },
+        kind: 'context',
+        seq: 0,
+        provenance: { role: 'inject', label: 'compact' },
+        form: 'instructions',
       }])
     })
 

+ 7 - 1
packages/client/ui-conversation/src/client/chat/ContextBody.module.css

@@ -65,7 +65,13 @@
   color: var(--dsw-alias-label-caption);
 }
 
-/* catalog: one row per published entry. */
+/* catalog: a replacement notice above one row per published entry. */
+.catalogNotice {
+  margin: 0 0 6px;
+  color: var(--dsw-alias-label-caption);
+}
+
+
 .entries {
   display: flex;
   flex-direction: column;

+ 113 - 48
packages/client/ui-conversation/src/client/chat/ContextBody.tsx

@@ -5,7 +5,7 @@
 // even when this UI version has never seen its producer.
 
 import type { ReactNode } from 'react'
-import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ContextMessageNode, KnownContextForm } from '@deepseek-ai/dsh-client-runtime/client'
 import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
 import css from './ContextBody.module.css'
@@ -27,6 +27,9 @@ function asRecord(value: unknown): Record<string, unknown> | null {
  * Context is text in practice (every producer injects one text block), but the
  * block union is merge-extensible, so an unknown block keeps its own fallback
  * rather than vanishing.
+ *
+ * Blocks join with no separator, matching how provider adapters flatten them:
+ * inserting a line break here would show the reader a line the model never saw.
  */
 function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } {
   const texts: string[] = []
@@ -35,7 +38,7 @@ function partitionContent(content: ContextMessageNode['content']): { text: strin
     if (block.type === 'text') texts.push(block.text)
     else rest.push(block)
   }
-  return { text: texts.join('\n'), rest }
+  return { text: texts.join(''), rest }
 }
 
 /** The model-facing text, truncated to the display bound. */
@@ -45,11 +48,16 @@ function boundedText(text: string, t: Translate): string {
     : text
 }
 
-/** One source field rendered as a value row; nested shapes stay compact JSON. */
-function fieldValue(value: unknown): string {
-  if (typeof value === 'string') return value
-  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
-  return JSON.stringify(value)
+/**
+ * One source field rendered as a value row; nested shapes stay compact JSON.
+ * Bounded on its own, because provenance is as unbounded as the text: an unknown
+ * producer may record an arbitrarily large string or array.
+ */
+function fieldValue(value: unknown, t: Translate): string {
+  const text = typeof value === 'string'
+    ? value
+    : typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value)
+  return boundedText(text, t)
 }
 
 /**
@@ -57,7 +65,7 @@ function fieldValue(value: unknown): string {
  * header already names the producer, and `form` because the presentation the
  * reader is looking at IS that value.
  */
-function SourceFields({ source }: { source: unknown }): ReactNode {
+function SourceFields({ source, t }: { source: unknown; t: Translate }): ReactNode {
   const record = asRecord(source)
   if (record === null) return null
   const rows = Object.entries(record).filter(([key]) => key !== 'kind' && key !== 'form')
@@ -67,7 +75,7 @@ function SourceFields({ source }: { source: unknown }): ReactNode {
       {rows.map(([key, value]) => (
         <div key={key} className={css.field}>
           <dt className={css.fieldKey}>{key}</dt>
-          <dd className={css.fieldValue}>{fieldValue(value)}</dd>
+          <dd className={css.fieldValue}>{fieldValue(value, t)}</dd>
         </div>
       ))}
     </dl>
@@ -98,7 +106,7 @@ export function OpaqueBody({ content, source, t }: {
           truncatedLabel={total => t('json.truncated', { total })}
         />
       ))}
-      <SourceFields source={source} />
+      <SourceFields source={source} t={t} />
     </>
   )
 }
@@ -110,26 +118,37 @@ interface InstructionChange {
   digest?: string
 }
 
-/** Instruction changes read off the source; empty when the shape is unusable. */
-function instructionChanges(source: unknown): InstructionChange[] {
+/**
+ * Instruction changes read off the source, or null when the record is not a
+ * usable instruction list.
+ *
+ * The read is all-or-nothing: silently dropping one unreadable entry would show
+ * a confident, incomplete file list for a log this version cannot fully read.
+ * Paths are deduplicated in first-seen order, matching how the header label is
+ * derived from the same array.
+ */
+function instructionChanges(source: unknown): InstructionChange[] | null {
   const record = asRecord(source)
   const list = record === null ? undefined : record['changes']
-  if (!Array.isArray(list)) return []
+  if (!Array.isArray(list)) return null
   const changes: InstructionChange[] = []
-  for (const entry of list) {
+  const seen = new Set<string>()
+  for (const entry of list as readonly unknown[]) {
     const change = asRecord(entry)
-    if (change === null) continue
+    if (change === null) return null
     const path = change['path']
-    if (typeof path !== 'string' || path === '') continue
+    if (typeof path !== 'string' || path === '') return null
     const action = change['action']
     const digest = change['digest']
+    if (seen.has(path)) continue
+    seen.add(path)
     changes.push({
       action: typeof action === 'string' ? action : '',
       path,
       ...typeof digest === 'string' ? { digest } : {},
     })
   }
-  return changes
+  return changes.length === 0 ? null : changes
 }
 
 /**
@@ -138,7 +157,8 @@ function instructionChanges(source: unknown): InstructionChange[] {
  * The text keeps its `<system-reminder>` framing verbatim — the framing is part
  * of what the model read, so hiding it would misreport the request.
  * @param props - Durable content, its source, and the locale seat.
- * @returns The instructions context body.
+ * @returns The instructions context body, or the opaque body when the change
+ * list is unreadable.
  */
 export function InstructionsBody({ content, source, t }: {
   content: ContextMessageNode['content']
@@ -146,22 +166,21 @@ export function InstructionsBody({ content, source, t }: {
   t: Translate
 }): ReactNode {
   const changes = instructionChanges(source)
+  if (changes === null) return <OpaqueBody content={content} source={source} t={t} />
   const baseline = asRecord(source)?.['baseline'] === true
   const { text, rest } = partitionContent(content)
   return (
     <>
-      {changes.length > 0 && (
-        <ul className={css.files} data-context-files>
-          {changes.map(change => (
-            <li key={change.path} className={css.file} title={change.digest}>
-              <span className={css.filePath}>{change.path}</span>
-              <span className={css.fileAction}>
-                {t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)}
-              </span>
-            </li>
-          ))}
-        </ul>
-      )}
+      <ul className={css.files} data-context-files>
+        {changes.map(change => (
+          <li key={change.path} className={css.file} title={change.digest}>
+            <span className={css.filePath}>{change.path}</span>
+            <span className={css.fileAction}>
+              {t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)}
+            </span>
+          </li>
+        ))}
+      </ul>
       {text !== '' && <pre className={css.text} data-context-text>{boundedText(text, t)}</pre>}
       {rest.map((block, index) => (
         <JsonBlock
@@ -181,21 +200,26 @@ interface CatalogEntry {
   description: string
 }
 
-/** Catalog entries read off the source; empty when the shape is unusable. */
-function catalogEntries(source: unknown): CatalogEntry[] {
+/**
+ * Catalog entries read off the source, or null when the record is not a usable
+ * catalog. All-or-nothing for the same reason as the instruction list: this body
+ * replaces the model-facing text, so a partial list would hide the only complete
+ * account of what the model read.
+ */
+function catalogEntries(source: unknown): CatalogEntry[] | null {
   const record = asRecord(source)
   const list = record === null ? undefined : record['entries']
-  if (!Array.isArray(list)) return []
+  if (!Array.isArray(list)) return null
   const entries: CatalogEntry[] = []
-  for (const item of list) {
+  for (const item of list as readonly unknown[]) {
     const entry = asRecord(item)
-    if (entry === null) continue
+    if (entry === null) return null
     const name = entry['name']
-    if (typeof name !== 'string' || name === '') continue
     const description = entry['description']
-    entries.push({ name, description: typeof description === 'string' ? description : '' })
+    if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null
+    entries.push({ name, description })
   }
-  return entries
+  return entries.length === 0 ? null : entries
 }
 
 /**
@@ -205,7 +229,8 @@ function catalogEntries(source: unknown): CatalogEntry[] {
  * A catalog whose source carries no usable entries falls through to the opaque
  * body, so an older or hand-edited log still shows its text.
  * @param props - Durable content, its source, and the locale seat.
- * @returns The catalog context body.
+ * @returns The catalog context body, or the opaque body when the entry list is
+ * unreadable.
  */
 export function CatalogBody({ content, source, t }: {
   content: ContextMessageNode['content']
@@ -213,15 +238,55 @@ export function CatalogBody({ content, source, t }: {
   t: Translate
 }): ReactNode {
   const entries = catalogEntries(source)
-  if (entries.length === 0) return <OpaqueBody content={content} source={source} t={t} />
+  if (entries === null) return <OpaqueBody content={content} source={source} t={t} />
+  const update = asRecord(source)?.['update'] === true
   return (
-    <ul className={css.entries} data-context-entries>
-      {entries.map(entry => (
-        <li key={entry.name} className={css.entry}>
-          <code className={css.entryName}>{entry.name}</code>
-          <span className={css.entryDescription}>{entry.description}</span>
-        </li>
-      ))}
-    </ul>
+    <>
+      {update && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
+      <ul className={css.entries} data-context-entries>
+        {entries.map((entry, index) => (
+          // Index key: a hand-edited or foreign log may repeat a name, and a
+          // duplicate React key would drop a row the model did see.
+          <li key={index} className={css.entry}>
+            <code className={css.entryName}>{entry.name}</code>
+            <span className={css.entryDescription}>{entry.description}</span>
+          </li>
+        ))}
+      </ul>
+    </>
   )
 }
+
+/**
+ * Choose the body for one context node.
+ *
+ * Returns the form the body actually rendered as, which is not always the
+ * declared one: a declared form whose fields are unreadable falls back to
+ * opaque, and the caller labels the row with what it really shows.
+ * @param form - the producer-declared form projected onto the node.
+ * @param props - durable content, its source, and the locale seat.
+ * @returns the rendered form (null for opaque) and its body.
+ */
+export function contextBody(
+  form: ContextMessageNode['form'],
+  props: { content: ContextMessageNode['content']; source: unknown; t: Translate },
+): { rendered: KnownContextForm | null; body: ReactNode } {
+  switch (form) {
+    case 'instructions':
+      return instructionChanges(props.source) === null
+        ? { rendered: null, body: <OpaqueBody {...props} /> }
+        : { rendered: 'instructions', body: <InstructionsBody {...props} /> }
+    case 'catalog':
+      return catalogEntries(props.source) === null
+        ? { rendered: null, body: <OpaqueBody {...props} /> }
+        : { rendered: 'catalog', body: <CatalogBody {...props} /> }
+    case null:
+      return { rendered: null, body: <OpaqueBody {...props} /> }
+    /* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new
+    KnownContextForm here rather than letting it degrade to opaque silently. */
+    default: {
+      const unreachable: never = form
+      throw new Error(`unreachable context form: ${String(unreachable)}`)
+    }
+  }
+}

+ 6 - 7
packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx

@@ -3,7 +3,7 @@ import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
 import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { DisclosureRow } from './DisclosureRow.tsx'
-import { CatalogBody, InstructionsBody, OpaqueBody } from './ContextBody.tsx'
+import { contextBody } from './ContextBody.tsx'
 import css from './ContextInjectionRow.module.css'
 
 /** Props for the logged non-user message presentation. */
@@ -31,6 +31,9 @@ export interface ContextInjectionRowProps {
  */
 export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) {
   const [open, setOpen] = useState(false)
+  // Resolved rather than declared: a form whose fields are unreadable renders
+  // the opaque body, and the marker must say what the row actually shows.
+  const { rendered, body } = contextBody(form, { content, source, t })
 
   return (
     <DisclosureRow
@@ -53,12 +56,8 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
       expandOnRowClick
       onToggle={() => { setOpen(value => !value) }}
     >
-      <div className={css.body} data-context-injection-body data-context-form={form ?? undefined}>
-        {form === 'instructions'
-          ? <InstructionsBody content={content} source={source} t={t} />
-          : form === 'catalog'
-            ? <CatalogBody content={content} source={source} t={t} />
-            : <OpaqueBody content={content} source={source} t={t} />}
+      <div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
+        {body}
       </div>
     </DisclosureRow>
   )

+ 2 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -58,6 +58,7 @@ export const zh = {
   'message.context.instructions.loaded': '已载入',
   'message.context.instructions.updated': '已更新',
   'message.context.instructions.removed': '已移除',
+  'message.context.catalog.replaced': '替换目录',
   'message.steering': '插话',
   'message.compaction': '上下文已压缩',
   'message.compaction.expand': '点击查看压缩摘要',
@@ -176,6 +177,7 @@ export const en = {
   'message.context.instructions.loaded': 'loaded',
   'message.context.instructions.updated': 'updated',
   'message.context.instructions.removed': 'removed',
+  'message.context.catalog.replaced': 'Replacement catalog',
   'message.steering': 'Interjection',
   'message.compaction': 'Context compacted',
   'message.compaction.expand': 'View compaction summary',

+ 102 - 0
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -271,6 +271,7 @@ describe('MessageItem arms', () => {
           changes: [
             { action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' },
             { action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' },
+            { action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' },
           ],
         },
         provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' },
@@ -307,6 +308,107 @@ describe('MessageItem arms', () => {
     const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent)
     expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B'])
     expect(view.container.querySelector('[data-context-text]')).toBeNull()
+    expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull()
+  })
+
+  it('a replacement catalog says so above its entries', () => {
+    const view = render(
+      <MessageItem t={t} node={{
+        kind: 'context',
+        seq: 3,
+        content: [{ type: 'text', text: 'catalog prose' }],
+        source: {
+          kind: 'skill-catalog',
+          form: 'catalog',
+          update: true,
+          entries: [{ name: 'a-skill', description: 'Does A' }],
+        },
+        provenance: { role: 'inject', label: 'skill-catalog' },
+        form: 'catalog',
+      } as never}
+      />,
+    )
+    fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
+    expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
+  })
+
+  it('a partially unreadable catalog falls back whole rather than showing a short list', () => {
+    // All-or-nothing: a body that replaces the model-facing text must not show
+    // a confident, incomplete account of what the model read.
+    const view = render(
+      <MessageItem t={t} node={{
+        kind: 'context',
+        seq: 3,
+        content: [{ type: 'text', text: 'catalog prose' }],
+        source: {
+          kind: 'skill-catalog',
+          form: 'catalog',
+          entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill' }],
+        },
+        provenance: { role: 'inject', label: 'skill-catalog' },
+        form: 'catalog',
+      } as never}
+      />,
+    )
+    fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
+    expect(view.container.querySelector('[data-context-entries]')).toBeNull()
+    expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
+    // The marker reports what rendered, not what was declared.
+    expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
+      .toBeNull()
+  })
+
+  it('an unreadable instruction list falls back to the opaque body with its fields', () => {
+    const view = render(
+      <MessageItem t={t} node={{
+        kind: 'context',
+        seq: 3,
+        content: [{ type: 'text', text: 'instruction prose' }],
+        source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'set' }] },
+        provenance: { role: 'inject', label: 'workspace-instructions' },
+        form: 'instructions',
+      } as never}
+      />,
+    )
+    fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
+    expect(view.container.querySelector('[data-context-files]')).toBeNull()
+    expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
+    expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
+  })
+
+  it('joins adjacent text blocks the way a provider adapter flattens them', () => {
+    // No invented separator: showing a line break the model never saw would
+    // misreport the request.
+    const view = render(
+      <MessageItem t={t} node={{
+        kind: 'context',
+        seq: 3,
+        content: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
+        source: null,
+        provenance: { role: 'inject', label: null },
+        form: null,
+      } as never}
+      />,
+    )
+    fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
+    expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond')
+  })
+
+  it('bounds an oversized provenance field, not only the model-facing text', () => {
+    const view = render(
+      <MessageItem t={t} node={{
+        kind: 'context',
+        seq: 3,
+        content: [{ type: 'text', text: 'short' }],
+        source: { kind: 'plugin', note: 'y'.repeat(21_000) },
+        provenance: { role: 'inject', label: 'plugin' },
+        form: null,
+      } as never}
+      />,
+    )
+    fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
+    expect(view.container.querySelector('[data-context-fields] dd')?.textContent)
+      .toMatch(/… 已截断,共 \d+ 字符$/)
   })
 
   it('a catalog whose source carries no entries falls back to the opaque body', () => {

+ 39 - 8
packages/skill/tool-skill/src/index.ts

@@ -284,9 +284,14 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag
   })
 }
 
-/** Model-facing catalog lines, projected from the same entries the source records. */
+/**
+ * Model-facing catalog lines, projected from the same entries the source records.
+ * The pseudo-XML escaping belongs to this frame, not to the published fact, so it
+ * is applied here and never stored. Names are `isSkillName`-validated and carry
+ * no escapable character.
+ */
 function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] {
-  return entries.map(entry => `- \`${entry.name}\`: ${entry.description}`)
+  return entries.map(entry => `- \`${entry.name}\`: ${escapeText(entry.description)}`)
 }
 
 /**
@@ -295,12 +300,38 @@ function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[]
  * written for the model and must not decide whether a republish is needed.
  */
 function digestCatalogEntries(entries: SkillCatalogSource['entries']): string {
-  const canonical = entries.map(entry => `${entry.name}\u0000${entry.description}`).join('\n')
+  // JSON per entry rather than a separator character: every separator is itself
+  // a legal description character, so only quoting makes the boundary exact.
+  const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n')
   return createHash('sha256')
     .update(canonical)
     .digest('hex')
 }
 
+/**
+ * Entries of one durable catalog message, or undefined when the record is not a
+ * usable catalog.
+ *
+ * `agent.session.events` may be a resumed, forked, or externally written seed,
+ * and seed validation only guarantees a source object with a non-empty `kind`;
+ * no per-kind field is checked there. An unreadable record is therefore treated
+ * as "not this plugin's catalog" — the posture the replaced content digest had —
+ * rather than throwing inside the step listener, which would fail every
+ * subsequent turn of that session.
+ */
+function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | undefined {
+  const entries = (source as { entries?: unknown }).entries
+  if (!Array.isArray(entries)) return undefined
+  const readable: { name: string; description: string }[] = []
+  for (const entry of entries as readonly unknown[]) {
+    if (typeof entry !== 'object' || entry === null) return undefined
+    const { name, description } = entry as { name?: unknown; description?: unknown }
+    if (typeof name !== 'string' || name === '' || typeof description !== 'string') return undefined
+    readable.push({ name, description })
+  }
+  return readable
+}
+
 function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
   const visible = new Set(agent.session.surface.nodes)
   const events = agent.session.events
@@ -310,19 +341,19 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool
     // oxlint-disable-next-line typescript/no-non-null-assertion
     const event = events[index]!
     if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue
-    const digest = digestCatalogEntries(event.data.source.entries)
+    const entries = readCatalogEntries(event.data.source)
+    if (entries === undefined) continue
+    const digest = digestCatalogEntries(entries)
     published = true
     if (visible.has(event.seq)) return { visibleDigest: digest, published }
   }
   return { published }
 }
 
+/** Normalized, length-bounded description exactly as the catalog publishes it (unescaped). */
 function catalogDescription(value: string, maxLength: number): string {
   const normalized = value.replaceAll(/\s+/g, ' ').trim()
-  const truncated = normalized.length <= maxLength
-    ? normalized
-    : `${normalized.slice(0, maxLength - 3)}...`
-  return escapeText(truncated)
+  return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`
 }
 
 function assertPositiveInteger(name: string, value: number, minimum = 1): void {

+ 51 - 1
packages/skill/tool-skill/tests/tool-skill.spec.ts

@@ -97,6 +97,14 @@ function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/
     && event.data.source.kind === 'skill-catalog')
 }
 
+function readableCatalog(event: Extract<SessionEvent, { type: 'user/message' }>): boolean {
+  const entries = (event.data.source as { entries?: unknown }).entries
+  return Array.isArray(entries)
+    && entries.every(entry => typeof entry === 'object' && entry !== null
+      && typeof (entry as { name?: unknown }).name === 'string'
+      && typeof (entry as { description?: unknown }).description === 'string')
+}
+
 function catalogContent(entries: string[]): Message['content'] {
   return [{
     type: 'text',
@@ -218,7 +226,7 @@ describe('dsh-tool-skill', () => {
           kind: 'skill-catalog',
           form: 'catalog',
           entries: [
-            { name: 'a-skill', description: 'Use {{placeholder}} &lt;safely&gt; &amp; carefully.' },
+            { name: 'a-skill', description: 'Use {{placeholder}} <safely> & carefully.' },
             { name: 'model-only-skill', description: 'Model-only skill.' },
             { name: 'z-skill', description: 'Long description Long description Long descript...' },
           ],
@@ -411,6 +419,48 @@ describe('dsh-tool-skill', () => {
     expect(catalogMessages(session)).toHaveLength(2)
   })
 
+  it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => {
+    // Seeds reach `agent.session.events` from JSONL/SQLite on resume or fork,
+    // and seed validation only guarantees a source object with a non-empty
+    // `kind`. A catalog whose entries are missing or wrongly shaped must be
+    // skipped like any foreign record; throwing here would fail every later
+    // step of that session at the latest possible point.
+    const home = await tempDir('tool-catalog-malformed')
+    const ctx = await setup(home)
+    ctx.skills.register({
+      name: 'live-skill',
+      description: 'Live skill',
+      source: 'runtime',
+      content: 'Live body.',
+    })
+    const session = Session.create(SessionId('catalog-malformed'))
+    const agent = sessionAgent(session)
+    openMessageTurn(session)
+    for (const source of [
+      { kind: 'skill-catalog', form: 'catalog' },
+      { kind: 'skill-catalog', form: 'catalog', entries: null },
+      { kind: 'skill-catalog', form: 'catalog', entries: 'not-an-array' },
+      { kind: 'skill-catalog', form: 'catalog', entries: [null] },
+      { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'x' }] },
+      { kind: 'skill-catalog', form: 'catalog', entries: [{ description: 'no name' }] },
+    ]) {
+      session.append('user/message', createUserMessage({
+        content: [{ type: 'text', text: 'unreadable catalog' }],
+        source: source as never,
+      }), { surfaceOp: 'append' })
+    }
+
+    await expect(fireStep(ctx, agent, 1, 1)).resolves.toBeUndefined()
+
+    // None of the six counted as published, so the live catalog lands as a
+    // first publication rather than a replacement.
+    const published = catalogMessages(session).filter(event => readableCatalog(event))
+    expect(published).toHaveLength(1)
+    expect(published[0]?.data.source).toMatchObject({ kind: 'skill-catalog', form: 'catalog' })
+    expect(published[0]?.data.source).not.toHaveProperty('update')
+    expect(JSON.stringify(published[0]?.data.content)).toContain('live-skill')
+  })
+
   it('re-establishes the current catalog after compaction hides its durable message', async () => {
     const home = await tempDir('tool-catalog-compaction')
     const ctx = await setup(home)

+ 5 - 0
scripts/type-equiv.manifest.json

@@ -26,6 +26,11 @@
       "symbol": "MessageSourceMap",
       "source": "packages/llm/llm/src/message.ts"
     },
+    {
+      "doc": "docs/core-data-structures/core.md",
+      "symbol": "ContextForm",
+      "source": "packages/llm/llm/src/message.ts"
+    },
     {
       "doc": "docs/core-data-structures/core.md",
       "symbol": "FinishReasonMap",