diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml index aeba2cd585..ffde4bbd2d 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md index c2369a21a4..20577b0ad0 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +++ b/.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 `` 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 `` 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 `` block out of the prose. + +Entries record the published fact **unescaped**. The pseudo-XML escaping belongs to the `` 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 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md index 3c16279a4e..154e8ae54f 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -23,10 +23,16 @@ Status: implemented **`instructions`**——从工作区文件中读出的指令。`workspace-context` 在启动基线与后续增量上都声明它;其既有的 `changes[]` 已经携带了呈现所需的路径、动作与 digest,因此没有新增字段。内容区在正文之上列出对账过的文件,并原样保留 `` 包装:那层包装本就是模型读到的一部分,隐藏它会歪曲这次请求。 -**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`。内容区直接列出这些条目,不再从散文里反解 `` 块。 +**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`,后者由内容区渲染成替换提示。内容区直接列出这些条目,不再从散文里反解 `` 块。 + +条目记录的是**未转义**的发布事实。伪 XML 转义属于 `` 这层为模型而设的框架,因此只在渲染该框架时施加、从不存储;否则消费方要正确展示含 `<` 的描述就得知道框架的编码方式,本决策刚移除的框架知识会换一种形式泄漏回来。`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 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index b45a0ce588..4d3c96d424 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/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 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6c12b8af07..d91e84a706 100644 --- a/docs/core-data-structures/core.md +++ b/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`. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index f83e8873ad..f9cf2c14fe 100644 --- a/docs/core-data-structures/core.zh.md +++ b/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`。 diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index 4e18e177c3..29afe3cb3f 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/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') : '' diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts index 527e324e26..221c9705e1 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/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', }]) }) diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index 9aa2654554..789fb4c992 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/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', }]) }) diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.module.css b/packages/client/ui-conversation/src/client/chat/ContextBody.module.css index 251bb83c0b..d204943fea 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextBody.module.css +++ b/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; diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx index 845097d763..d590d104b5 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx +++ b/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 | 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]) => (
{key}
-
{fieldValue(value)}
+
{fieldValue(value, t)}
))} @@ -98,7 +106,7 @@ export function OpaqueBody({ content, source, t }: { truncatedLabel={total => t('json.truncated', { total })} /> ))} - + ) } @@ -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() + 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 `` 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 const baseline = asRecord(source)?.['baseline'] === true const { text, rest } = partitionContent(content) return ( <> - {changes.length > 0 && ( -
    - {changes.map(change => ( -
  • - {change.path} - - {t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)} - -
  • - ))} -
- )} +
    + {changes.map(change => ( +
  • + {change.path} + + {t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)} + +
  • + ))} +
{text !== '' &&
{boundedText(text, t)}
} {rest.map((block, index) => ( + if (entries === null) return + const update = asRecord(source)?.['update'] === true return ( -
    - {entries.map(entry => ( -
  • - {entry.name} - {entry.description} -
  • - ))} -
+ <> + {update &&

{t('message.context.catalog.replaced')}

} +
    + {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. +
  • + {entry.name} + {entry.description} +
  • + ))} +
+ ) } + +/** + * 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: } + : { rendered: 'instructions', body: } + case 'catalog': + return catalogEntries(props.source) === null + ? { rendered: null, body: } + : { rendered: 'catalog', body: } + case null: + return { rendered: null, body: } + /* 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)}`) + } + } +} diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 7d08391f14..2c561c96de 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/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 ( { setOpen(value => !value) }} > -
- {form === 'instructions' - ? - : form === 'catalog' - ? - : } +
+ {body}
) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 3035856ba8..70673c145e 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/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', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 2dc328a052..c5569fa4f1 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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', () => { diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 75762a328c..0435ef92a1 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/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 { diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 56a039ebc0..b2729ddc9e 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -97,6 +97,14 @@ function catalogMessages(session: Session): Extract): 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}} <safely> & carefully.' }, + { name: 'a-skill', description: 'Use {{placeholder}} & 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) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f8e56eab8d..5fd940e962 100644 --- a/scripts/type-equiv.manifest.json +++ b/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",