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

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