fix(web): address the late review batch on context bodies
- Content blocks render in the order the model received them. Partitioning hoisted every unknown block past the text around it, so an interleaved log read back in an order the model never saw. - A delta distinguishes a newly reconciled file from a rewritten one; `set` and `replace` already separate them at the producer, and collapsing both to "updated" misread a new file. - The superseded note states current fact in its consequences and testing rather than keeping claims the implementation now contradicts, per implemented/AGENTS.md; the decision itself stays as the record of that change, with the supersession pointer above it.
This commit is contained in:
@@ -25,23 +25,36 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
: null
|
||||
}
|
||||
|
||||
/** One run of the model-facing content: adjacent text, or one unknown block. */
|
||||
type ContentRun = { text: string } | { block: unknown }
|
||||
|
||||
/**
|
||||
* Concatenated text of the content blocks, with the non-text blocks kept aside.
|
||||
* 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.
|
||||
* The content blocks as runs, IN THE ORDER the model received them.
|
||||
*
|
||||
* 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.
|
||||
* Adjacent text blocks join with no separator, matching how provider adapters
|
||||
* flatten them — inserting a line break would show the reader a line the model
|
||||
* never saw. An unknown block breaks the run and keeps its own fallback rather
|
||||
* than being hoisted past the text around it or vanishing; the block union is
|
||||
* merge-extensible, so a foreign log may interleave shapes this build does not
|
||||
* know.
|
||||
*/
|
||||
function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } {
|
||||
const texts: string[] = []
|
||||
const rest: unknown[] = []
|
||||
function contentRuns(content: ContextMessageNode['content']): ContentRun[] {
|
||||
const runs: ContentRun[] = []
|
||||
for (const block of content) {
|
||||
if (block.type === 'text') texts.push(block.text)
|
||||
else rest.push(block)
|
||||
if (block.type !== 'text') {
|
||||
runs.push({ block })
|
||||
continue
|
||||
}
|
||||
const last = runs[runs.length - 1]
|
||||
if (last !== undefined && 'text' in last) last.text += block.text
|
||||
else runs.push({ text: block.text })
|
||||
}
|
||||
return { text: texts.join(''), rest }
|
||||
return runs
|
||||
}
|
||||
|
||||
/** Only the blocks this UI version does not know, for bodies that replace the text. */
|
||||
function unknownBlocks(content: ContextMessageNode['content']): unknown[] {
|
||||
return contentRuns(content).flatMap(run => 'block' in run ? [run.block] : [])
|
||||
}
|
||||
|
||||
/** The model-facing text, truncated to the display bound. */
|
||||
@@ -126,11 +139,20 @@ function ModelFacingContent({ content, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
const { text, rest } = partitionContent(content)
|
||||
return (
|
||||
<>
|
||||
{text !== '' && <pre className={css.text} data-context-text>{boundedText(text, t)}</pre>}
|
||||
<UnknownBlocks blocks={rest} t={t} />
|
||||
{contentRuns(content).map((run, index) => ('text' in run
|
||||
? run.text !== '' && (
|
||||
<pre key={index} className={css.text} data-context-text>{boundedText(run.text, t)}</pre>
|
||||
)
|
||||
: (
|
||||
<JsonBlock
|
||||
key={index}
|
||||
label={t('message.unknownBlock')}
|
||||
payload={run.block}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
)))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -194,6 +216,24 @@ function instructionChanges(source: unknown): InstructionChange[] | null {
|
||||
return changes.length === 0 ? null : changes
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale key for one reconciled file. The baseline loads a file; a later delta
|
||||
* distinguishes a newly reconciled path from a rewritten one, which `set` and
|
||||
* `replace` already separate at the producer.
|
||||
* @param action - the durable change action.
|
||||
* @param baseline - whether this context is the startup/resume baseline.
|
||||
* @returns the key naming what happened to that file.
|
||||
*/
|
||||
function instructionAction(
|
||||
action: InstructionChange['action'],
|
||||
baseline: boolean,
|
||||
): 'message.context.instructions.removed' | 'message.context.instructions.loaded'
|
||||
| 'message.context.instructions.added' | 'message.context.instructions.updated' {
|
||||
if (action === 'remove') return 'message.context.instructions.removed'
|
||||
if (baseline) return 'message.context.instructions.loaded'
|
||||
return action === 'set' ? 'message.context.instructions.added' : 'message.context.instructions.updated'
|
||||
}
|
||||
|
||||
/**
|
||||
* `instructions` form: the files this context reconciled, then their text.
|
||||
*
|
||||
@@ -218,7 +258,7 @@ export function InstructionsBody({ content, source, t }: {
|
||||
<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'}`)}
|
||||
{t(instructionAction(change.action, baseline))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -279,7 +319,7 @@ export function CatalogBody({ content, source, t }: {
|
||||
// Entry count is unbounded (a provider may publish any number of skills), and
|
||||
// the scrollport bounds height, not node count — so the list bounds itself.
|
||||
const shown = entries.slice(0, MAX_ENTRIES)
|
||||
const { rest } = partitionContent(content)
|
||||
const rest = unknownBlocks(content)
|
||||
return (
|
||||
<>
|
||||
{update && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
|
||||
|
||||
@@ -56,6 +56,7 @@ export const zh = {
|
||||
'message.contextInjection': '上下文注入',
|
||||
'message.contextRecall': '跨会话召回',
|
||||
'message.context.instructions.loaded': '已载入',
|
||||
'message.context.instructions.added': '已新增',
|
||||
'message.context.instructions.updated': '已更新',
|
||||
'message.context.instructions.removed': '已移除',
|
||||
'message.context.catalog.replaced': '替换目录',
|
||||
@@ -176,6 +177,7 @@ export const en = {
|
||||
'message.contextInjection': 'Context injection',
|
||||
'message.contextRecall': 'Session recall',
|
||||
'message.context.instructions.loaded': 'loaded',
|
||||
'message.context.instructions.added': 'added',
|
||||
'message.context.instructions.updated': 'updated',
|
||||
'message.context.instructions.removed': 'removed',
|
||||
'message.context.catalog.replaced': 'Replacement catalog',
|
||||
|
||||
@@ -288,6 +288,52 @@ describe('MessageItem arms', () => {
|
||||
.toContain('<system-reminder>')
|
||||
})
|
||||
|
||||
it('a delta distinguishes a newly reconciled file from a rewritten one', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'delta' }],
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
changes: [
|
||||
{ action: 'set', scope: 'a', path: 'new/AGENTS.md' },
|
||||
{ action: 'replace', scope: 'b', path: 'old/AGENTS.md' },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'new/AGENTS.md, old/AGENTS.md' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ }))
|
||||
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
|
||||
expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新'])
|
||||
})
|
||||
|
||||
it('keeps an interleaved unknown block in the order the model received it', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'future-block', payload: 1 },
|
||||
{ type: 'text', text: 'after' },
|
||||
],
|
||||
source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent)
|
||||
expect(texts).toEqual(['before', 'after'])
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the catalog form lists its durable entries instead of the model-facing prose', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
|
||||
Reference in New Issue
Block a user