fix(web): address the second review round on context provenance
- An empty replacement catalog is a real catalog: `renderCatalogUpdate()` publishes zero entries when the last skill disappears, and falling back would hide that every earlier name was retired. - The opaque fallback keeps a `form` declaration this version cannot present. It is the one place a newer or foreign log's declared shape would otherwise vanish from the UI entirely, since the row marker is also absent there. - An instruction change with an unrecognized `action` disqualifies the record. The action decides the word the row shows, so an unknown one would be presented as loaded or updated. - The catalog list bounds itself and reports the withheld count. Entry count is unbounded and the scrollport bounds height, not node count. - A catalog message keeps content blocks this version does not know, instead of dropping model-visible content the extensible union may carry. - `core.md` defines `ContextFormed`, the interface actually carrying the optional field, beside `ContextForm`. - The superseded-in-part bullet states the affected clauses as one rule rather than enumerating them; two rounds of enumeration each missed some, which is the shape being fragile rather than the list being wrong. - The note records the one migration case that does not self-heal: an old-format catalog as the only one, with an empty current view, leaves a stale catalog nothing replaces.
This commit is contained in:
@@ -411,13 +411,34 @@ describe('MessageItem arms', () => {
|
||||
.toMatch(/… 已截断,共 \d+ 字符$/)
|
||||
})
|
||||
|
||||
it('a catalog whose source carries no entries falls back to the opaque body', () => {
|
||||
it('an empty replacement catalog stays a catalog: it retires every earlier name', () => {
|
||||
// `renderCatalogUpdate` legitimately publishes zero entries when the last
|
||||
// skill disappears; falling back would hide that the catalog was cleared.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries: [] },
|
||||
source: { kind: 'skill-catalog', form: 'catalog', update: true, entries: [] },
|
||||
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('替换目录')
|
||||
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0)
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBe('catalog')
|
||||
})
|
||||
|
||||
it('a catalog whose entries are unreadable falls back to the opaque body', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries: 'not-a-list' },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
@@ -428,51 +449,71 @@ describe('MessageItem arms', () => {
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
|
||||
})
|
||||
|
||||
it('a recalled session titles its row by role and names the sessions it read', () => {
|
||||
it('bounds a large catalog and says how many rows it withheld', () => {
|
||||
const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' }))
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200)
|
||||
expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条')
|
||||
})
|
||||
|
||||
it('a catalog keeps a content block this version does not know', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'snapshot' }],
|
||||
source: { kind: 'session-reference', version: 1, references: [{ label: '重构 loader' }] },
|
||||
provenance: { role: 'recall', label: '重构 loader' },
|
||||
form: null,
|
||||
content: [{ type: 'text', text: 'prose' }, { type: 'future-block', payload: 1 }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'a', description: 'b' }] },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByRole('button', { name: /^跨会话召回\s*重构 loader$/ })).toBeTruthy()
|
||||
expect(view.queryByText('上下文注入')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the producer name visible while the context body is expanded', () => {
|
||||
it('an instruction change with an unrecognized action falls back whole', () => {
|
||||
// The action decides the word the row shows, so an unknown one cannot be
|
||||
// presented as loaded or updated.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'instructions' }],
|
||||
source: { kind: 'workspace-instructions', changes: [{ path: 'AGENTS.md' }] },
|
||||
provenance: { role: 'inject', label: 'AGENTS.md' },
|
||||
form: null,
|
||||
content: [{ type: 'text', text: 'instruction prose' }],
|
||||
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'merge', path: 'A.md' }] },
|
||||
provenance: { role: 'inject', label: 'workspace-instructions' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
const disclosure = view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md$/ })
|
||||
fireEvent.click(disclosure)
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.container.querySelector('[data-context-source]')?.textContent).toBe('AGENTS.md')
|
||||
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')
|
||||
})
|
||||
|
||||
it('a context source that names no producer shows the role alone', () => {
|
||||
it('the opaque fallback keeps a form declaration this version cannot present', () => {
|
||||
// Otherwise a newer or foreign log's declared shape vanishes from the UI.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }], source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }],
|
||||
source: { kind: 'plugin', plugin: 'later', form: 'a-later-form' },
|
||||
provenance: { role: 'inject', label: 'later' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByRole('button', { name: '上下文注入' })).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-context-source]')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ }))
|
||||
const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
|
||||
expect(fields).toEqual(['plugin', 'form'])
|
||||
})
|
||||
|
||||
it('unknown nodes retain the generic JSON row', () => {
|
||||
|
||||
Reference in New Issue
Block a user