From 91cd3f7b7c27bd12c186f44ede42e47345a8f29f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 21 Apr 2026 11:07:21 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=BD=20refactor:=20Skills=20polish:=20p?= =?UTF-8?q?recedence-aware=20body=20validation,=20controller=20drop=20logs?= =?UTF-8?q?,=20SkillPills=20rename=20(#12760)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge sanity-review cleanup on top of #12746: - `createSkill` / `updateSkill` now parse SKILL.md body's always-apply status once and reuse it for both validation and derivation (was parsing the same YAML block twice per call). - Body-inline `always-apply:` validation becomes precedence-aware: a caller sending an explicit top-level `alwaysApply` or a structured `frontmatter['always-apply']` no longer gets rejected for a typo in the body — the body value is never consulted at derivation time when a higher-precedence source wins. New tests cover the three relevant interactions (explicit+body-typo, frontmatter+body-typo, body-only typo still rejects). - OpenAI and Responses controllers now emit a `logger.warn` when `injectSkillPrimes` drops always-apply primes to stay under `MAX_PRIMED_SKILLS_PER_TURN`. `injectSkillPrimes` already logs internally; the controller-level warn adds endpoint context so operators can identify which path hit the cap at a glance. Mirrors AgentClient's existing log. - Rename `ManualSkillPills` → `SkillPills` (component + type + file + test + all JSDoc references). The component handles both manual and always-apply pills now; the original name was carried over from the manual-only Phase 3 and misleads new readers. - Drive-by fix: declare `appConfig = req.config` at the top of `createResponse` in `responses.js` — it was used unqualified on lines 381/396, which silently evaluated to `undefined` (via optional chaining) and disabled the skills-capability check on the Responses endpoint. Pre-existing, surfaced by lint on the touched file. --- api/app/clients/BaseClient.js | 4 +- api/server/controllers/agents/client.js | 4 +- api/server/controllers/agents/openai.js | 10 +++ api/server/controllers/agents/responses.js | 11 +++ .../Chat/Input/PendingManualSkillsChips.tsx | 2 +- .../components/Chat/Input/SkillsCommand.tsx | 2 +- .../Input/__tests__/SkillsCommand.spec.tsx | 2 +- .../Chat/Messages/Content/Container.tsx | 6 +- .../Chat/Messages/Content/ContentParts.tsx | 2 +- .../{ManualSkillPills.tsx => SkillPills.tsx} | 8 +- ...killPills.test.tsx => SkillPills.test.tsx} | 20 ++--- client/src/hooks/Chat/useChatFunctions.ts | 2 +- packages/data-provider/src/schemas.ts | 2 +- .../data-schemas/src/methods/skill.spec.ts | 74 +++++++++++++++++++ packages/data-schemas/src/methods/skill.ts | 64 ++++++++++++++-- packages/data-schemas/src/schema/message.ts | 2 +- packages/data-schemas/src/types/message.ts | 2 +- 17 files changed, 180 insertions(+), 37 deletions(-) rename client/src/components/Chat/Messages/Content/{ManualSkillPills.tsx => SkillPills.tsx} (92%) rename client/src/components/Chat/Messages/Content/__tests__/{ManualSkillPills.test.tsx => SkillPills.test.tsx} (78%) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 1a77c8cf82..41b6ea0db7 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -494,7 +494,7 @@ class BaseClient { } /** * Persist the user's manual skill picks onto the user message so the - * frontend `ManualSkillPills` component can render them in history + * frontend `SkillPills` component can render them in history * after reload. UI-only metadata — the runtime skill resolution * pipeline reads the top-level `req.body.manualSkills` separately. * Filter is defense-in-depth on top of Mongoose schema validation: @@ -510,7 +510,7 @@ class BaseClient { } /** * Persist the names of skills auto-primed this turn via `always-apply` - * frontmatter so `ManualSkillPills` can render pinned-variant badges + * frontmatter so `SkillPills` can render pinned-variant badges * on the user bubble that survive reload and history render. Frozen * at turn time (not reconstructed from `Skill.alwaysApply` at render * time) because the flag is mutable — historical turns must keep diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index a19a5bf628..3669933cae 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -948,11 +948,11 @@ class AgentClient extends BaseClient { * the card would cause the skill body to get primed twice per * turn starting on turn 2. The user-facing acknowledgement for * always-apply lives on the user bubble as the pinned - * `ManualSkillPills` row (`message.alwaysAppliedSkills`), which + * `SkillPills` row (`message.alwaysAppliedSkills`), which * is the durable signal the user wants: "this skill auto-primes". * * Live streaming display of manual user-bubble pills is handled - * by `ManualSkillPills` reading `message.manualSkills`. No + * by `SkillPills` reading `message.manualSkills`. No * separate SSE emit is needed here; trying to stream a mid-run * tool_call at index 0 collided with the LLM's first text * content, while emitting at a sparse offset pushed the card diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 67b0221cc6..d26be8117e 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -480,6 +480,16 @@ const OpenAIChatCompletionController = async (req, res) => { alwaysApplySkillPrimes, }); indexTokenCountMap = primeResult.indexTokenCountMap; + /* Surface the cap-driven always-apply truncation at the controller + layer too — `injectSkillPrimes` already logs internally, but the + controller-level warn includes endpoint context so operators can + tell at a glance which path hit the cap. Mirrors AgentClient's + warn in `client.js`. */ + if (primeResult.alwaysApplyDropped > 0) { + logger.warn( + `[OpenAI API] Dropped ${primeResult.alwaysApplyDropped} always-apply prime(s) to stay within MAX_PRIMED_SKILLS_PER_TURN.`, + ); + } } /** diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 2954f8973c..9c626234cd 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -378,6 +378,7 @@ const createResponse = async (req, res) => { getSkillByName: db.getSkillByName, }; + const appConfig = req.config; const enabledCapabilities = new Set( appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities, ); @@ -555,6 +556,16 @@ const createResponse = async (req, res) => { alwaysApplySkillPrimes, }); indexTokenCountMap = primeResult.indexTokenCountMap; + /* Surface the cap-driven always-apply truncation at the controller + layer too — `injectSkillPrimes` already logs internally, but the + controller-level warn includes endpoint context so operators can + tell at a glance which path hit the cap. Mirrors AgentClient's + warn in `client.js`. */ + if (primeResult.alwaysApplyDropped > 0) { + logger.warn( + `[Responses API] Dropped ${primeResult.alwaysApplyDropped} always-apply prime(s) to stay within MAX_PRIMED_SKILLS_PER_TURN.`, + ); + } } /* Stable for the turn: the capability set is the admin config, and diff --git a/client/src/components/Chat/Input/PendingManualSkillsChips.tsx b/client/src/components/Chat/Input/PendingManualSkillsChips.tsx index 3ff0c4b08b..5111ea985d 100644 --- a/client/src/components/Chat/Input/PendingManualSkillsChips.tsx +++ b/client/src/components/Chat/Input/PendingManualSkillsChips.tsx @@ -8,7 +8,7 @@ import store from '~/store'; * Chip row rendered above the textarea showing skills the user picked in * the `$` popover for the next submission. Each chip has an × button to * remove that skill before sending — primary purpose is new-chat UX where - * there's no submitted user message yet to render `ManualSkillPills` on. + * there's no submitted user message yet to render `SkillPills` on. * * Reads + writes `pendingManualSkillsByConvoId` atom directly. The atom * gets drained in `useChatFunctions.ask` on submit, so chips naturally diff --git a/client/src/components/Chat/Input/SkillsCommand.tsx b/client/src/components/Chat/Input/SkillsCommand.tsx index 364a0ff32e..aec1d1b80c 100644 --- a/client/src/components/Chat/Input/SkillsCommand.tsx +++ b/client/src/components/Chat/Input/SkillsCommand.tsx @@ -215,7 +215,7 @@ function SkillsCommandContent({ before the LLM turn — no textarea-level marker is needed, and injecting `$skill-name ` as text would mislead users into thinking free-form text invocation is supported. Visual confirmation after - submit comes from `ManualSkillPills` on the user message bubble + submit comes from `SkillPills` on the user message bubble until the live skill-card stream takes over. */ setPendingManualSkills((prev) => prev.includes(mention.value) ? prev : [...prev, mention.value], diff --git a/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx b/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx index 74ef28d996..064688552e 100644 --- a/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx @@ -216,7 +216,7 @@ describe('SkillsCommand', () => { expect(agentUpdater({ skills: true })).toEqual({ skills: true }); /* Textarea is cleared of the `$` trigger but no `$skill-name ` cue is - inserted — visual confirmation is the `ManualSkillPills` row that + inserted — visual confirmation is the `SkillPills` row that renders on the submitted user message, and injecting text would mislead users into thinking free-form `$name` invocation works. */ expect(textAreaRef.current?.value).toBe(''); diff --git a/client/src/components/Chat/Messages/Content/Container.tsx b/client/src/components/Chat/Messages/Content/Container.tsx index 00d28b0536..7d5f842511 100644 --- a/client/src/components/Chat/Messages/Content/Container.tsx +++ b/client/src/components/Chat/Messages/Content/Container.tsx @@ -1,6 +1,6 @@ import { TMessage } from 'librechat-data-provider'; import Files from './Files'; -import ManualSkillPills from './ManualSkillPills'; +import SkillPills from './SkillPills'; const Container = ({ children, message }: { children: React.ReactNode; message?: TMessage }) => (
- - + + )} {children} diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 0380e77f80..3fb23dc2a6 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -144,7 +144,7 @@ const ContentParts = memo(function ContentParts({ * response (no `manualSkills` field) → interim cards disappear and * the real `skill` tool_call part in `content` takes over. * - * Skipped on the user side (they get `ManualSkillPills` on the user + * Skipped on the user side (they get `SkillPills` on the user * bubble) and when no skills were invoked on this turn. */ const pendingSkills = useMemo( diff --git a/client/src/components/Chat/Messages/Content/ManualSkillPills.tsx b/client/src/components/Chat/Messages/Content/SkillPills.tsx similarity index 92% rename from client/src/components/Chat/Messages/Content/ManualSkillPills.tsx rename to client/src/components/Chat/Messages/Content/SkillPills.tsx index 7e56b048c4..b9b60f116a 100644 --- a/client/src/components/Chat/Messages/Content/ManualSkillPills.tsx +++ b/client/src/components/Chat/Messages/Content/SkillPills.tsx @@ -11,7 +11,7 @@ import { useLocalize } from '~/hooks'; * Exported so message-render code can import and pass the appropriate * value without hardcoding string literals. */ -export type ManualSkillPillsSource = 'manual' | 'always-apply'; +export type SkillPillsSource = 'manual' | 'always-apply'; /** * Compact pill row rendered on a submitted user message, one chip per skill @@ -25,12 +25,12 @@ export type ManualSkillPillsSource = 'manual' | 'always-apply'; * and pills survive page reloads / history renders. The `source` prop picks * the icon variant so both flavors render from the same component. */ -function ManualSkillPills({ +function SkillPills({ skills, source = 'manual', }: { skills?: string[]; - source?: ManualSkillPillsSource; + source?: SkillPillsSource; }) { const localize = useLocalize(); @@ -64,4 +64,4 @@ function ManualSkillPills({ ); } -export default memo(ManualSkillPills); +export default memo(SkillPills); diff --git a/client/src/components/Chat/Messages/Content/__tests__/ManualSkillPills.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/SkillPills.test.tsx similarity index 78% rename from client/src/components/Chat/Messages/Content/__tests__/ManualSkillPills.test.tsx rename to client/src/components/Chat/Messages/Content/__tests__/SkillPills.test.tsx index a39e95e310..9b7a41b132 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ManualSkillPills.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/SkillPills.test.tsx @@ -6,21 +6,21 @@ jest.mock('~/hooks', () => ({ `${key}:${params?.[0] ?? ''}`, })); -import ManualSkillPills from '../ManualSkillPills'; +import SkillPills from '../SkillPills'; -describe('ManualSkillPills', () => { +describe('SkillPills', () => { it('renders nothing when skills is undefined', () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); it('renders nothing when skills is empty', () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); it('renders one pill per entry', () => { - render(); + render(); const items = screen.getAllByRole('listitem'); expect(items).toHaveLength(2); expect(items[0]).toHaveTextContent('brand-guidelines'); @@ -28,18 +28,18 @@ describe('ManualSkillPills', () => { }); it('localizes the list aria-label (manual default)', () => { - render(); + render(); expect(screen.getByRole('list')).toHaveAttribute('aria-label', 'com_ui_skills_manual_invoked:'); }); it('tags each pill with data-skill-source="manual" by default', () => { - render(); + render(); const items = screen.getAllByRole('listitem'); expect(items[0]).toHaveAttribute('data-skill-source', 'manual'); }); it('switches aria-label and data attribute for source="always-apply"', () => { - render(); + render(); expect(screen.getByRole('list')).toHaveAttribute( 'aria-label', 'com_ui_skills_always_apply_invoked:', @@ -50,9 +50,9 @@ describe('ManualSkillPills', () => { it('renders the pin-icon variant for always-apply (no ScrollText)', () => { const { container: alwaysApply } = render( - , + , ); - const { container: manual } = render(); + const { container: manual } = render(); // lucide-react icons render as SVGs with distinguishing class names. We // assert on class token presence rather than the full SVG markup so a // lucide internal change to path data doesn't break this contract. diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 34c6990b07..36647908d2 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -259,7 +259,7 @@ export default function useChatFunctions({ error: false, /** * UI-only metadata. Survives reload because the backend persists the - * field on the message schema, and `ManualSkillPills` reads straight + * field on the message schema, and `SkillPills` reads straight * off the message so there's no Recoil state to clean up. Runtime * skill resolution reads the top-level `manualSkills` payload field. */ diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 3e1f8a74ef..dd611250cd 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -682,7 +682,7 @@ export const tMessageSchema = z.object({ .optional(), /** * Skill names the user invoked manually via the `$` popover on this turn. - * Purely UI metadata — `ManualSkillPills` renders these above the message + * Purely UI metadata — `SkillPills` renders these above the message * bubble so users can see which skills they asked for in history and on * reload. Runtime resolution uses the top-level payload field with the * same name. Empty / absent for model-invoked skills (shown as tool_call diff --git a/packages/data-schemas/src/methods/skill.spec.ts b/packages/data-schemas/src/methods/skill.spec.ts index 4d8ea062a9..a3c62887eb 100644 --- a/packages/data-schemas/src/methods/skill.spec.ts +++ b/packages/data-schemas/src/methods/skill.spec.ts @@ -1187,6 +1187,80 @@ describe('Skill CRUD methods', () => { expect(result.skill.alwaysApply).toBe(true); } }); + + it('updateSkill accepts a body typo when an explicit top-level alwaysApply overrides it', async () => { + /* Validation respects the same precedence as derivation: a caller + sending `alwaysApply: true` alongside a body whose frontmatter + carries a typo (`always-apply: tru`) should NOT be rejected — + the body value is never consulted. Rejecting would be + user-hostile for programmatic callers that legitimately own the + explicit field. */ + const { skill } = await methods.createSkill(makeSkillInput({ name: 'typo-overridden' })); + const bodyWithTypo = `---\nname: typo-overridden\ndescription: body has a broken flag.\nalways-apply: tru\n---\n\n# Body`; + const result = await methods.updateSkill({ + id: skill._id.toString(), + expectedVersion: skill.version, + update: { alwaysApply: true, body: bodyWithTypo }, + }); + expect(result.status).toBe('updated'); + if (result.status === 'updated') { + expect(result.skill.alwaysApply).toBe(true); + } + }); + + it('updateSkill accepts a body typo when a structured frontmatter override carries a valid boolean', async () => { + /* Same precedence rule for the frontmatter-bag layer: a caller + that sends a structured `frontmatter['always-apply']: true` + plus a body with a typo is overriding the body at derivation + time, so the body typo should not reject the update. */ + const { skill } = await methods.createSkill(makeSkillInput({ name: 'fm-overrides-body' })); + const bodyWithTypo = `---\nname: fm-overrides-body\ndescription: broken body flag.\nalways-apply: yes\n---\n\n# Body`; + const result = await methods.updateSkill({ + id: skill._id.toString(), + expectedVersion: skill.version, + update: { + frontmatter: { + name: 'fm-overrides-body', + description: 'A small demo skill used in tests.', + 'always-apply': true, + }, + body: bodyWithTypo, + }, + }); + expect(result.status).toBe('updated'); + if (result.status === 'updated') { + expect(result.skill.alwaysApply).toBe(true); + } + }); + + it('updateSkill still rejects a body typo when no higher-precedence source is provided', async () => { + /* Body-inline validation must still fire when nothing else is + carrying the value — otherwise a body PATCH with `tru` would + silently drop the typo + keep the old column value. */ + const { skill } = await methods.createSkill(makeSkillInput({ name: 'typo-body-only' })); + const bodyWithTypo = `---\nname: typo-body-only\ndescription: nothing else carries the flag.\nalways-apply: tru\n---\n\n# Body`; + await expect( + methods.updateSkill({ + id: skill._id.toString(), + expectedVersion: skill.version, + update: { body: bodyWithTypo }, + }), + ).rejects.toMatchObject({ code: 'SKILL_VALIDATION_FAILED' }); + }); + + it('createSkill accepts a body typo when an explicit top-level alwaysApply overrides it', async () => { + /* Same precedence-aware validation on the create path: explicit + top-level `alwaysApply` short-circuits body-inline validation. */ + const bodyWithTypo = `---\nname: create-typo-overridden\ndescription: broken body flag.\nalways-apply: tru\n---\n\n# Body`; + const { skill } = await methods.createSkill( + makeSkillInput({ + name: 'create-typo-overridden', + alwaysApply: false, + body: bodyWithTypo, + }), + ); + expect(skill.alwaysApply).toBe(false); + }); }); describe('SkillFile methods', () => { diff --git a/packages/data-schemas/src/methods/skill.ts b/packages/data-schemas/src/methods/skill.ts index 55607b26ad..79387bc7a2 100644 --- a/packages/data-schemas/src/methods/skill.ts +++ b/packages/data-schemas/src/methods/skill.ts @@ -756,6 +756,10 @@ function resolveAlwaysApplyFromInput( frontmatter: Record | undefined, body: string | undefined, fallback: boolean, + /* Callers that have already parsed the body (e.g. because they also + ran body-level validation) can thread the result in to avoid a + second parse. Leave undefined to let the helper parse on demand. */ + precomputedBody?: BodyAlwaysApplyResult, ): boolean { if (typeof explicit === 'boolean') { return explicit; @@ -764,7 +768,7 @@ function resolveAlwaysApplyFromInput( if (typeof fromFrontmatter === 'boolean') { return fromFrontmatter; } - const fromBody = extractAlwaysApplyFromBody(body); + const fromBody = precomputedBody ?? extractAlwaysApplyFromBody(body); if (fromBody.status === 'valid') { return fromBody.value; } @@ -846,6 +850,11 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk } async function createSkill(data: CreateSkillInput): Promise { + /* Parse body's always-apply status once — reused for validation + (below) and derivation in `resolveAlwaysApplyFromInput`. Avoids + parsing the same YAML frontmatter block twice per create. */ + const bodyAlwaysApply = + data.body !== undefined ? extractAlwaysApplyFromBody(data.body) : undefined; const issues: ValidationIssue[] = [ ...validateSkillName(data.name), ...validateSkillDescription(data.description), @@ -853,8 +862,25 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk ...validateSkillDisplayTitle(data.displayTitle), ...validateSkillFrontmatter(data.frontmatter), ...validateAlwaysApply(data.alwaysApply), - ...validateAlwaysApplyInBody(data.body), ]; + /* Body-level `always-apply:` only needs to be well-formed when a + higher-precedence source won't override it (see + `resolveAlwaysApplyFromInput` for the cascade). A caller sending + an explicit top-level `alwaysApply` or a structured + `frontmatter['always-apply']` has the body value overridden at + derivation time, so rejecting them for a typo they aren't relying + on would be user-hostile. */ + if ( + bodyAlwaysApply?.status === 'invalid' && + typeof data.alwaysApply !== 'boolean' && + typeof data.frontmatter?.['always-apply'] !== 'boolean' + ) { + issues.push({ + field: 'body.frontmatter.always-apply', + code: 'INVALID_TYPE', + message: '"always-apply" in SKILL.md frontmatter must be a boolean (true or false)', + }); + } const { errors, warnings } = partitionIssues(issues); if (errors.length > 0) { const error = new Error('Skill validation failed'); @@ -901,6 +927,7 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk data.frontmatter, data.body, false, + bodyAlwaysApply, ), tenantId: data.tenantId, ...derived, @@ -1128,6 +1155,12 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk return { status: 'not_found' }; } + /* Parse body's always-apply status once — reused for validation + (precedence-aware, below) and the derivation cascade further + down. Avoids parsing the same YAML frontmatter block twice per + update. */ + const bodyAlwaysApply = + update.body !== undefined ? extractAlwaysApplyFromBody(update.body) : undefined; const issues: ValidationIssue[] = []; if (update.name !== undefined) issues.push(...validateSkillName(update.name)); if (update.description !== undefined) @@ -1138,7 +1171,23 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk if (update.frontmatter !== undefined) issues.push(...validateSkillFrontmatter(update.frontmatter)); if (update.alwaysApply !== undefined) issues.push(...validateAlwaysApply(update.alwaysApply)); - if (update.body !== undefined) issues.push(...validateAlwaysApplyInBody(update.body)); + /* Body-level `always-apply:` only needs to be well-formed when a + higher-precedence source won't override it (see + `resolveAlwaysApplyFromInput` for precedence). Rejecting a typo + the caller is already overriding would be user-hostile, and the + body-inline derivation branch below is skipped for those + payloads anyway. */ + if ( + bodyAlwaysApply?.status === 'invalid' && + update.alwaysApply === undefined && + typeof update.frontmatter?.['always-apply'] !== 'boolean' + ) { + issues.push({ + field: 'body.frontmatter.always-apply', + code: 'INVALID_TYPE', + message: '"always-apply" in SKILL.md frontmatter must be a boolean (true or false)', + }); + } const { errors, warnings } = partitionIssues(issues); if (errors.length > 0) { const error = new Error('Skill validation failed'); @@ -1207,11 +1256,10 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk derivedAlwaysApply = fromFrontmatter; } } - if (derivedAlwaysApply === undefined && update.body !== undefined) { - const fromBody = extractAlwaysApplyFromBody(update.body); - if (fromBody.status === 'valid') { - derivedAlwaysApply = fromBody.value; - } else if (fromBody.status === 'absent') { + if (derivedAlwaysApply === undefined && bodyAlwaysApply !== undefined) { + if (bodyAlwaysApply.status === 'valid') { + derivedAlwaysApply = bodyAlwaysApply.value; + } else if (bodyAlwaysApply.status === 'absent') { /* An `absent` result means the user submitted a new body that declares no `always-apply:` key (either the key was removed or no frontmatter block was ever there). The body is the diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts index eb512129a9..d68edd0df3 100644 --- a/packages/data-schemas/src/schema/message.ts +++ b/packages/data-schemas/src/schema/message.ts @@ -124,7 +124,7 @@ const messageSchema: Schema = new Schema( attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined }, /** * Skill names the user invoked manually via the `$` popover on this turn. - * UI metadata only — `ManualSkillPills` on the frontend renders these on + * UI metadata only — `SkillPills` on the frontend renders these on * the user message bubble so the selection persists through reload and * shows in history. Runtime skill resolution lives separately on the * request body, not on the message itself. diff --git a/packages/data-schemas/src/types/message.ts b/packages/data-schemas/src/types/message.ts index 5eb47b8e25..cf2213d88b 100644 --- a/packages/data-schemas/src/types/message.ts +++ b/packages/data-schemas/src/types/message.ts @@ -44,7 +44,7 @@ export interface IMessage extends Document { encoding?: string; }; attachments?: unknown[]; - /** Skills the user invoked manually via the `$` popover on this turn. UI-only metadata for `ManualSkillPills`. */ + /** Skills the user invoked manually via the `$` popover on this turn. UI-only metadata for `SkillPills`. */ manualSkills?: string[]; /** * Skills auto-primed on this turn via `always-apply` frontmatter. Persisted