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.
This commit is contained in:
creatixchu
2026-08-05 14:55:21 +08:00
parent e6293785bc
commit ebe6ca20ad
17 changed files with 408 additions and 79 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 时它来自 JSONLSQLite 种子,种子验证只保证来源是带非空 `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

View File

@@ -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

View File

@@ -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`.

View File

@@ -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`。

View File

@@ -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')
: ''

View File

@@ -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',
}])
})

View File

@@ -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',
}])
})

View File

@@ -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;

View File

@@ -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)}`)
}
}
}

View File

@@ -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>
)

View File

@@ -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',

View File

@@ -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', () => {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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",