fix(web): address the review round on the remaining context forms

- `relay` resolves its sender in `contextBody` like every other form. It was
  the one shape whose marker could claim a form the body did not render: an
  unreadable sender fell back inside the body while the row still said relay,
  contradicting the contract this PR's own note states.
- `recall` requires the retained, omitted, and truncated fields. Completeness
  is what the card exists to report, so a reference that cannot state it is
  not a readable recall — showing the label alone presents a confident card
  over unknown loss.
- The snapshot body states the supersession its producer framing line carries.
  That line is the one part of the model-facing text no section contains, and
  unlike an instruction context's `<system-reminder>` it states the form's own
  semantics rather than wrapping content.
- `GoalMessageSource` is a discriminated pair, so `{ form: 'notice' }` without
  its account no longer compiles. The guarantee this PR claims now holds at
  that seam too, not only through `ContextFormed` on plugin sources.
- Goal and tool-goal summaries are bounded by a shared `boundContextSummary`,
  which tool-tasks now uses as well. A goal objective is unbounded caller text
  in exactly the way a task label is.
- The runtime snapshot interpolates once per request: agent-loop renders the
  sections and joins them through `joinContextSections`.
- Every form's fallback branch is pinned, not only the notice one.
This commit is contained in:
creatixchu
2026-08-05 17:39:50 +08:00
parent bb59526598
commit e7f2005ec7
16 changed files with 160 additions and 84 deletions

View File

@@ -617,6 +617,45 @@ describe('MessageItem arms', () => {
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
})
it('each form falls back to the opaque body when its required facts are unreadable', () => {
// The fallback chain is the load-bearing wall: every dedicated form must
// reach it, and the row marker must not claim a form that did not render.
const cases = [
{ form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' },
{ form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' },
{ form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' },
] as const
for (const { form, source, label } of cases) {
cleanup()
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }],
source, provenance: { role: 'inject', label }, form,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) }))
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`)
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBeNull()
}
})
it('a snapshot states the supersession its framing line carries', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }],
source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] },
provenance: { role: 'inject', label: 'plugin' },
form: 'snapshot',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent)
.toBe('取代先前的快照')
})
it('a relay names the agent that sent it above what it said', () => {
const view = render(
<MessageItem t={t} node={{