Merge remote-tracking branch 'origin/master' into worktree/context-source-cards
# Conflicts: # .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml # .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md # .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md # apps/web/tests/seeded-history.e2e.ts # apps/web/tests/snapshots/queue-actions/layout.expected.md # docs/core-data-structures/core.i18n.yaml # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/tests/history-fold.spec.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx # packages/context/workspace-context/src/index.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/skill/tool-skill/README.i18n.yaml # packages/skill/tool-skill/README.md # packages/skill/tool-skill/README.zh.md # packages/skill/tool-skill/src/index.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts
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: c8c8b0d18665893104bc8ac65c1a90cae7816724
|
||||
README.zh.md: 696e18955bf6f622251a0ca875f53e66419ec944
|
||||
README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626
|
||||
README.zh.md: c6b815bef59eb1f14be0892078694f129366d004
|
||||
|
||||
@@ -8,13 +8,13 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools
|
||||
|
||||
## Catalog lifecycle
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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`
|
||||
|
||||
@@ -28,7 +28,7 @@ Resource guidance resolves only paths or URLs explicitly referenced by the instr
|
||||
|
||||
An unresolved name reports that the skill is unknown or no longer available. Invalid names and skills whose `invocation.modelInvocable` is `false` produce distinct error results. `invocation.userInvocable` does not restrict this model-facing surface.
|
||||
|
||||
Tool execution does not call `agent.inject()`. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body as synthetic context. Only the catalog projection injects replacement summaries.
|
||||
Tool execution does not add a synthetic context message. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body. Only the catalog projection adds replacement summaries.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
## 目录生命周期
|
||||
|
||||
每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 `<system-reminder>`。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。
|
||||
每次符合条件的 `agent/pre-step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将 pre-step 中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会向下游 `enter` 决策添加初始的持久用户角色 `<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 变化时,下游 `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`
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。
|
||||
|
||||
工具执行不调用 `agent.inject()`。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需将正文重复为合成上下文。只有目录投影会注入替换摘要。
|
||||
工具执行不会添加合成上下文消息。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需重复正文。只有目录投影会添加替换摘要。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
@@ -162,23 +162,46 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
|
||||
// Register after the tool so reverse teardown removes guidance first. Exact definition
|
||||
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
|
||||
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
|
||||
ctx.on('agent/pre-step', async (
|
||||
agent: Agent,
|
||||
_messages,
|
||||
{ signal },
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject') return decision
|
||||
signal.throwIfAborted()
|
||||
const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool
|
||||
const snapshot = toolVisible
|
||||
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal })
|
||||
: { skills: [], complete: true }
|
||||
signal.throwIfAborted()
|
||||
if (!snapshot.complete) return
|
||||
if (!snapshot.complete) return decision
|
||||
const skills = snapshot.skills.filter(isModelInvocable)
|
||||
const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)
|
||||
const digest = digestCatalogEntries(entries)
|
||||
const history = catalogHistory(agent)
|
||||
if (history.visibleDigest === digest) return
|
||||
if (!history.published && skills.length === 0) return
|
||||
const existing = catalogMessage(decision.messages)
|
||||
if (history.visibleDigest === digest) {
|
||||
return existing === undefined
|
||||
? decision
|
||||
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) }
|
||||
}
|
||||
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.message.id) }
|
||||
}
|
||||
const catalog = history.published
|
||||
? renderCatalogUpdate(entries)
|
||||
: renderCatalogMessage(entries)
|
||||
agent.inject(catalog)
|
||||
return {
|
||||
kind: 'enter',
|
||||
messages: existing === undefined
|
||||
? [...decision.messages, catalog]
|
||||
: decision.messages.map(message => message.id === existing.message.id ? catalog : message),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -350,6 +373,17 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool
|
||||
return { published }
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -5,10 +5,10 @@ import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
@@ -44,17 +44,14 @@ function agentForCwd(cwd: string): Agent {
|
||||
id,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
reserveTurnAdmission: () => undefined,
|
||||
steer: () => {},
|
||||
inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
|
||||
cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
@@ -64,24 +61,21 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
|
||||
id: SessionId(id),
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'running',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
reserveTurnAdmission: () => undefined,
|
||||
steer: () => {},
|
||||
inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
|
||||
cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function openMessageTurn(session: Session, turn = 1): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `turn ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
@@ -89,7 +83,32 @@ function openMessageTurn(session: Session, turn = 1): void {
|
||||
}
|
||||
|
||||
async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): Promise<void> {
|
||||
await agentEvents(ctx, agent).serial('agent/step', turn, step, new AbortController().signal)
|
||||
const signal = new AbortController().signal
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
[],
|
||||
{ turn, step, signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
if (decision.kind === 'enter') {
|
||||
for (const message of decision.messages) {
|
||||
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function proposeStep(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
messages: UserMessage[],
|
||||
): Promise<PreStepDecision> {
|
||||
const signal = new AbortController().signal
|
||||
return await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
messages,
|
||||
{ turn: 1, step: 1, signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages }),
|
||||
)
|
||||
}
|
||||
|
||||
function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
|
||||
@@ -117,7 +136,17 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro
|
||||
}
|
||||
|
||||
async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> {
|
||||
await agentEvents(ctx, agent).serial('agent/step', 1, 1, signal)
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
[],
|
||||
{ turn: 1, step: 1, signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
if (decision.kind === 'enter') {
|
||||
for (const message of decision.messages) {
|
||||
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
return agent.session.deriveMessages()
|
||||
}
|
||||
|
||||
@@ -212,13 +241,30 @@ describe('dsh-tool-skill', () => {
|
||||
source: 'runtime',
|
||||
content: 'User-only body.',
|
||||
})
|
||||
ctx.on('agent/step', (agent) => {
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' } }))
|
||||
ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject') return decision
|
||||
return {
|
||||
...decision,
|
||||
messages: [
|
||||
...decision.messages,
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: 'later contribution' }],
|
||||
source: { kind: 'plugin', plugin: 'later-contribution' },
|
||||
}),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const prefix = await composePrefix(ctx, '/workspace')
|
||||
|
||||
expect(prefix).toEqual([
|
||||
{
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'later contribution' }],
|
||||
source: { kind: 'plugin', plugin: 'later-contribution' },
|
||||
},
|
||||
{
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'user',
|
||||
@@ -248,14 +294,8 @@ describe('dsh-tool-skill', () => {
|
||||
].join('\n'),
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'later contribution' }],
|
||||
source: { kind: 'plugin', plugin: 'later-contribution' },
|
||||
},
|
||||
])
|
||||
const rendered = JSON.stringify(prefix[0])
|
||||
const rendered = JSON.stringify(prefix[1])
|
||||
expect(rendered).not.toContain('whenToUse')
|
||||
expect(rendered).not.toContain('secret-source')
|
||||
expect(rendered).not.toContain('/secret/path')
|
||||
@@ -325,6 +365,88 @@ describe('dsh-tool-skill', () => {
|
||||
expect(catalogMessages(session)).toEqual([])
|
||||
})
|
||||
|
||||
it('deduplicates or replaces a catalog already proposed for the same step', async () => {
|
||||
const home = await tempDir('tool-proposed-catalog')
|
||||
const ctx = await setup(home)
|
||||
const disposeFirst = ctx.skills.register({
|
||||
name: 'first-skill',
|
||||
description: 'First skill',
|
||||
source: 'runtime',
|
||||
content: 'First body.',
|
||||
})
|
||||
const session = Session.create(SessionId('proposed-catalog'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
const initial = catalogMessages(session)[0]?.data
|
||||
if (initial === undefined) throw new Error('expected initial catalog')
|
||||
|
||||
const duplicate = await proposeStep(ctx, agent, [initial])
|
||||
expect(duplicate).toEqual({ kind: 'enter', messages: [] })
|
||||
|
||||
ctx.skills.register({
|
||||
name: 'second-skill',
|
||||
description: 'Second skill',
|
||||
source: 'runtime',
|
||||
content: 'Second body.',
|
||||
})
|
||||
const companion = createUserMessage({
|
||||
content: [{ type: 'text', text: 'keep this message' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const replaced = await proposeStep(ctx, agent, [companion, initial])
|
||||
expect(replaced.kind).toBe('enter')
|
||||
if (replaced.kind === 'reject') throw new Error('expected catalog replacement')
|
||||
expect(replaced.messages).toHaveLength(2)
|
||||
expect(replaced.messages[0]).toBe(companion)
|
||||
expect(replaced.messages[1]?.id).not.toBe(initial.id)
|
||||
expect(JSON.stringify(replaced.messages[1]?.content)).toContain('second-skill')
|
||||
|
||||
disposeFirst()
|
||||
})
|
||||
|
||||
it('removes a stale proposed catalog before the first empty baseline', async () => {
|
||||
const home = await tempDir('tool-proposed-empty-catalog')
|
||||
const ctx = await setup(home)
|
||||
const session = Session.create(SessionId('proposed-empty-catalog'))
|
||||
const stale = createUserMessage({
|
||||
content: catalogContent(['- `stale-skill`: Stale skill']),
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'stale-skill', description: 'Stale skill' }],
|
||||
},
|
||||
})
|
||||
|
||||
const decision = await proposeStep(ctx, sessionAgent(session), [stale])
|
||||
|
||||
expect(decision).toEqual({ kind: 'enter', messages: [] })
|
||||
})
|
||||
|
||||
it('keeps a proposed catalog that already matches the current snapshot', async () => {
|
||||
const home = await tempDir('tool-matching-proposal')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'first-skill',
|
||||
description: 'First skill',
|
||||
source: 'runtime',
|
||||
content: 'First body.',
|
||||
})
|
||||
const session = Session.create(SessionId('matching-proposal'))
|
||||
const proposed = createUserMessage({
|
||||
content: catalogContent(['- `first-skill`: First skill']),
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'first-skill', description: 'First skill' }],
|
||||
},
|
||||
})
|
||||
|
||||
const decision = await proposeStep(ctx, sessionAgent(session), [proposed])
|
||||
|
||||
expect(decision).toEqual({ kind: 'enter', messages: [proposed] })
|
||||
})
|
||||
|
||||
it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => {
|
||||
const home = await tempDir('tool-dynamic-catalog')
|
||||
const ctx = await setup(home)
|
||||
|
||||
Reference in New Issue
Block a user