feat(web): declare the remaining context forms on every shipped producer

Four values complete the vocabulary, so the opaque body is reached only by
producers that genuinely promise no shape.

`snapshot` — current state a later snapshot supersedes. system-prompt now
exposes `renderContextSections()`, the named contributions
`renderContextSnapshot()` already joins for the model, so the body attributes
each part to the subsystem that produced it instead of re-splitting joined
prose. The runtime snapshot, time-context, and tmux-context declare it.

`notice` — a one-off account of what just happened, declared by tool-tasks,
goal state changes, tool-goal wrap-up, plan-mode switches, and
repeat-tool-guard. Its `summary` rides the COLLAPSED row: these five are the
majority of shipped producers and none of them needs expanding to be read.
The task summary bounds itself because its inputs are unbounded caller text.

`relay` — a message another agent addressed to this one; both subagent
sources declare it and the body names the sender above what it said.

`recall` — material lifted from another session's log. session-reference
needed no new field: its references already record retained and omitted
counts and the truncation flag, which the body shows first, because recalled
context is bounded on the way in.

`ContextFormed` is now discriminated by `form`, so a producer cannot declare
a shape without the facts that shape is presented from — a notice without its
summary, or a snapshot without its sections, fails to compile.

Only the two hook bridges stay opaque, by design: their content is whatever
an external program printed, so no shape can be promised for it. Unknown
kinds and unreadable records land there too.
This commit is contained in:
creatixchu
2026-08-05 16:07:04 +08:00
parent 5f34f782fc
commit b7034e4a26
132 changed files with 771 additions and 159 deletions
@@ -100,3 +100,62 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* snapshot: one titled block per contributing subsystem. */
.sections {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
}
.section {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sectionName {
color: var(--dsw-alias-label-caption);
}
.sectionText {
margin: 0;
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* relay: who sent this, above what they said. */
.relaySender {
margin: 0 0 6px;
color: var(--dsw-alias-label-caption);
overflow-wrap: anywhere;
}
/* recall: one row per source session, with how much of it survived. */
.recalls {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.recall {
display: flex;
gap: 8px;
min-width: 0;
}
.recallLabel {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.recallCounts {
flex: none;
color: var(--dsw-alias-label-caption);
}
@@ -345,31 +345,223 @@ export function CatalogBody({ content, source, t }: {
)
}
/** One named contribution to a runtime snapshot, as the durable source records it. */
interface SnapshotSection {
name: string
text: string
}
/** Snapshot sections read off the source, or null when the record is unusable. */
function snapshotSections(source: unknown): SnapshotSection[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['sections']
if (!Array.isArray(list)) return null
const sections: SnapshotSection[] = []
for (const item of list as readonly unknown[]) {
const section = asRecord(item)
if (section === null) return null
const name = section['name']
const text = section['text']
if (typeof name !== 'string' || name === '' || typeof text !== 'string') return null
sections.push({ name, text })
}
return sections.length === 0 ? null : sections
}
/**
* `snapshot` form: the named contributions this snapshot assembled, in order.
*
* The sections are the same bytes the model read, split at the boundaries the
* producer assembled them on, so a reader sees which subsystem contributed
* which state instead of one undifferentiated wall.
* @param props - Durable content, its source, and the locale seat.
* @returns The snapshot context body, or the opaque body when unreadable.
*/
export function SnapshotBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sections = snapshotSections(source)
if (sections === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<dl className={css.sections} data-context-sections>
{sections.map((section, index) => (
<div key={index} className={css.section}>
<dt className={css.sectionName}>{section.name}</dt>
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
</div>
))}
</dl>
)
}
/**
* `notice` form: what just happened, with the model-facing text beneath it.
*
* The one-line account also rides the collapsed row ({@link contextBody}), so a
* notice is usually readable without expanding at all.
* @param props - Durable content, its source, and the locale seat.
* @returns The notice context body.
*/
export function NoticeBody({ content, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
return <ModelFacingContent content={content} t={t} />
}
/**
* `relay` form: which agent sent this, then what it said.
*
* The sender is an opaque session id; it is shown as provenance rather than a
* label, because this client cannot resolve it to a title.
* @param props - Durable content, its source, and the locale seat.
* @returns The relay context body.
*/
export function RelayBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sender = asRecord(source)?.['senderSessionId']
if (typeof sender !== 'string' || sender === '') {
return <OpaqueBody content={content} source={source} t={t} />
}
return (
<>
<p className={css.relaySender} data-context-relay-sender>
{t('message.context.relay.from', { session: sender })}
</p>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** One recalled session, as the durable source records it. */
interface RecalledSession {
label: string
retained: number | null
omitted: number | null
truncated: boolean
}
/** Recalled sessions read off the source, or null when the record is unusable. */
function recalledSessions(source: unknown): RecalledSession[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['references']
if (!Array.isArray(list)) return null
const sessions: RecalledSession[] = []
for (const item of list as readonly unknown[]) {
const reference = asRecord(item)
if (reference === null) return null
const label = reference['label']
if (typeof label !== 'string' || label === '') return null
const retained = reference['retainedMessages']
const omitted = reference['omittedMessages']
sessions.push({
label,
retained: typeof retained === 'number' ? retained : null,
omitted: typeof omitted === 'number' ? omitted : null,
truncated: reference['truncated'] === true,
})
}
return sessions.length === 0 ? null : sessions
}
/**
* `recall` form: which sessions this material came from and how much of each
* survived the read, then the material itself.
*
* Completeness is the fact a reader needs first: recalled context is bounded on
* the way in, so a card that hid the omitted count would overstate what the
* model received.
* @param props - Durable content, its source, and the locale seat.
* @returns The recall context body, or the opaque body when unreadable.
*/
export function RecallBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sessions = recalledSessions(source)
if (sessions === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<ul className={css.recalls} data-context-recalls>
{sessions.map((session, index) => (
<li key={index} className={css.recall}>
<span className={css.recallLabel}>{session.label}</span>
{session.retained !== null && session.omitted !== null && (
<span className={css.recallCounts}>
{t('message.context.recall.counts', {
retained: session.retained,
omitted: session.omitted,
})}
</span>
)}
{session.truncated && (
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
)}
</li>
))}
</ul>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** The one-line account a `notice` puts on its collapsed row, when it records one. */
function noticeSummary(source: unknown): string | null {
const summary = asRecord(source)?.['summary']
return typeof summary === 'string' && summary !== '' ? summary : null
}
/**
* 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.
* `summary` is the collapsed row's one-line account, which only a `notice`
* records: its whole point is being readable without expanding.
* @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.
* @returns the rendered form (null for opaque), its collapsed summary, and its body.
*/
export function contextBody(
form: ContextMessageNode['form'],
props: { content: ContextMessageNode['content']; source: unknown; t: Translate },
): { rendered: KnownContextForm | null; body: ReactNode } {
): { rendered: KnownContextForm | null; summary: string | null; body: ReactNode } {
const opaque = { rendered: null, summary: null, body: <OpaqueBody {...props} /> }
switch (form) {
case 'instructions':
return instructionChanges(props.source) === null
? { rendered: null, body: <OpaqueBody {...props} /> }
: { rendered: 'instructions', body: <InstructionsBody {...props} /> }
? opaque
: { rendered: 'instructions', summary: null, body: <InstructionsBody {...props} /> }
case 'catalog':
return catalogEntries(props.source) === null
? { rendered: null, body: <OpaqueBody {...props} /> }
: { rendered: 'catalog', body: <CatalogBody {...props} /> }
? opaque
: { rendered: 'catalog', summary: null, body: <CatalogBody {...props} /> }
case 'snapshot':
return snapshotSections(props.source) === null
? opaque
: { rendered: 'snapshot', summary: null, body: <SnapshotBody {...props} /> }
case 'notice': {
const summary = noticeSummary(props.source)
return summary === null
? opaque
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
}
case 'relay':
return { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
case 'recall':
return recalledSessions(props.source) === null
? opaque
: { rendered: 'recall', summary: null, body: <RecallBody {...props} /> }
case null:
return { rendered: null, body: <OpaqueBody {...props} /> }
return opaque
/* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new
KnownContextForm here rather than letting it degrade to opaque silently. */
default: {
@@ -24,6 +24,18 @@
}
.source {
flex: none;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A notice's one-line account: the reason it rarely needs expanding. */
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
@@ -33,7 +33,7 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
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 })
const { rendered, summary, body } = contextBody(form, { content, source, t })
return (
<DisclosureRow
@@ -48,6 +48,12 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
<>
<span className={css.sep} aria-hidden />
<span className={css.source} data-context-source>{provenance.label}</span>
{summary !== null && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary} data-context-summary>{summary}</span>
</>
)}
</>
)}
keepContentWhenOpen
@@ -61,6 +61,9 @@ export const zh = {
'message.context.instructions.removed': '已移除',
'message.context.catalog.replaced': '替换目录',
'message.context.catalog.more': '…还有 {count} 条',
'message.context.relay.from': '来自会话 {session}',
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
'message.context.recall.truncated': '已截断',
'message.steering': '插话',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
@@ -182,6 +185,9 @@ export const en = {
'message.context.instructions.removed': 'removed',
'message.context.catalog.replaced': 'Replacement catalog',
'message.context.catalog.more': '… {count} more',
'message.context.relay.from': 'From session {session}',
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
'message.context.recall.truncated': 'truncated',
'message.steering': 'Interjection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',