Merge remote-tracking branch 'github/master' into feat/agent-event-payload
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 packages/skill/tool-skill/README.md
|
||||
README.md: 05e7f7f4d08c52c1bfa0b7f67a618231b29f3e43
|
||||
README.zh.md: 23ce58c867d33d433fa69212562a5ccd13ba9d0e
|
||||
README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626
|
||||
README.zh.md: c6b815bef59eb1f14be0892078694f129366d004
|
||||
|
||||
@@ -10,11 +10,11 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools
|
||||
|
||||
At every eligible `agent/pre-step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the pre-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 adds an initial durable user-role `<system-reminder>` to a downstream `enter` decision. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog.
|
||||
|
||||
The digest covers the exact rendered text between the `<available_skills>` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, an entering pre-step receives 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 at the next pre-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 cannot decide whether a republish is needed and consumers never re-parse 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; unreadable and foreign records are skipped. When the digest changes, the downstream `enter` decision receives 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 at the next pre-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.
|
||||
|
||||
`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle.
|
||||
`catalogDescriptionMaxLength` controls normalized catalog descriptions; rendering XML-escapes them. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle.
|
||||
|
||||
## Tool: `skill`
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
|
||||
每次符合条件的 `agent/pre-step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将 pre-step 中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会向下游 `enter` 决策添加初始的持久用户角色 `<system-reminder>`。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。
|
||||
|
||||
该 digest 覆盖 `<available_skills>` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,进入步骤的 pre-step 会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
|
||||
每条目录消息都携带 `skill-catalog` 来源,也就是 `catalog` 形态的上下文。它的 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖这些持久条目,而不是渲染后的正文,因此 `<system-reminder>` 包装不会影响是否需要重新发布,消费方也不需要重新解析 `<available_skills>` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线;不可读和外来的记录都会跳过。digest 变化时,下游 `enter` 决策会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
|
||||
|
||||
如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
|
||||
|
||||
`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。
|
||||
`catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。
|
||||
|
||||
## 工具:`skill`
|
||||
|
||||
|
||||
@@ -22,9 +22,37 @@ export const name = 'tool-skill'
|
||||
export const inject = ['agents', 'tools', 'skills']
|
||||
|
||||
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
|
||||
const CATALOG_ENTRIES_START = '<available_skills>\n'
|
||||
const CATALOG_ENTRIES_END = '</available_skills>'
|
||||
const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const
|
||||
/**
|
||||
* Durable provenance for one published session skill catalog. The catalog is a
|
||||
* `catalog`-form context, so it records the entries it published beside the
|
||||
* model-facing prose: a consumer presenting the list must not re-parse the
|
||||
* `<available_skills>` block, whose framing exists for the model.
|
||||
*/
|
||||
export interface SkillCatalogSource {
|
||||
readonly kind: 'skill-catalog'
|
||||
readonly form: 'catalog'
|
||||
/** Marks a replacement catalog rather than this session's first publication. */
|
||||
readonly update?: true
|
||||
/** Exactly the entries this message published, in catalog order. */
|
||||
readonly entries: readonly { readonly name: string; readonly description: string }[]
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
'skill-catalog': SkillCatalogSource
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */
|
||||
function catalogSourceEntries(
|
||||
skills: SkillSummary[],
|
||||
descriptionMaxLength: number,
|
||||
): SkillCatalogSource['entries'] {
|
||||
return skills.map(skill => ({
|
||||
name: skill.name,
|
||||
description: catalogDescription(skill.description, descriptionMaxLength),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Model-facing skill catalog configuration. */
|
||||
export interface Config {
|
||||
@@ -148,28 +176,29 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
signal.throwIfAborted()
|
||||
if (!snapshot.complete) return decision
|
||||
const skills = snapshot.skills.filter(isModelInvocable)
|
||||
const digest = catalogDigest(skills, catalogDescriptionMaxLength)
|
||||
const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)
|
||||
const digest = digestCatalogEntries(entries)
|
||||
const history = catalogHistory(agent)
|
||||
const existing = catalogMessage(decision.messages)
|
||||
if (history.visibleDigest === digest) {
|
||||
return existing === undefined
|
||||
? decision
|
||||
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.id) }
|
||||
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) }
|
||||
}
|
||||
if (existing !== undefined && catalogContentDigest(existing.content) === digest) return decision
|
||||
if (existing !== undefined && digestCatalogEntries(existing.entries) === digest) return decision
|
||||
if (!history.published && skills.length === 0) {
|
||||
return existing === undefined
|
||||
? decision
|
||||
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.id) }
|
||||
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) }
|
||||
}
|
||||
const catalog = history.published
|
||||
? renderCatalogUpdate(skills, catalogDescriptionMaxLength)
|
||||
: renderCatalogMessage(skills, catalogDescriptionMaxLength)
|
||||
? renderCatalogUpdate(entries)
|
||||
: renderCatalogMessage(entries)
|
||||
return {
|
||||
kind: 'enter',
|
||||
messages: existing === undefined
|
||||
? [...decision.messages, catalog]
|
||||
: decision.messages.map(message => message.id === existing.id ? catalog : message),
|
||||
: decision.messages.map(message => message.id === existing.message.id ? catalog : message),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -220,8 +249,7 @@ function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceB
|
||||
}
|
||||
}
|
||||
|
||||
function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
|
||||
const entries = renderCatalogEntries(skills, descriptionMaxLength)
|
||||
function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage {
|
||||
return createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
@@ -230,20 +258,23 @@ function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: numb
|
||||
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
...entries,
|
||||
...renderCatalogEntries(entries),
|
||||
'</available_skills>',
|
||||
'',
|
||||
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
source: PLUGIN_SOURCE,
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
|
||||
const entries = renderCatalogEntries(skills, descriptionMaxLength)
|
||||
const availability = skills.length === 0
|
||||
function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessage {
|
||||
const availability = entries.length === 0
|
||||
? [
|
||||
'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.',
|
||||
]
|
||||
@@ -258,31 +289,70 @@ function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: numbe
|
||||
'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
...entries,
|
||||
...renderCatalogEntries(entries),
|
||||
'</available_skills>',
|
||||
'',
|
||||
...availability,
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
source: PLUGIN_SOURCE,
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
update: true,
|
||||
entries,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] {
|
||||
return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
|
||||
/**
|
||||
* Model-facing catalog lines, projected from the same entries the source records.
|
||||
* The pseudo-XML escaping belongs to this frame, not to the published fact, so it
|
||||
* is applied here and never stored. Names are `isSkillName`-validated and carry
|
||||
* no escapable character.
|
||||
*/
|
||||
function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] {
|
||||
return entries.map(entry => `- \`${entry.name}\`: ${escapeText(entry.description)}`)
|
||||
}
|
||||
|
||||
function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string {
|
||||
return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n'))
|
||||
}
|
||||
|
||||
function digestCatalogEntries(entries: string): string {
|
||||
/**
|
||||
* Catalog identity over the durable entry list rather than the rendered prose.
|
||||
* The entries are what changes; the surrounding `<system-reminder>` framing is
|
||||
* written for the model and must not decide whether a republish is needed.
|
||||
*/
|
||||
function digestCatalogEntries(entries: SkillCatalogSource['entries']): string {
|
||||
// JSON per entry rather than a separator character: every separator is itself
|
||||
// a legal description character, so only quoting makes the boundary exact.
|
||||
const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n')
|
||||
return createHash('sha256')
|
||||
.update(entries)
|
||||
.update(canonical)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries of one durable catalog message, or undefined when the record is not a
|
||||
* usable catalog.
|
||||
*
|
||||
* `agent.session.events` may be a resumed, forked, or externally written seed,
|
||||
* and seed validation only guarantees a source object with a non-empty `kind`;
|
||||
* no per-kind field is checked there. An unreadable record is therefore treated
|
||||
* as "not this plugin's catalog" — the posture the replaced content digest had —
|
||||
* rather than throwing inside the step listener, which would fail every
|
||||
* subsequent turn of that session.
|
||||
*/
|
||||
function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | undefined {
|
||||
const entries = (source as { entries?: unknown }).entries
|
||||
if (!Array.isArray(entries)) return undefined
|
||||
const readable: { name: string; description: string }[] = []
|
||||
for (const entry of entries as readonly unknown[]) {
|
||||
if (typeof entry !== 'object' || entry === null) return undefined
|
||||
const { name, description } = entry as { name?: unknown; description?: unknown }
|
||||
if (typeof name !== 'string' || name === '' || typeof description !== 'string') return undefined
|
||||
readable.push({ name, description })
|
||||
}
|
||||
return readable
|
||||
}
|
||||
|
||||
function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
|
||||
const visible = new Set(agent.session.surface.nodes)
|
||||
const events = agent.session.events
|
||||
@@ -291,43 +361,31 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool
|
||||
// The loop bounds prove the read-only event view contains this index.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const event = events[index]!
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue
|
||||
const digest = catalogContentDigest(event.data.content)
|
||||
if (digest === undefined) continue
|
||||
if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue
|
||||
const entries = readCatalogEntries(event.data.source)
|
||||
if (entries === undefined) continue
|
||||
const digest = digestCatalogEntries(entries)
|
||||
published = true
|
||||
if (visible.has(event.seq)) return { visibleDigest: digest, published }
|
||||
}
|
||||
return { published }
|
||||
}
|
||||
|
||||
function catalogMessage(messages: readonly UserMessage[]): UserMessage | undefined {
|
||||
return messages.find(message =>
|
||||
message.source.kind === 'plugin'
|
||||
&& message.source.plugin === PLUGIN_SOURCE.plugin
|
||||
&& catalogContentDigest(message.content) !== undefined)
|
||||
}
|
||||
|
||||
function catalogContentDigest(content: UserMessage['content']): string | undefined {
|
||||
if (content.length !== 1 || content[0]?.type !== 'text') return undefined
|
||||
const text = content[0].text
|
||||
const start = text.indexOf(CATALOG_ENTRIES_START)
|
||||
if (start === -1) return undefined
|
||||
const entriesStart = start + CATALOG_ENTRIES_START.length
|
||||
const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart)
|
||||
if (end === -1) return undefined
|
||||
const renderedEntries = text.slice(entriesStart, end)
|
||||
const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries
|
||||
return digestCatalogEntries(entries)
|
||||
function catalogMessage(
|
||||
messages: readonly UserMessage[],
|
||||
): { message: UserMessage; entries: SkillCatalogSource['entries'] } | undefined {
|
||||
for (const message of messages) {
|
||||
if (message.source.kind !== 'skill-catalog') continue
|
||||
const entries = readCatalogEntries(message.source)
|
||||
if (entries !== undefined) return { message, entries }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Normalized, length-bounded description exactly as the catalog publishes it (unescaped). */
|
||||
function catalogDescription(value: string, maxLength: number): string {
|
||||
const normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||
const truncated = normalized.length <= maxLength
|
||||
? normalized
|
||||
: `${normalized.slice(0, maxLength - 3)}...`
|
||||
return escapeText(truncated)
|
||||
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
|
||||
@@ -111,8 +111,15 @@ async function proposeStep(
|
||||
|
||||
function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
|
||||
return session.events.filter((event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'dsh-tool-skill')
|
||||
&& event.data.source.kind === 'skill-catalog')
|
||||
}
|
||||
|
||||
function readableCatalog(event: Extract<SessionEvent, { type: 'user/message' }>): boolean {
|
||||
const entries = (event.data.source as { entries?: unknown }).entries
|
||||
return Array.isArray(entries)
|
||||
&& entries.every(entry => typeof entry === 'object' && entry !== null
|
||||
&& typeof (entry as { name?: unknown }).name === 'string'
|
||||
&& typeof (entry as { description?: unknown }).description === 'string')
|
||||
}
|
||||
|
||||
function catalogContent(entries: string[]): Message['content'] {
|
||||
@@ -258,7 +265,15 @@ describe('dsh-tool-skill', () => {
|
||||
{
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'user',
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [
|
||||
{ name: 'a-skill', description: 'Use {{placeholder}} <safely> & carefully.' },
|
||||
{ name: 'model-only-skill', description: 'Model-only skill.' },
|
||||
{ name: 'z-skill', description: 'Long description Long description Long descript...' },
|
||||
],
|
||||
},
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
@@ -391,14 +406,22 @@ describe('dsh-tool-skill', () => {
|
||||
const home = await tempDir('tool-proposed-empty-catalog')
|
||||
const ctx = await setup(home)
|
||||
const session = Session.create(SessionId('proposed-empty-catalog'))
|
||||
const malformed = createUserMessage({
|
||||
content: [{ type: 'text', text: 'preserve unreadable claimed context' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog' } as never,
|
||||
})
|
||||
const stale = createUserMessage({
|
||||
content: catalogContent(['- `stale-skill`: Stale skill']),
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'stale-skill', description: 'Stale skill' }],
|
||||
},
|
||||
})
|
||||
|
||||
const decision = await proposeStep(ctx, sessionAgent(session), [stale])
|
||||
const decision = await proposeStep(ctx, sessionAgent(session), [malformed, stale])
|
||||
|
||||
expect(decision).toEqual({ kind: 'enter', messages: [] })
|
||||
expect(decision).toEqual({ kind: 'enter', messages: [malformed] })
|
||||
})
|
||||
|
||||
it('keeps a proposed catalog that already matches the current snapshot', async () => {
|
||||
@@ -413,7 +436,11 @@ describe('dsh-tool-skill', () => {
|
||||
const session = Session.create(SessionId('matching-proposal'))
|
||||
const proposed = createUserMessage({
|
||||
content: catalogContent(['- `first-skill`: First skill']),
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'first-skill', description: 'First skill' }],
|
||||
},
|
||||
})
|
||||
|
||||
const decision = await proposeStep(ctx, sessionAgent(session), [proposed])
|
||||
@@ -465,7 +492,12 @@ describe('dsh-tool-skill', () => {
|
||||
expect(catalogMessages(session)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('resumes from the latest valid visible catalog content', async () => {
|
||||
it('resumes from the durable entries of the latest visible catalog', async () => {
|
||||
// Catalog identity moved onto `source.entries` when the catalog became a
|
||||
// `catalog`-form context: the model-facing prose no longer decides whether
|
||||
// a republish is needed, so a seeded message is recognized by its source
|
||||
// alone and malformed prose can no longer hide (or fake) a published
|
||||
// catalog. A foreign-sourced message is not this plugin's catalog at all.
|
||||
const home = await tempDir('tool-catalog-resume')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
@@ -478,30 +510,78 @@ describe('dsh-tool-skill', () => {
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: catalogContent(['- `old-skill`: Old skill']),
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
content: [{ type: 'text', text: 'prose a reader cannot rely on' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'old-skill', description: 'Old skill' }],
|
||||
},
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'missing catalog markers' }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<available_skills>\nmissing closing marker' }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'reasoning', text: 'not a user-role catalog block' }],
|
||||
content: catalogContent(['- `resumed-skill`: Resumed skill']),
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
|
||||
expect(catalogMessages(session)).toHaveLength(6)
|
||||
expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill')
|
||||
// The seeded entries differ from the live snapshot, so one replacement
|
||||
// lands; the foreign-sourced lookalike neither counts as published nor
|
||||
// suppresses it.
|
||||
expect(catalogMessages(session)).toHaveLength(2)
|
||||
const latest = catalogMessages(session).at(-1)
|
||||
expect(latest?.data.source).toMatchObject({
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
update: true,
|
||||
entries: [{ name: 'resumed-skill', description: 'Resumed skill' }],
|
||||
})
|
||||
expect(JSON.stringify(latest?.data.content)).toContain('resumed-skill')
|
||||
|
||||
// A second step over unchanged entries republishes nothing.
|
||||
await fireStep(ctx, agent, 1, 2)
|
||||
expect(catalogMessages(session)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => {
|
||||
// Seeds reach `agent.session.events` from JSONL/SQLite on resume or fork,
|
||||
// and seed validation only guarantees a source object with a non-empty
|
||||
// `kind`. A catalog whose entries are missing or wrongly shaped must be
|
||||
// skipped like any foreign record; throwing here would fail every later
|
||||
// step of that session at the latest possible point.
|
||||
const home = await tempDir('tool-catalog-malformed')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'live-skill',
|
||||
description: 'Live skill',
|
||||
source: 'runtime',
|
||||
content: 'Live body.',
|
||||
})
|
||||
const session = Session.create(SessionId('catalog-malformed'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
for (const source of [
|
||||
{ kind: 'skill-catalog', form: 'catalog' },
|
||||
{ kind: 'skill-catalog', form: 'catalog', entries: null },
|
||||
{ kind: 'skill-catalog', form: 'catalog', entries: 'not-an-array' },
|
||||
{ kind: 'skill-catalog', form: 'catalog', entries: [null] },
|
||||
{ kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'x' }] },
|
||||
{ kind: 'skill-catalog', form: 'catalog', entries: [{ description: 'no name' }] },
|
||||
]) {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'unreadable catalog' }],
|
||||
source: source as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
await expect(fireStep(ctx, agent, 1, 1)).resolves.toBeUndefined()
|
||||
|
||||
// None of the six counted as published, so the live catalog lands as a
|
||||
// first publication rather than a replacement.
|
||||
const published = catalogMessages(session).filter(event => readableCatalog(event))
|
||||
expect(published).toHaveLength(1)
|
||||
expect(published[0]?.data.source).toMatchObject({ kind: 'skill-catalog', form: 'catalog' })
|
||||
expect(published[0]?.data.source).not.toHaveProperty('update')
|
||||
expect(JSON.stringify(published[0]?.data.content)).toContain('live-skill')
|
||||
})
|
||||
|
||||
it('re-establishes the current catalog after compaction hides its durable message', async () => {
|
||||
|
||||
Reference in New Issue
Block a user