mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md
|
||||
2026-08-05-context-form-vocabulary.md: 20577b0ad0eaa9eb8dc8528cedd18d15439ad299
|
||||
2026-08-05-context-form-vocabulary.zh.md: 154e8ae54fc6b27ce782533b4246d48190639128
|
||||
2026-08-05-context-form-vocabulary.md: c111acfec4fa85fe9e25adeb615371d65098f20f
|
||||
2026-08-05-context-form-vocabulary.zh.md: d4da218c4c6026eb875513c2acbfcd18ced8faa1
|
||||
|
||||
@@ -27,7 +27,7 @@ The vocabulary is semantic, never visual. A value states that the content is a f
|
||||
|
||||
Entries record the published fact **unescaped**. The pseudo-XML escaping belongs to the `<available_skills>` frame, which exists for the model, so it is applied when rendering that frame and never stored; otherwise a consumer would have to know the frame's encoding to display a description containing `<`, and the same frame knowledge this decision removes would leak back in another shape. `escapeText` is deterministic and injective, so digesting the unescaped entries preserves republish semantics exactly, and the model-facing text stays byte-identical.
|
||||
|
||||
That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits.
|
||||
That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits. One case does not self-heal: if that old-format catalog is the only one and the current view has no skills, the plugin sees no published catalog and emits no tombstone, so the model keeps a stale catalog nothing replaces. The pre-release stance ("backends reject old on-disk formats") permits it; it is recorded here rather than left to the optimistic path.
|
||||
|
||||
Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Status: implemented
|
||||
|
||||
条目记录的是**未转义**的发布事实。伪 XML 转义属于 `<available_skills>` 这层为模型而设的框架,因此只在渲染该框架时施加、从不存储;否则消费方要正确展示含 `<` 的描述就得知道框架的编码方式,本决策刚移除的框架知识会换一种形式泄漏回来。`escapeText` 确定且单射,故对未转义条目取 digest 与此前完全等价,重新发布语义不变,面向模型的文本逐字节不变。
|
||||
|
||||
这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。
|
||||
这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。有一种情形不会自愈:当那份旧格式目录是唯一的一份、且当前视图没有任何 skill 时,插件看不到已发布目录,也就不会发出 tombstone,模型手里会留着一份无人替换的过期目录。发布前阶段的姿态(「后端拒绝旧的磁盘格式」)允许这一点;此处如实记录,而不是只写乐观路径。
|
||||
|
||||
两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md
|
||||
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 9dc87cd69f5d274ba5e8d66c30805cada7f1fa1a
|
||||
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: e650b660efe0bf308a4b1a8c72e9c89a133ac769
|
||||
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: dfd79a1b1b44778275b4326cf5f51f6e69ce2a7c
|
||||
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 9b470dda8de45ca754a04900ffac0535ebfabd96
|
||||
|
||||
@@ -28,7 +28,7 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome:
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Superseded in part.** Composer steering shipped afterwards, so the first and third Decision bullets and the first consequence below no longer describe master: `InputMachine` carries the `queue`/`steer` mode union again, Web users can steer, and the steering bubble regained its user IconActions before carrying a caption again. The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and restored the `插话` caption on steering bubbles. Host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript still hold.
|
||||
- **Superseded in part.** Every clause below about the steer ENTRY, the `queue`/`steer` mode union, the interjection caption, its locale strings, and the goldens that pinned their absence no longer describes master — in the Decision that is bullets 1 and 3 through 5, and in the consequences bullets 1 and 3. Composer steering shipped afterwards, and the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required. What still holds: host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript.
|
||||
- Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls.
|
||||
- Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections.
|
||||
- Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one.
|
||||
|
||||
@@ -28,7 +28,7 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
- **部分被取代。** composer steering 在此之后落地,因此 Decision 的第一条与第三条、以及下面的第一条后果都已不再描述 master:`InputMachine` 重新携带 `queue`/`steer` mode 联合类型,Web 用户可以 steer,steering 气泡也早在重新带上标注之前就恢复了用户 IconActions。随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并恢复了 steering 气泡上的 `插话` 标注。host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍,仍然成立。
|
||||
- **部分被取代。** 下文中所有关于 steer **入口**、`queue`/`steer` mode 联合类型、插话标注、其 locale 字符串、以及钉住这些「不存在」的黄金基线的子句,均已不再描述 master——在 Decision 中是第 1 条与第 3 至 5 条,在后果中是第 1 条与第 3 条。composer steering 在此之后落地,随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策。仍然成立的是:host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍。
|
||||
- Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。
|
||||
- Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。
|
||||
- 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/core-data-structures/core.md
|
||||
core.md: d91e84a7066b004567ed9f668ded52ee486a1c99
|
||||
core.zh.md: f9cf2c14fe1021d7862d8bb14696044b7d8f7fbb
|
||||
core.md: 7761930b7f133600e89befd1bda96cfc7d2f05f5
|
||||
core.zh.md: 1a68b47229e3c961e772e7b0db33ded91b025e2e
|
||||
|
||||
@@ -193,6 +193,13 @@ type ContextForm =
|
||||
| 'catalog'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Optional producer-declared {@link ContextForm}, mixed into the source shapes that carry one. */
|
||||
interface ContextFormed {
|
||||
readonly form?: ContextForm
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming
|
||||
|
||||
Adapters emit a raw **chunk** protocol; the loop logs the chunks (replay fidelity) while feeding the same chunks through a `BlockAssembler` to rebuild blocks and messages. `StreamChunk` is a closed discriminated union over `type` — `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`.
|
||||
|
||||
@@ -199,6 +199,13 @@ type ContextForm =
|
||||
| 'catalog'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Optional producer-declared {@link ContextForm}, mixed into the source shapes that carry one. */
|
||||
interface ContextFormed {
|
||||
readonly form?: ContextForm
|
||||
}
|
||||
```
|
||||
|
||||
## 流式输出
|
||||
|
||||
适配器发出原始**分片**协议;循环记录分片(回放保真度),同时将同一批分片送入 `BlockAssembler` 以重建块和消息。`StreamChunk` 是基于 `type` 的封闭判别联合——`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`。
|
||||
|
||||
@@ -13,6 +13,9 @@ import css from './ContextBody.module.css'
|
||||
/** Model-facing text stays bounded at the disclosure, not at the producer. */
|
||||
const MAX_CHARS = 20_000
|
||||
|
||||
/** Rows a list body materializes before summarizing the remainder. */
|
||||
const MAX_ENTRIES = 200
|
||||
|
||||
type Translate = ChatViewSlotProps['t']
|
||||
|
||||
/** One durable source narrowed to the readable-record shape; null for anything else. */
|
||||
@@ -61,14 +64,22 @@ function fieldValue(value: unknown, t: Translate): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance fields as a key/value list. `kind` is omitted because the row
|
||||
* header already names the producer, and `form` because the presentation the
|
||||
* reader is looking at IS that value.
|
||||
* Provenance fields as a key/value list. `kind` is always omitted because the
|
||||
* row header already names the producer. `form` is omitted only when a
|
||||
* dedicated body rendered for it — then the presentation the reader is looking
|
||||
* at IS that value. On the opaque fallback the declaration is kept, because
|
||||
* that is the one place a form this version cannot present would otherwise
|
||||
* disappear from the UI entirely.
|
||||
*/
|
||||
function SourceFields({ source, t }: { source: unknown; t: Translate }): ReactNode {
|
||||
function SourceFields({ source, formRendered, t }: {
|
||||
source: unknown
|
||||
formRendered: boolean
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
const record = asRecord(source)
|
||||
if (record === null) return null
|
||||
const rows = Object.entries(record).filter(([key]) => key !== 'kind' && key !== 'form')
|
||||
const hidden = formRendered ? ['kind', 'form'] : ['kind']
|
||||
const rows = Object.entries(record).filter(([key]) => !hidden.includes(key))
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<dl className={css.fields} data-context-fields>
|
||||
@@ -82,6 +93,28 @@ function SourceFields({ source, t }: { source: unknown; t: Translate }): ReactNo
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content blocks this UI version does not know, kept visible rather than
|
||||
* dropped: the block union is merge-extensible, so a newer or foreign log may
|
||||
* carry a shape this build has no presentation for.
|
||||
* @param props - The unrecognized blocks and the locale seat.
|
||||
* @returns One generic JSON block per unknown entry.
|
||||
*/
|
||||
function UnknownBlocks({ blocks, t }: { blocks: readonly unknown[]; t: Translate }): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, index) => (
|
||||
<JsonBlock
|
||||
key={index}
|
||||
label={t('message.unknownBlock')}
|
||||
payload={block}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing content of one context, shared by every form that shows it:
|
||||
* the text with its real line breaks, then any block this UI version does not
|
||||
@@ -97,14 +130,7 @@ function ModelFacingContent({ content, t }: {
|
||||
return (
|
||||
<>
|
||||
{text !== '' && <pre className={css.text} data-context-text>{boundedText(text, t)}</pre>}
|
||||
{rest.map((block, index) => (
|
||||
<JsonBlock
|
||||
key={index}
|
||||
label={t('message.unknownBlock')}
|
||||
payload={block}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
))}
|
||||
<UnknownBlocks blocks={rest} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -124,14 +150,14 @@ export function OpaqueBody({ content, source, t }: {
|
||||
return (
|
||||
<>
|
||||
<ModelFacingContent content={content} t={t} />
|
||||
<SourceFields source={source} t={t} />
|
||||
<SourceFields source={source} formRendered={false} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** One reconciled instruction file, as the durable source records it. */
|
||||
interface InstructionChange {
|
||||
action: string
|
||||
action: 'set' | 'replace' | 'remove'
|
||||
path: string
|
||||
digest?: string
|
||||
}
|
||||
@@ -157,14 +183,13 @@ function instructionChanges(source: unknown): InstructionChange[] | null {
|
||||
const path = change['path']
|
||||
if (typeof path !== 'string' || path === '') return null
|
||||
const action = change['action']
|
||||
// The action decides which word the row shows, so an unrecognized one is
|
||||
// not a readable change — it would be presented as loaded or updated.
|
||||
if (action !== 'set' && action !== 'replace' && action !== 'remove') return null
|
||||
const digest = change['digest']
|
||||
if (seen.has(path)) continue
|
||||
seen.add(path)
|
||||
changes.push({
|
||||
action: typeof action === 'string' ? action : '',
|
||||
path,
|
||||
...typeof digest === 'string' ? { digest } : {},
|
||||
})
|
||||
changes.push({ action, path, ...typeof digest === 'string' ? { digest } : {} })
|
||||
}
|
||||
return changes.length === 0 ? null : changes
|
||||
}
|
||||
@@ -228,7 +253,9 @@ function catalogEntries(source: unknown): CatalogEntry[] | null {
|
||||
if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null
|
||||
entries.push({ name, description })
|
||||
}
|
||||
return entries.length === 0 ? null : entries
|
||||
// An empty list is a real catalog: a replacement with no entries retires
|
||||
// every earlier name. Only an unreadable shape falls back.
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,11 +276,15 @@ export function CatalogBody({ content, source, t }: {
|
||||
const entries = catalogEntries(source)
|
||||
if (entries === null) return <OpaqueBody content={content} source={source} t={t} />
|
||||
const update = asRecord(source)?.['update'] === true
|
||||
// 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)
|
||||
return (
|
||||
<>
|
||||
{update && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
|
||||
<ul className={css.entries} data-context-entries>
|
||||
{entries.map((entry, index) => (
|
||||
{shown.map((entry, index) => (
|
||||
// Index key: a hand-edited or foreign log may repeat a name, and a
|
||||
// duplicate React key would drop a row the model did see.
|
||||
<li key={index} className={css.entry}>
|
||||
@@ -262,6 +293,14 @@ export function CatalogBody({ content, source, t }: {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{shown.length < entries.length && (
|
||||
<p className={css.catalogNotice} data-context-entries-truncated>
|
||||
{t('message.context.catalog.more', { count: entries.length - shown.length })}
|
||||
</p>
|
||||
)}
|
||||
{/* The block union is merge-extensible: a catalog message carrying an
|
||||
unknown block still shows it rather than dropping model-visible content. */}
|
||||
<UnknownBlocks blocks={rest} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export const zh = {
|
||||
'message.context.instructions.updated': '已更新',
|
||||
'message.context.instructions.removed': '已移除',
|
||||
'message.context.catalog.replaced': '替换目录',
|
||||
'message.context.catalog.more': '…还有 {count} 条',
|
||||
'message.steering': '插话',
|
||||
'message.compaction': '上下文已压缩',
|
||||
'message.compaction.expand': '点击查看压缩摘要',
|
||||
@@ -178,6 +179,7 @@ export const en = {
|
||||
'message.context.instructions.updated': 'updated',
|
||||
'message.context.instructions.removed': 'removed',
|
||||
'message.context.catalog.replaced': 'Replacement catalog',
|
||||
'message.context.catalog.more': '… {count} more',
|
||||
'message.steering': 'Interjection',
|
||||
'message.compaction': 'Context compacted',
|
||||
'message.compaction.expand': 'View compaction summary',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
|
||||
README.md: deccda0ece1ffe2cbdb576a8af3801f28695d8d5
|
||||
README.zh.md: eaf442d9e123a2d56bf09abd9faab47a001e01b9
|
||||
README.md: c8c8b0d18665893104bc8ac65c1a90cae7816724
|
||||
README.zh.md: 696e18955bf6f622251a0ca875f53e66419ec944
|
||||
|
||||
@@ -10,7 +10,7 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools
|
||||
|
||||
At every `agent/step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it injects an initial durable user-role `<system-reminder>` before the request. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog.
|
||||
|
||||
Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `<system-reminder>` framing — written for the model — cannot decide whether a republish is needed, and a consumer presenting the list never re-parses the `<available_skills>` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary.
|
||||
Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `<system-reminder>` framing — written for the model — cannot decide whether a republish is needed, and a consumer presenting the list never re-parses the `<available_skills>` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message it can read; an unreadable record is skipped like any foreign one. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary.
|
||||
|
||||
The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 `<system-reminder>`。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。
|
||||
|
||||
每条目录消息都携带 `skill-catalog` 来源:一份 `catalog` 形态的上下文,其 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖的是这些持久条目而非渲染出的散文,因此为模型书写的 `<system-reminder>` 包装无法左右是否需要重新发布,消费方展示该列表时也不必再解析 `<available_skills>` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见的 `skill-catalog` 消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
|
||||
每条目录消息都携带 `skill-catalog` 来源:一份 `catalog` 形态的上下文,其 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖的是这些持久条目而非渲染出的散文,因此为模型书写的 `<system-reminder>` 包装无法左右是否需要重新发布,消费方展示该列表时也不必再解析 `<available_skills>` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线;不可读的记录与外来记录一样被跳过。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
|
||||
|
||||
如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
"symbol": "ContextForm",
|
||||
"source": "packages/llm/llm/src/message.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "ContextFormed",
|
||||
"source": "packages/llm/llm/src/message.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "FinishReasonMap",
|
||||
|
||||
Reference in New Issue
Block a user