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:
@@ -203,7 +203,12 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(notice.data.content.some(
|
||||
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
|
||||
)).toBe(true)
|
||||
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
|
||||
expect(notice.data.source).toEqual({
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-tasks',
|
||||
form: 'notice',
|
||||
summary: 'bash echo bg-ok [status: completed, exit code: 0]',
|
||||
})
|
||||
|
||||
// The next turn collects the output through the generic task tool.
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }))
|
||||
|
||||
@@ -97,7 +97,7 @@ export function contextProvenance(source: unknown): ContextProvenanceView {
|
||||
* dropping the row, so a log written by a newer or foreign producer still
|
||||
* renders.
|
||||
*/
|
||||
const KNOWN_FORMS = ['instructions', 'catalog'] as const
|
||||
const KNOWN_FORMS = ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'] as const
|
||||
|
||||
/** One durable context form this UI version knows how to present. */
|
||||
export type KnownContextForm = typeof KNOWN_FORMS[number]
|
||||
|
||||
Binary file not shown.
@@ -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',
|
||||
|
||||
@@ -562,6 +562,106 @@ describe('MessageItem arms', () => {
|
||||
expect(fields).toEqual(['plugin', 'form'])
|
||||
})
|
||||
|
||||
it('the snapshot form attributes each part to the subsystem that produced it', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'Current runtime context.\n\nsandbox\n\nworkspace' }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: '@deepseek-ai/dsh-system-prompt',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'sandbox:policy', text: 'workspace-write' }, { name: 'workspace', text: '/repo' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: '@deepseek-ai/dsh-system-prompt' },
|
||||
form: 'snapshot',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ }))
|
||||
const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent)
|
||||
expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo'])
|
||||
})
|
||||
|
||||
it('a notice puts its account on the collapsed row', () => {
|
||||
// The whole point of the form: readable without expanding.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'background task bash-1 finished.' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash pnpm test [status: completed]' },
|
||||
provenance: { role: 'inject', label: 'tool-tasks' },
|
||||
form: 'notice',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.querySelector('[data-context-summary]')?.textContent)
|
||||
.toBe('bash pnpm test [status: completed]')
|
||||
expect(view.container.querySelector('[data-context-injection-body]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a notice without its account falls back to the opaque body', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'notice prose' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice' },
|
||||
provenance: { role: 'inject', label: 'tool-tasks' },
|
||||
form: 'notice',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.querySelector('[data-context-summary]')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-tasks$/ }))
|
||||
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a relay names the agent that sent it above what it said', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'child report body' }],
|
||||
source: { kind: 'subagent-report', form: 'relay', senderSessionId: 'child-7' },
|
||||
provenance: { role: 'inject', label: 'subagent-report' },
|
||||
form: 'relay',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*subagent-report$/ }))
|
||||
expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7')
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body')
|
||||
})
|
||||
|
||||
it('a recall reports how much of each source session survived the read', () => {
|
||||
// Recalled context is bounded on the way in, so hiding the omitted count
|
||||
// would overstate what the model received.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'recalled material' }],
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: [
|
||||
{ label: '重构 loader', retainedMessages: 18, omittedMessages: 42, truncated: true },
|
||||
{ label: '修 CI', retainedMessages: 3, omittedMessages: 0, truncated: false },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'recall', label: '重构 loader, 修 CI' },
|
||||
form: 'recall',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ }))
|
||||
const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent)
|
||||
expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条'])
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material')
|
||||
})
|
||||
|
||||
it('unknown nodes retain the generic JSON row', () => {
|
||||
const unknownView = render(
|
||||
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||||
|
||||
@@ -199,6 +199,7 @@ export class SessionReferenceService extends Service {
|
||||
const prompt = renderPrompt(rendered.map(source => source.data))
|
||||
const source: SessionReferenceSource = {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: rendered.map((source, index) => ({
|
||||
sessionId: source.data.sessionId,
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
/** Durable provenance for one prepared cross-session context. */
|
||||
export interface SessionReferenceSource {
|
||||
kind: 'session-reference'
|
||||
/** Material lifted out of another session's log (`recall` context form). */
|
||||
form: 'recall'
|
||||
version: 1
|
||||
references: {
|
||||
sessionId: string
|
||||
|
||||
@@ -174,6 +174,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const previous = step === 1
|
||||
? precedingMessageTime(agent)
|
||||
: precedingStepContextTime(agent, turn)
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } }))
|
||||
const text = renderText(now, turn, step, previous, formatter, resolvedTimeZone)
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
|
||||
}))
|
||||
}, { prepend: true })
|
||||
}
|
||||
|
||||
@@ -154,7 +154,19 @@ describe('durable step context', () => {
|
||||
const event = session.events.at(-1)
|
||||
expect(event?.type).toBe('user/message')
|
||||
if (event?.type !== 'user/message') throw new Error('missing time context')
|
||||
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
|
||||
// The reading is a `snapshot`-form context: one named contribution whose
|
||||
// text is exactly what the model read, so a consumer attributes it without
|
||||
// re-splitting prose.
|
||||
expect(event.data.source).toEqual({
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
form: 'snapshot',
|
||||
sections: [{
|
||||
name: 'time-context',
|
||||
text: 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
|
||||
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
|
||||
}],
|
||||
})
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
|
||||
@@ -233,9 +233,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (location === undefined) return
|
||||
const state = renderState(location)
|
||||
if (previous !== undefined && previous.state === state) return
|
||||
const text = renderReading(location, turn)
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: renderReading(location, turn) }],
|
||||
source: { kind: 'plugin', plugin: name },
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
|
||||
}))
|
||||
}, { prepend: true })
|
||||
}
|
||||
|
||||
@@ -163,7 +163,14 @@ describe('tmux-context injection', () => {
|
||||
])
|
||||
const event = session.events.at(-1)
|
||||
if (event?.type !== 'user/message') throw new Error('missing tmux context')
|
||||
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' })
|
||||
// `snapshot` form: one named contribution carrying exactly the reading the
|
||||
// model saw, so a consumer attributes it without re-splitting prose.
|
||||
expect(event.data.source).toMatchObject({
|
||||
kind: 'plugin',
|
||||
plugin: 'tmux-context',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'tmux-context' }],
|
||||
})
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
|
||||
@@ -1810,12 +1810,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
declaration: 'export type ContentBlockType = keyof ContentBlockMap;',
|
||||
},
|
||||
{
|
||||
name: 'ContextForm',
|
||||
declaration: 'export type ContextForm = \'instructions\' | \'catalog\';',
|
||||
name: 'ContextFormed',
|
||||
declaration: 'export type ContextFormed = {\n readonly form?: never;\n} | {\n readonly form: \'instructions\';\n} | {\n readonly form: \'catalog\';\n} | {\n readonly form: \'snapshot\';\n readonly sections: readonly ContextSnapshotSection[];\n} | {\n readonly form: \'notice\';\n readonly summary: string;\n} | {\n readonly form: \'relay\';\n} | {\n readonly form: \'recall\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'ContextFormed',
|
||||
declaration: 'export interface ContextFormed {\n readonly form?: ContextForm;\n}',
|
||||
name: 'ContextSnapshotSection',
|
||||
declaration: 'export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ContinuableCreateRequest',
|
||||
|
||||
@@ -46,10 +46,12 @@ import {
|
||||
llmRetryPolicyOf,
|
||||
markAgentLoopRequest,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContextSnapshotSection, GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import { renderContextSections, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
|
||||
@@ -105,7 +107,11 @@ function retainedRuntimeContext(session: Session): { found: boolean; text: strin
|
||||
}
|
||||
|
||||
/** Append a full current snapshot only when it changed or compaction removed it. */
|
||||
function materializeRuntimeContext(session: Session, current: string): void {
|
||||
function materializeRuntimeContext(
|
||||
session: Session,
|
||||
current: string,
|
||||
sections: readonly ContextSnapshotSection[],
|
||||
): void {
|
||||
const previous = retainedRuntimeContext(session)
|
||||
if (!previous.found && current.length === 0) {
|
||||
const compactedPriorSnapshot = session.surface.replaceGeneration > 0
|
||||
@@ -116,7 +122,10 @@ function materializeRuntimeContext(session: Session, current: string): void {
|
||||
if (previous.text === snapshot) return
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: snapshot }],
|
||||
source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE },
|
||||
// The cleared marker has no contributions left to attribute.
|
||||
source: sections.length === 0
|
||||
? { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE }
|
||||
: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE, form: 'snapshot', sections },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
@@ -691,7 +700,7 @@ export class ReactLoopAgent implements Agent {
|
||||
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
|
||||
signal.throwIfAborted()
|
||||
const system = renderPrompt(assembly)
|
||||
materializeRuntimeContext(session, renderContextSnapshot(assembly))
|
||||
materializeRuntimeContext(session, renderContextSnapshot(assembly), renderContextSections(assembly))
|
||||
|
||||
// Commit the exact pending batch only after every asynchronous
|
||||
// pre-request contribution succeeded. Input accepted after this splice
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContextSnapshotSection, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -208,14 +208,26 @@ export function renderPrompt(assembly: PromptAssembly): string {
|
||||
* @returns the current full snapshot, or `''` when no context is active.
|
||||
*/
|
||||
export function renderContextSnapshot(assembly: PromptAssembly): string {
|
||||
const body = assembly.contexts
|
||||
.map(context => interpolate(context, assembly.variables, 'context'))
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
const body = renderContextSections(assembly).map(section => section.text).join('\n\n')
|
||||
if (body.length === 0) return ''
|
||||
return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The same snapshot, kept as the named contributions it was assembled from.
|
||||
*
|
||||
* {@link renderContextSnapshot} joins these for the model; a consumer that
|
||||
* presents the snapshot uses them to attribute each part to the subsystem that
|
||||
* contributed it, without re-splitting the joined prose.
|
||||
* @param assembly - the assembly whose contexts and variables to render.
|
||||
* @returns one entry per contributing context that rendered to non-empty text.
|
||||
*/
|
||||
export function renderContextSections(assembly: PromptAssembly): ContextSnapshotSection[] {
|
||||
return assembly.contexts
|
||||
.map(context => ({ name: context.name, text: interpolate(context, assembly.variables, 'context') }))
|
||||
.filter(section => section.text.length > 0)
|
||||
}
|
||||
|
||||
/** Interpolate one section or context and attribute diagnostics to its owning input. */
|
||||
function interpolate(
|
||||
input: AssembledSection | AssembledContext,
|
||||
|
||||
@@ -61,6 +61,13 @@ export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
|
||||
/** Message attribution for durable goal state and continuation rounds. */
|
||||
export interface GoalMessageSource {
|
||||
readonly kind: 'goal'
|
||||
/**
|
||||
* Round-zero state changes are `notice`-form contexts; a continuation round
|
||||
* carries the objective forward as ordinary context and declares no form.
|
||||
*/
|
||||
readonly form?: 'notice'
|
||||
/** Present with `form`: one-line account of the mutation. */
|
||||
readonly summary?: string
|
||||
readonly goalId: GoalId
|
||||
readonly revision: number
|
||||
/** Zero for state changes; positive for admitted continuation rounds. */
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
goalChangeRef,
|
||||
} from './fold.ts'
|
||||
import type { GoalFoldState } from './fold.ts'
|
||||
import { renderGoalChange } from './render.ts'
|
||||
import { goalChangeSummary, renderGoalChange } from './render.ts'
|
||||
import {
|
||||
GOAL_CHANGE_VERSION,
|
||||
GoalError,
|
||||
@@ -568,7 +568,15 @@ export class GoalService extends Service {
|
||||
try {
|
||||
agent.inject(createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change },
|
||||
source: {
|
||||
kind: 'goal',
|
||||
goalId: ref.id,
|
||||
revision: ref.revision,
|
||||
round: 0,
|
||||
change,
|
||||
form: 'notice',
|
||||
summary: goalChangeSummary(change),
|
||||
},
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
const index = cache.pending.indexOf(pending)
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { GoalChangeMeta } from './domain.ts'
|
||||
|
||||
/**
|
||||
* One-line account of a goal mutation for the `notice` form's collapsed row.
|
||||
* @param change - durable goal change carried by the message source.
|
||||
* @returns the operation and, for a surviving goal, its objective.
|
||||
*/
|
||||
export function goalChangeSummary(change: GoalChangeMeta): string {
|
||||
// The row header already names the producer, so the account does not repeat it.
|
||||
return change.operation === 'clear'
|
||||
? change.operation
|
||||
: `${change.operation}: ${change.goal.objective}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a complete goal snapshot or clear tombstone without hidden prose.
|
||||
* @param change - durable goal change carried by the message source.
|
||||
|
||||
@@ -315,7 +315,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
content: args.action === 'complete'
|
||||
? renderWrapupContext(goal.objective)
|
||||
: renderWrapupContext(goal.objective, args.blocked_reason as string),
|
||||
source: { kind: 'plugin', plugin: 'tool-goal' },
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-goal',
|
||||
form: 'notice',
|
||||
summary: `${args.action as string}: ${goal.objective}`,
|
||||
},
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(goalValue(goal))
|
||||
|
||||
@@ -372,7 +372,12 @@ describe('goal tool state transitions', () => {
|
||||
expect(complete.concludesTurn).toBeUndefined()
|
||||
const contexts = complete.additionalContexts ?? []
|
||||
expect(contexts).toHaveLength(1)
|
||||
expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' })
|
||||
expect(contexts[0]?.source).toEqual({
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-goal',
|
||||
form: 'notice',
|
||||
summary: 'complete: pause cleanly',
|
||||
})
|
||||
const block = contexts[0]?.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected one text wrap-up block')
|
||||
expect(block.text).toContain('<goal_complete>')
|
||||
|
||||
@@ -200,7 +200,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const text = count === thresholds[0]
|
||||
? GENTLE_REMINDER
|
||||
: detailedReminder(exec.name, count, previewArguments(canonical, argumentsPreviewChars))
|
||||
return createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE })
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { ...PLUGIN_SOURCE, form: 'notice', summary: `${exec.name} × ${count}` },
|
||||
})
|
||||
}
|
||||
|
||||
// Observe-and-enrich, never veto: count first (state advances regardless of
|
||||
|
||||
@@ -45,7 +45,14 @@ function reminders(agent: Agent): { text: string; source: unknown }[] {
|
||||
}))
|
||||
}
|
||||
|
||||
const GUARD_SOURCE = { kind: 'plugin', plugin: 'repeat-tool-guard' }
|
||||
// The reminder is a `notice`-form context; its summary names the repeated
|
||||
// call so a reader sees it without expanding the row.
|
||||
const guardSource = (tool: string, count: number) => ({
|
||||
kind: 'plugin',
|
||||
plugin: 'repeat-tool-guard',
|
||||
form: 'notice',
|
||||
summary: `${tool} × ${count}`,
|
||||
})
|
||||
|
||||
describe('threshold escalation', () => {
|
||||
it('reminds gently at the first default threshold (3) and in detail at the second (5)', async () => {
|
||||
@@ -62,11 +69,11 @@ describe('threshold escalation', () => {
|
||||
const found = reminders(agent)
|
||||
expect(found).toHaveLength(2)
|
||||
expect(found[0]!.text).toContain('repeating the exact same tool call')
|
||||
expect(found[0]!.source).toEqual(GUARD_SOURCE)
|
||||
expect(found[0]!.source).toEqual(guardSource('probe', 3))
|
||||
expect(found[1]!.text).toContain('consecutive_calls: 5')
|
||||
expect(found[1]!.text).toContain('- tool: probe')
|
||||
expect(found[1]!.text).toContain('{"q":"same"}')
|
||||
expect(found[1]!.source).toEqual(GUARD_SOURCE)
|
||||
expect(found[1]!.source).toEqual(guardSource('probe', 5))
|
||||
})
|
||||
|
||||
it('keys the gentle text to thresholds[0], not the literal 3', async () => {
|
||||
@@ -327,7 +334,7 @@ describe('fold onto the downstream decision', () => {
|
||||
expect(found[0]!.text).toBe('downstream-ctx')
|
||||
expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
expect(found[1]!.text).toContain('repeating the exact same tool call')
|
||||
expect(found[1]!.source).toEqual(GUARD_SOURCE)
|
||||
expect(found[1]!.source).toEqual(guardSource('probe', 2))
|
||||
expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } })
|
||||
// The block's feedback reached the tool result unchanged.
|
||||
const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')
|
||||
|
||||
@@ -50,12 +50,49 @@ export type ContextForm =
|
||||
| 'instructions'
|
||||
/** A catalog of items available in this session, republished as it changes. */
|
||||
| 'catalog'
|
||||
/** Current state, where a later snapshot from the same producer supersedes an earlier one. */
|
||||
| 'snapshot'
|
||||
/** A one-off account of something that just happened; it supersedes nothing. */
|
||||
| 'notice'
|
||||
/** A message another agent addressed to this one. */
|
||||
| 'relay'
|
||||
/** Material lifted out of another session's log, possibly reduced on the way in. */
|
||||
| 'recall'
|
||||
|
||||
/** Optional producer-declared {@link ContextForm}, mixed into the source shapes that carry one. */
|
||||
export interface ContextFormed {
|
||||
readonly form?: ContextForm
|
||||
/** One named contribution to a `snapshot`-form context, in assembly order. */
|
||||
export interface ContextSnapshotSection {
|
||||
/** The contributing subsystem's name. */
|
||||
readonly name: string
|
||||
/** That contribution's model-facing text, exactly as assembled. */
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer-declared {@link ContextForm} and the fields that form requires,
|
||||
* mixed into the source shapes that carry one.
|
||||
*
|
||||
* Discriminated by `form` so a producer cannot declare a shape without the
|
||||
* facts that shape is presented from: a `notice` must record its one-line
|
||||
* account, a `snapshot` its sections. Omitting `form` stays valid — an
|
||||
* undeclared context is the documented default.
|
||||
*/
|
||||
export type ContextFormed =
|
||||
| { readonly form?: never }
|
||||
| { readonly form: 'instructions' }
|
||||
| { readonly form: 'catalog' }
|
||||
| {
|
||||
readonly form: 'snapshot'
|
||||
/** The named contributions this snapshot assembled, in order. */
|
||||
readonly sections: readonly ContextSnapshotSection[]
|
||||
}
|
||||
| {
|
||||
readonly form: 'notice'
|
||||
/** One-line account of what happened, shown without expanding the row. */
|
||||
readonly summary: string
|
||||
}
|
||||
| { readonly form: 'relay' }
|
||||
| { readonly form: 'recall' }
|
||||
|
||||
/**
|
||||
* Where a message (or injected content) came from.
|
||||
* Merge-extensible sum type — plugins add their own `kind`s.
|
||||
|
||||
@@ -454,7 +454,8 @@ export class PlanModeService extends Service {
|
||||
: 'The user switched this session back to the default mode.'
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'plan-mode' },
|
||||
// The narration is already one sentence, so it is its own summary.
|
||||
source: { kind: 'plugin', plugin: 'plan-mode', form: 'notice', summary: text },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ import type SubagentActivationSetupRegistry from './activation-setup-registry.ts
|
||||
/** Attribution for a model coordinator's follow-up to one of its children. */
|
||||
export interface CoordinatorMessageSource {
|
||||
readonly kind: 'coordinator'
|
||||
/** A message another agent addressed to this one (`relay` context form). */
|
||||
readonly form: 'relay'
|
||||
/** Session id of the agent whose tool call produced the follow-up. */
|
||||
readonly senderSessionId: SessionId
|
||||
}
|
||||
@@ -54,6 +56,8 @@ export interface CoordinatorMessageSource {
|
||||
/** Durable attribution for a continuable child's explicit parent report. */
|
||||
export interface SubagentReportMessageSource {
|
||||
readonly kind: 'subagent-report'
|
||||
/** A message another agent addressed to this one (`relay` context form). */
|
||||
readonly form: 'relay'
|
||||
/** Session id of the reporting child. */
|
||||
readonly senderSessionId: SessionId
|
||||
}
|
||||
@@ -481,6 +485,7 @@ export class SubagentContinuationManager {
|
||||
],
|
||||
source: {
|
||||
kind: 'subagent-report' as const,
|
||||
form: 'relay' as const,
|
||||
senderSessionId: activation.childId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ export function apply(ctx: Context): void {
|
||||
SessionId(args.subagent_id),
|
||||
message,
|
||||
{
|
||||
source: { kind: 'coordinator', senderSessionId: parent.id },
|
||||
source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id },
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -102,6 +102,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
// Durable provenance records the calling agent without granting authority.
|
||||
expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({
|
||||
kind: 'coordinator',
|
||||
form: 'relay',
|
||||
senderSessionId: parent.id,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,6 +114,26 @@ function fitWithSuffix(
|
||||
return `${retainTail(content, maxBytes - fixedBytes)}${fixed}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound for the durable one-line account. A notice summary rides a collapsed
|
||||
* transcript row, and both the task label and its status detail are caller
|
||||
* text with no length of their own, so the summary caps itself rather than
|
||||
* committing unbounded prose to the log.
|
||||
*/
|
||||
const SUMMARY_MAX_CHARS = 120
|
||||
|
||||
/**
|
||||
* One-line account of a settled task for the `notice` form's collapsed row.
|
||||
* @param snapshot - the settled task.
|
||||
* @returns its kind, label, and status, bounded to {@link SUMMARY_MAX_CHARS}.
|
||||
*/
|
||||
function completionSummary(snapshot: TaskSnapshot): string {
|
||||
const summary = `${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`
|
||||
return summary.length <= SUMMARY_MAX_CHARS
|
||||
? summary
|
||||
: `${summary.slice(0, SUMMARY_MAX_CHARS - 1)}…`
|
||||
}
|
||||
|
||||
function fitCompletionNotice(snapshot: TaskSnapshot): string {
|
||||
const prefix = `background task ${snapshot.id}`
|
||||
const detail = ` (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}`
|
||||
@@ -230,7 +250,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
type: 'text',
|
||||
text: fitCompletionNotice(snapshot),
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks' },
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-tasks',
|
||||
form: 'notice',
|
||||
summary: completionSummary(snapshot),
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -460,7 +460,12 @@ describe('completion notices', () => {
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks' },
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-tasks',
|
||||
form: 'notice',
|
||||
summary: 'bash pnpm test [status: completed, exit code: 0]',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -484,7 +489,14 @@ describe('completion notices', () => {
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'background task subagent-1\n[notice truncated]\nDone; task_output.' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks' },
|
||||
// The label and status detail are unbounded caller text, so the durable
|
||||
// one-line account caps itself rather than committing their full length.
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-tasks',
|
||||
form: 'notice',
|
||||
summary: `subagent ${'x'.repeat(110)}…`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user