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 `<`. `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:
@@ -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',
|
||||
}])
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
}])
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user