From 4635503c6fa3cbac06ddf33db39d56a48b33c21f Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 2 Jul 2026 17:06:48 -0400 Subject: [PATCH] Support .md skill files as user-configurable default skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `defaultSkills` field to `SystemCard` so a user's active system card supplies the skills enabled by default in new AI assistant rooms, referencing `.md` skill files (`MarkdownDef` with `boxel.kind: skill` frontmatter). - SystemCard: `defaultSkills = linksToMany(MarkdownDef)` + a "Default Skills" section in the isolated view. - `loadDefaultSkills(submode)` now returns skill ids — from the system card's `defaultSkills` when set (mode-agnostic), else the hardcoded fallback, which is repointed from legacy `Skill/*` cards to `skills/*/SKILL.md`. - Room creation resolves skill ids kind-agnostically (`.md` files or legacy `Skill` cards) via `loadSkillSource`, splitting card vs file uploads; `CreateAIAssistantRoomInput` gains `enabledSkillIds`/`disabledSkillIds`. - "Add same skills" is now id-based so `.md` skills survive that flow. - Seed the default SystemCard with the three default `.md` skills. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/command.gts | 6 + packages/base/system-card.gts | 36 +++++ packages/host/app/commands/ask-ai.ts | 4 +- .../app/commands/create-ai-assistant-room.ts | 140 ++++++++++++++---- packages/host/app/lib/utils.ts | 12 ++ .../services/ai-assistant-panel-service.ts | 105 +++++-------- packages/host/app/services/matrix-service.ts | 43 +++--- packages/host/tests/helpers/index.gts | 1 + .../commands/load-default-skills-test.gts | 104 +++++++++++++ packages/matrix/tests/skills.spec.ts | 5 +- packages/runtime-common/constants.ts | 7 +- 11 files changed, 336 insertions(+), 127 deletions(-) create mode 100644 packages/host/tests/integration/commands/load-default-skills-test.gts diff --git a/packages/base/command.gts b/packages/base/command.gts index 3b26c0e7313..ab754538859 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -335,8 +335,14 @@ export class CorrectnessResultCard extends CardDef { export class CreateAIAssistantRoomInput extends CardDef { @field name = contains(StringField); + // Legacy: skills passed as loaded `Skill` cards. Retained for back-compat. @field enabledSkills = linksToMany(Skill); @field disabledSkills = linksToMany(Skill); + // Skills passed by id (kind-agnostic): each id may name a `.md` skill file + // (`boxel.kind: skill`) or a legacy `Skill` card. Resolved via + // `loadSkillSource` at room creation. Preferred over the card fields above. + @field enabledSkillIds = containsMany(StringField); + @field disabledSkillIds = containsMany(StringField); @field llmMode = contains(StringField); // 'gpt-4o' or 'gpt-4o-mini' } diff --git a/packages/base/system-card.gts b/packages/base/system-card.gts index 2d39fe8e94b..c3c95f1878a 100644 --- a/packages/base/system-card.gts +++ b/packages/base/system-card.gts @@ -10,6 +10,7 @@ import { import BooleanField from './boolean'; import StringField from './string'; import enumField from './enum'; +import { MarkdownDef } from './markdown-file-def'; import { getMenuItems, rri } from '@cardstack/runtime-common'; import { type GetMenuItemParams } from './menu-items'; import { type MenuItemOptions, MenuItem } from '@cardstack/boxel-ui/helpers'; @@ -81,6 +82,16 @@ export class SystemCard extends CardDef { searchable: true, }); + // Skills enabled by default in new AI assistant rooms. These are `.md` skill + // files (`MarkdownDef` whose `boxel.kind: skill` frontmatter makes them a + // skill source). When set on the user's active system card, they replace the + // host's hardcoded default-skill list for new rooms. + @field defaultSkills = linksToMany(MarkdownDef, { + description: + 'Skills enabled by default in new AI assistant rooms (markdown skill files)', + searchable: true, + }); + [getMenuItems](params: GetMenuItemParams): MenuItemOptions[] { let menuItems = super[getMenuItems](params); menuItems = [ @@ -359,6 +370,15 @@ class SystemCardIsolated extends Component {
<@fields.defaultModelConfiguration /> <@fields.modelConfigurations /> + +
+

Default Skills

+

+ Skills enabled automatically in new AI assistant sessions when this + is your active system card. +

+ <@fields.defaultSkills @format='fitted' /> +
@@ -484,6 +504,22 @@ class SystemCardIsolated extends Component { .system-card-content { padding-top: var(--boxel-sp-sm); } + + .default-skills { + margin-top: var(--boxel-sp-lg); + } + + .section-heading { + margin: 0 0 var(--boxel-sp-4xs); + font-size: var(--boxel-font-size); + font-weight: 600; + } + + .section-hint { + margin: 0 0 var(--boxel-sp-sm); + font-size: var(--boxel-font-size-sm); + color: var(--boxel-500, #6b7280); + } } diff --git a/packages/host/app/commands/ask-ai.ts b/packages/host/app/commands/ask-ai.ts index 71b71eba3d7..927bccf3f87 100644 --- a/packages/host/app/commands/ask-ai.ts +++ b/packages/host/app/commands/ask-ai.ts @@ -37,14 +37,14 @@ export default class AskAiCommand extends HostBaseCommand< this.commandContext, ); - let [skills, openCards] = await Promise.all([ + let [skillIds, openCards] = await Promise.all([ this.matrixService.loadDefaultSkills('code') || Promise.resolve([]), this.operatorModeStateService.getOpenCards.perform() || Promise.resolve([]), ]); let { roomId } = await createRoomCommand.execute({ name: 'AI App Generator Assistant', - enabledSkills: skills, + enabledSkillIds: skillIds, llmMode: input.llmMode, }); await sendMessageCommand.execute({ diff --git a/packages/host/app/commands/create-ai-assistant-room.ts b/packages/host/app/commands/create-ai-assistant-room.ts index 6451f85e8d0..4986d243caa 100644 --- a/packages/host/app/commands/create-ai-assistant-room.ts +++ b/packages/host/app/commands/create-ai-assistant-room.ts @@ -9,19 +9,30 @@ import { DEFAULT_FALLBACK_MODEL_ID, } from '@cardstack/runtime-common/matrix-constants'; +import type { CardDef } from 'https://cardstack.com/base/card-api'; import type * as BaseCommandModule from 'https://cardstack.com/base/command'; import type { FileDef } from 'https://cardstack.com/base/file-api'; +import type * as SkillModule from 'https://cardstack.com/base/skill'; + +import { isSkillCard } from '../lib/file-def-manager'; import HostBaseCommand from '../lib/host-base-command'; +import { + getSkillSourceCommands, + loadSkillSource, + type SkillSource, +} from '../lib/skill-commands'; import type MatrixService from '../services/matrix-service'; +import type StoreService from '../services/store'; export default class CreateAiAssistantRoomCommand extends HostBaseCommand< typeof BaseCommandModule.CreateAIAssistantRoomInput, typeof BaseCommandModule.CreateAIAssistantRoomResult > { @service declare private matrixService: MatrixService; + @service declare private store: StoreService; static actionVerb = 'Create'; @@ -48,6 +59,83 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< return CreateAIAssistantRoomInput; } + // Collect skill ids from both the id fields (preferred, kind-agnostic) and + // the legacy loaded-`Skill`-card fields, de-duped. A skill listed as both + // enabled and disabled is treated as enabled. + private collectSkillIds( + input: BaseCommandModule.CreateAIAssistantRoomInput, + ): { enabledIds: string[]; disabledIds: string[] } { + let idsOf = ( + ids: string[] | undefined, + cards: SkillModule.Skill[] | undefined, + ): string[] => [ + ...(ids ?? []), + ...(cards ?? []) + .map((c) => c.id) + .filter((id): id is NonNullable => Boolean(id)), + ]; + + let enabledIds = Array.from( + new Set(idsOf(input.enabledSkillIds, input.enabledSkills)), + ); + let enabledSet = new Set(enabledIds); + let disabledIds = Array.from( + new Set(idsOf(input.disabledSkillIds, input.disabledSkills)), + ).filter((id) => !enabledSet.has(id)); + + return { enabledIds, disabledIds }; + } + + // Resolve skill ids to their room-skills-config file defs, uploading skill + // cards and `.md` skill files by their respective paths (mirrors the split in + // `UpdateRoomSkillsCommand`). Returns the uploaded FileDefs plus the resolved + // skill sources so the caller can gather commands. + private async resolveSkills(ids: string[]): Promise<{ + fileDefs: FileDef[]; + sources: SkillSource[]; + }> { + let skillCardsToUpload: SkillModule.Skill[] = []; + let markdownSkillsToUpload: FileDef[] = []; + let sources: SkillSource[] = []; + + await Promise.all( + ids.map(async (id) => { + try { + let source = await loadSkillSource(this.store, id); + if (!source) { + console.warn( + `[CreateAiAssistantRoomCommand] skipping skill "${id}": not a skill card or skill markdown file`, + ); + return; + } + sources.push(source); + if (isSkillCard in source) { + skillCardsToUpload.push(source as SkillModule.Skill); + } else { + markdownSkillsToUpload.push(source as FileDef); + } + } catch (e) { + console.warn( + `[CreateAiAssistantRoomCommand] skipping skill "${id}": ${e}`, + ); + } + }), + ); + + let fileDefs: FileDef[] = []; + if (skillCardsToUpload.length) { + fileDefs = fileDefs.concat( + await this.matrixService.uploadCards(skillCardsToUpload as CardDef[]), + ); + } + if (markdownSkillsToUpload.length) { + fileDefs = fileDefs.concat( + await this.matrixService.uploadFiles(markdownSkillsToUpload), + ); + } + return { fileDefs, sources }; + } + protected async run( input: BaseCommandModule.CreateAIAssistantRoomInput, ): Promise { @@ -60,28 +148,21 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< 'Requires userId to execute CreateAiAssistantRoomCommand', ); } - let { enabledSkills, disabledSkills } = input; - let enabledSkillFileDefs: FileDef[] | undefined; - let commandFileDefs: FileDef[] | undefined; - let disabledSkillFileDefs: FileDef[] | undefined; - - if (enabledSkills?.length) { - enabledSkillFileDefs = await matrixService.uploadCards(enabledSkills); - } - if (disabledSkills?.length) { - disabledSkillFileDefs = await matrixService.uploadCards(disabledSkills); - } else { - disabledSkillFileDefs = []; - } - const commandDefinitions = [ - ...(enabledSkills?.flatMap((skill) => skill.commands) || []), - ...(disabledSkills?.flatMap((skill) => skill.commands) || []), - ]; + let { enabledIds, disabledIds } = this.collectSkillIds(input); + let [enabled, disabled] = await Promise.all([ + this.resolveSkills(enabledIds), + this.resolveSkills(disabledIds), + ]); + let commandDefinitions = [...enabled.sources, ...disabled.sources].flatMap( + (source) => getSkillSourceCommands(source), + ); + let commandFileDefs: FileDef[] = []; if (commandDefinitions.length) { - commandFileDefs = - await matrixService.uploadCommandDefinitions(commandDefinitions); + commandFileDefs = await matrixService.uploadCommandDefinitions( + matrixService.getUniqueCommandDefinitions(commandDefinitions), + ); } // Run room creation and module loading in parallel @@ -118,18 +199,15 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< { type: APP_BOXEL_ROOM_SKILLS_EVENT_TYPE, content: { - enabledSkillCards: - enabledSkillFileDefs?.map((skillFileDef) => - skillFileDef.serialize(), - ) ?? [], - disabledSkillCards: - disabledSkillFileDefs?.map((skillFileDef) => - skillFileDef.serialize(), - ) ?? [], - commandDefinitions: - commandFileDefs?.map((commandFileDef) => - commandFileDef.serialize(), - ) ?? [], + enabledSkillCards: enabled.fileDefs.map((fileDef) => + fileDef.serialize(), + ), + disabledSkillCards: disabled.fileDefs.map((fileDef) => + fileDef.serialize(), + ), + commandDefinitions: commandFileDefs.map((commandFileDef) => + commandFileDef.serialize(), + ), }, }, ], diff --git a/packages/host/app/lib/utils.ts b/packages/host/app/lib/utils.ts index 7faee7a0112..f056af1a059 100644 --- a/packages/host/app/lib/utils.ts +++ b/packages/host/app/lib/utils.ts @@ -84,5 +84,17 @@ export function skillCardURL(skillId: string): string { return `@cardstack/skills/Skill/${skillId}`; } +/** + * Constructs a universal @cardstack/skills/ reference to a `.md` skill file + * (`skills//SKILL.md`) — the markdown skill form, resolved as a + * `MarkdownDef` whose `boxel.kind: skill` frontmatter makes it a skill source. + * + * @example + * skillFileURL('source-code-editing') // '@cardstack/skills/skills/source-code-editing/SKILL.md' + */ +export function skillFileURL(skillName: string): string { + return `@cardstack/skills/skills/${skillName}/SKILL.md`; +} + export const devSkillId = `@cardstack/skills/${devSkillLocalPath}`; export const envSkillId = `@cardstack/skills/${envSkillLocalPath}`; diff --git a/packages/host/app/services/ai-assistant-panel-service.ts b/packages/host/app/services/ai-assistant-panel-service.ts index 43e83a37769..51aa5de192c 100644 --- a/packages/host/app/services/ai-assistant-panel-service.ts +++ b/packages/host/app/services/ai-assistant-panel-service.ts @@ -22,11 +22,10 @@ import type { CardDef, Format } from 'https://cardstack.com/base/card-api'; import type * as CommandModule from 'https://cardstack.com/base/command'; import type { FileDef } from 'https://cardstack.com/base/file-api'; -import type { Skill as SkillCard } from 'https://cardstack.com/base/skill'; - import CreateAiAssistantRoomCommand from '../commands/create-ai-assistant-room'; - import SummarizeSessionCommand from '../commands/summarize-session'; +import UpdateRoomSkillsCommand from '../commands/update-room-skills'; + import { Submodes } from '../components/submode-switcher'; import { isMatrixError } from '../lib/matrix-utils'; import { importResource } from '../resources/import'; @@ -293,46 +292,22 @@ export default class AiAssistantPanelService extends Service { ); } - private async extractSkillsFromCurrentRoom(): Promise<{ - enabledSkills: SkillCard[]; - disabledSkills: SkillCard[]; - }> { - let enabledSkills: SkillCard[] = []; - let disabledSkills: SkillCard[] = []; - - if (this.currentRoomResource?.matrixRoom?.skillsConfig) { - const skillConfig = this.currentRoomResource.matrixRoom.skillsConfig; - - // Extract enabled skills from the current room - if (skillConfig.enabledSkillCards?.length) { - for (const fileDef of skillConfig.enabledSkillCards) { - try { - const skill = await this.store.get(fileDef.sourceUrl); - if (skill && isCardInstance(skill)) { - enabledSkills.push(skill as SkillCard); - } - } catch (e) { - console.warn(`Failed to load skill from ${fileDef.sourceUrl}:`, e); - } - } - } - - // Extract disabled skills from the current room - if (skillConfig.disabledSkillCards?.length) { - for (const fileDef of skillConfig.disabledSkillCards) { - try { - const skill = await this.store.get(fileDef.sourceUrl); - if (skill && isCardInstance(skill)) { - disabledSkills.push(skill as SkillCard); - } - } catch (e) { - console.warn(`Failed to load skill from ${fileDef.sourceUrl}:`, e); - } - } - } - } - - return { enabledSkills, disabledSkills }; + // The current room's skills as ids (the fileDefs' sourceUrls), for the + // "add same skills" flow. Id-based so it carries both `.md` skill files and + // legacy `Skill` cards; room creation re-resolves each id kind-agnostically. + private extractSkillIdsFromCurrentRoom(): { + enabledSkillIds: string[]; + disabledSkillIds: string[]; + } { + let skillConfig = this.currentRoomResource?.matrixRoom?.skillsConfig; + let toIds = (fileDefs: { sourceUrl?: string }[] | undefined): string[] => + (fileDefs ?? []) + .map((fileDef) => fileDef.sourceUrl) + .filter((id): id is string => Boolean(id)); + return { + enabledSkillIds: toIds(skillConfig?.enabledSkillCards), + disabledSkillIds: toIds(skillConfig?.disabledSkillCards), + }; } private getPreferredLLMMode(): LLMMode | undefined { @@ -433,21 +408,20 @@ export default class AiAssistantPanelService extends Service { if (llmMode) { input.llmMode = llmMode; } - let enabledSkills: SkillCard[] = []; - let disabledSkills: SkillCard[] = []; + let enabledSkillIds: string[] = []; + let disabledSkillIds: string[] = []; if (addSameSkills) { - const extractedSkills = await this.extractSkillsFromCurrentRoom(); - enabledSkills = extractedSkills.enabledSkills; - disabledSkills = extractedSkills.disabledSkills; + ({ enabledSkillIds, disabledSkillIds } = + this.extractSkillIdsFromCurrentRoom()); } - if (enabledSkills.length || disabledSkills.length) { - input.enabledSkills = enabledSkills; - input.disabledSkills = disabledSkills; + if (enabledSkillIds.length || disabledSkillIds.length) { + input.enabledSkillIds = enabledSkillIds; + input.disabledSkillIds = disabledSkillIds; } else { - // Use default skills - input.enabledSkills = await this.matrixService.loadDefaultSkills( + // Use default skills (ids; may name `.md` skill files or cards) + input.enabledSkillIds = await this.matrixService.loadDefaultSkills( this.operatorModeStateService.state.submode, ); } @@ -541,25 +515,22 @@ export default class AiAssistantPanelService extends Service { private async applyDefaultSkillsToRoom(roomId: string) { try { - let skills = await this.matrixService.loadDefaultSkills( + let skillIds = await this.matrixService.loadDefaultSkills( this.operatorModeStateService.state.submode, ); - if (!skills.length) { + if (!skillIds.length) { return; } - let enabledSkillFileDefs = await this.matrixService.uploadCards(skills); - let commandDefinitions = skills.flatMap((skill) => skill.commands); - let commandFileDefs = - await this.matrixService.uploadCommandDefinitions(commandDefinitions); - await this.matrixService.sendStateEvent( - roomId, - APP_BOXEL_ROOM_SKILLS_EVENT_TYPE, - { - enabledSkillCards: enabledSkillFileDefs.map((fd) => fd.serialize()), - disabledSkillCards: [], - commandDefinitions: commandFileDefs.map((fd) => fd.serialize()), - }, + // Kind-agnostic: `UpdateRoomSkillsCommand` resolves each id to a `.md` + // skill file or a legacy `Skill` card, uploads it, and populates the + // room's skills config + command definitions. + let updateRoomSkillsCommand = new UpdateRoomSkillsCommand( + this.commandService.commandContext, ); + await updateRoomSkillsCommand.execute({ + roomId, + skillCardIdsToActivate: skillIds, + }); } catch (e) { console.error('Failed to apply default skills to room:', e); } diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index a813af53326..98c7212b120 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -31,7 +31,6 @@ import { aiBotUsername, submissionBotUsername, logger, - isCardInstance, Deferred, ri, SEARCH_MARKER, @@ -120,7 +119,7 @@ import { addPatchTools } from '../commands/utils'; import { getUniqueValidCommandDefinitions } from '../lib/command-definitions'; import { isSkillCard } from '../lib/file-def-manager'; import { getSkillSourceCommands, loadSkillSource } from '../lib/skill-commands'; -import { skillCardURL, devSkillId, envSkillId } from '../lib/utils'; +import { skillFileURL, devSkillId, envSkillId } from '../lib/utils'; import { importResource } from '../resources/import'; import { getRoom } from '../resources/room'; @@ -1798,31 +1797,29 @@ export default class MatrixService extends Service { } } - async loadDefaultSkills(submode: Submode) { - let interactModeDefaultSkills = [envSkillId]; + // The default skills for a new AI room, as skill ids. When the user's active + // system card lists `defaultSkills`, those win (mode-agnostic). Otherwise we + // fall back to the hardcoded, submode-aware set. Ids may name a `.md` skill + // file or a legacy `Skill` card; callers resolve them kind-agnostically via + // `loadSkillSource`. + async loadDefaultSkills(submode: Submode): Promise { + let configuredIds = (this.systemCard?.defaultSkills ?? []) + .map((skill) => skill?.id) + .filter((id): id is NonNullable => Boolean(id)); + if (configuredIds.length) { + return configuredIds; + } + let interactModeDefaultSkills = [envSkillId]; let codeModeDefaultSkills = [ devSkillId, envSkillId, - skillCardURL('source-code-editing'), + skillFileURL('source-code-editing'), ]; - let defaultSkills; - - if (submode === 'code') { - defaultSkills = codeModeDefaultSkills; - } else { - defaultSkills = interactModeDefaultSkills; - } - - return ( - await Promise.all( - defaultSkills.map(async (skillCardURL) => { - let maybeCard = await this.store.get(skillCardURL); - return isCardInstance(maybeCard) ? maybeCard : undefined; - }), - ) - ).filter(Boolean) as SkillModule.Skill[]; + return submode === 'code' + ? codeModeDefaultSkills + : interactModeDefaultSkills; } @cached @@ -2687,10 +2684,10 @@ export default class MatrixService extends Service { let updateRoomSkillsCommand = new UpdateRoomSkillsCommand( this.commandService.commandContext, ); - let defaultSkills = await this.loadDefaultSkills('code'); + let defaultSkillIds = await this.loadDefaultSkills('code'); await updateRoomSkillsCommand.execute({ roomId: this.currentRoomId, - skillCardIdsToActivate: defaultSkills.map((s) => s.id), + skillCardIdsToActivate: defaultSkillIds, }); } diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index ad1496f45fd..82b1282562d 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -123,6 +123,7 @@ export { catalogRealmURL, skillsRealmURL, skillCardURL, + skillFileURL, devSkillId, envSkillId, } from '@cardstack/host/lib/utils'; diff --git a/packages/host/tests/integration/commands/load-default-skills-test.gts b/packages/host/tests/integration/commands/load-default-skills-test.gts new file mode 100644 index 00000000000..b196e49b31c --- /dev/null +++ b/packages/host/tests/integration/commands/load-default-skills-test.gts @@ -0,0 +1,104 @@ +import { getOwner } from '@ember/owner'; +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import RealmService from '@cardstack/host/services/realm'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmInfo, + testRealmURL, + setupRealmCacheTeardown, + withCachedRealmSetup, + devSkillId, + envSkillId, + skillFileURL, +} from '../../helpers'; +import { setupBaseRealm } from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +class StubRealmService extends RealmService { + get defaultReadableRealm() { + return { + path: testRealmURL, + info: testRealmInfo, + }; + } +} + +module('Integration | commands | load-default-skills', function (hooks) { + setupRenderingTest(hooks); + setupLocalIndexing(hooks); + setupBaseRealm(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + }); + + hooks.beforeEach(function (this: RenderingTestContext) { + getOwner(this)!.register('service:realm', StubRealmService); + }); + + setupRealmCacheTeardown(hooks); + + hooks.beforeEach(async function () { + await withCachedRealmSetup(async () => + setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }), + ); + }); + + test('falls back to the hardcoded .md default skills when no system card is set', async function (assert) { + let matrixService = getService('matrix-service') as any; + matrixService._systemCard = undefined; + + assert.deepEqual( + await matrixService.loadDefaultSkills('code'), + [devSkillId, envSkillId, skillFileURL('source-code-editing')], + 'code mode falls back to the dev/env/source-code-editing .md skills', + ); + assert.deepEqual( + await matrixService.loadDefaultSkills('interact'), + [envSkillId], + 'interact mode falls back to the env .md skill', + ); + }); + + test('falls back when the system card lists no default skills', async function (assert) { + let matrixService = getService('matrix-service') as any; + matrixService._systemCard = { defaultSkills: [] }; + + assert.deepEqual( + await matrixService.loadDefaultSkills('interact'), + [envSkillId], + 'an empty defaultSkills list falls through to the hardcoded default', + ); + }); + + test("uses the system card's default skills (mode-agnostic) when set", async function (assert) { + let matrixService = getService('matrix-service') as any; + let skillA = `${testRealmURL}skills/my-skill/SKILL.md`; + let skillB = `${testRealmURL}skills/another-skill/SKILL.md`; + matrixService._systemCard = { + defaultSkills: [{ id: skillA }, { id: skillB }], + }; + + assert.deepEqual( + await matrixService.loadDefaultSkills('code'), + [skillA, skillB], + 'configured skills win in code mode', + ); + assert.deepEqual( + await matrixService.loadDefaultSkills('interact'), + [skillA, skillB], + 'the same configured skills apply in interact mode (mode-agnostic)', + ); + }); +}); diff --git a/packages/matrix/tests/skills.spec.ts b/packages/matrix/tests/skills.spec.ts index 80527850d3d..3467b7d74d0 100644 --- a/packages/matrix/tests/skills.spec.ts +++ b/packages/matrix/tests/skills.spec.ts @@ -50,10 +50,11 @@ test.describe('Skills', () => { ).toContainClass('checked'); } - const environmentSkillCardId = `@cardstack/skills/Skill/boxel-environment`; + const environmentSkillCardId = `@cardstack/skills/skills/boxel-environment/SKILL.md`; const defaultSkillCardsForCodeMode = [ - `@cardstack/skills/Skill/boxel-development`, + `@cardstack/skills/skills/boxel/SKILL.md`, environmentSkillCardId, + `@cardstack/skills/skills/source-code-editing/SKILL.md`, ]; const skillCard1 = `${appURL}/skill-pirate-speak`; const skillCard2 = `${appURL}/skill-seo`; diff --git a/packages/runtime-common/constants.ts b/packages/runtime-common/constants.ts index b62124f6d21..c87bf9639d4 100644 --- a/packages/runtime-common/constants.ts +++ b/packages/runtime-common/constants.ts @@ -28,8 +28,11 @@ export function baseRRI(path: string): RealmResourceIdentifier { return rri(`${baseRealmRRI}${path}`); } -export const devSkillLocalPath = 'Skill/boxel-development'; -export const envSkillLocalPath = 'Skill/boxel-environment'; +// Default skills for new AI rooms, as `.md` skill files (`boxel.kind: skill`). +// The legacy `Skill/*.json` cards still exist but the default-skill path now +// resolves these markdown skills instead. +export const devSkillLocalPath = 'skills/boxel/SKILL.md'; +export const envSkillLocalPath = 'skills/boxel-environment/SKILL.md'; export const baseRef: ResolvedCodeRef = { module: `${baseRealmRRI}card-api` as RealmResourceIdentifier,