From e3f41f9270911de8c705014cd01d54e1fe3badb1 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 3 Aug 2026 13:22:02 +0200 Subject: [PATCH 1/4] Make the skills index the default skill for new AI rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skills realm's index.md is the pull-model entry point: its body names every skill and command the assistant can reach, short enough to push into every room, so a room needs no other skill up front. It replaces the pair of legacy Skill cards the host used to push, and it applies in every submode — entering code mode no longer runs a second activation pass to add the coding skill and source-code-editing on top. Also fixes an ordering bug this exposed in updateSkillsAndToolsIfNeeded: re-uploading split the enabled skills by kind and concatenated the buckets, so any room holding both a skill card and a .md skill had its list reordered on every message send, writing a state event that changed nothing but the sequence. CS-12343 Co-Authored-By: Claude Opus 5 (1M context) --- .../operator-mode/create-listing-modal.gts | 2 +- packages/host/app/lib/utils.ts | 12 +-- .../services/ai-assistant-panel-service.ts | 9 +-- packages/host/app/services/matrix-service.ts | 74 +++++++------------ .../services/operator-mode-state-service.ts | 6 +- packages/host/app/tools/show-file.ts | 2 +- packages/host/app/tools/switch-submode.ts | 2 +- .../tests/acceptance/ai-assistant-test.gts | 8 +- packages/host/tests/helpers/index.gts | 2 +- .../ai-assistant-panel/skills-test.gts | 59 ++++++++------- .../ai-assistant-panel/tools-test.gts | 34 +++++++-- .../tools/load-default-skills-test.gts | 35 +++------ .../matrix/support/isolated-realm-server.ts | 1 + packages/matrix/tests/skills.spec.ts | 53 +++---------- packages/runtime-common/constants.ts | 17 +++-- 15 files changed, 135 insertions(+), 181 deletions(-) diff --git a/packages/host/app/components/operator-mode/create-listing-modal.gts b/packages/host/app/components/operator-mode/create-listing-modal.gts index 1eb65c29779..7bbe0f39cbc 100644 --- a/packages/host/app/components/operator-mode/create-listing-modal.gts +++ b/packages/host/app/components/operator-mode/create-listing-modal.gts @@ -254,7 +254,7 @@ export default class CreateListingModal extends Component { if (this.operatorModeStateService.workspaceChooserOpened) { this.operatorModeStateService.closeWorkspaceChooser(); } - await this.operatorModeStateService.updateSubmode(Submodes.Code); + this.operatorModeStateService.updateSubmode(Submodes.Code); await this.operatorModeStateService.updateCodePath( new URL(cardUrl + '.json'), 'preview', diff --git a/packages/host/app/lib/utils.ts b/packages/host/app/lib/utils.ts index 31f9beda831..4398d361335 100644 --- a/packages/host/app/lib/utils.ts +++ b/packages/host/app/lib/utils.ts @@ -4,7 +4,7 @@ import { realmURL, ensureTrailingSlash, devSkillLocalPath, - envSkillLocalPath, + skillsIndexLocalPath, } from '@cardstack/runtime-common'; export { iconURLFor, @@ -97,8 +97,10 @@ export function skillFileURL(skillName: string): string { } export const devSkillId = `@cardstack/skills/${devSkillLocalPath}`; -export const envSkillId = `@cardstack/skills/${envSkillLocalPath}`; -// The markdown-first source-code-editing skill, enabled directly in code -// mode alongside the card defaults. -export const sourceCodeEditingSkillUrl = `${skillsRealmURL}skills/source-code-editing/SKILL.md`; +// The skills index, the default skill for every new AI room. Spelled as a +// resolved absolute URL rather than an `@cardstack/skills/` reference because +// the index routes to its siblings entirely through document-relative markdown +// links: the prompt resolves those against the skill's own id, and only an +// absolute id can anchor that resolution. +export const skillsIndexId = `${skillsRealmURL}${skillsIndexLocalPath}`; diff --git a/packages/host/app/services/ai-assistant-panel-service.ts b/packages/host/app/services/ai-assistant-panel-service.ts index 3fd0bd592b4..04ec9acb3e3 100644 --- a/packages/host/app/services/ai-assistant-panel-service.ts +++ b/packages/host/app/services/ai-assistant-panel-service.ts @@ -444,9 +444,8 @@ export default class AiAssistantPanelService extends Service { input.disabledSkillIds = disabledSkillIds; } else { // Use default skills (ids; may name `.md` skill files or cards) - input.enabledSkillIds = await this.matrixService.loadDefaultSkills( - this.operatorModeStateService.state.submode, - ); + input.enabledSkillIds = + await this.matrixService.loadDefaultSkills(); } ({ roomId } = await createRoomCommand.execute(input)); @@ -538,9 +537,7 @@ export default class AiAssistantPanelService extends Service { private async applyDefaultSkillsToRoom(roomId: string) { try { - let skillIds = await this.matrixService.loadDefaultSkills( - this.operatorModeStateService.state.submode, - ); + let skillIds = await this.matrixService.loadDefaultSkills(); if (!skillIds.length) { return; } diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index c3fca19e5a9..ae9ae56a485 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -84,10 +84,7 @@ import { APP_BOXEL_SYSTEM_CARD_EVENT_TYPE, } from '@cardstack/runtime-common/matrix-constants'; -import { - type Submode, - Submodes, -} from '@cardstack/host/components/submode-switcher'; +import { Submodes } from '@cardstack/host/components/submode-switcher'; import ENV from '@cardstack/host/config/environment'; import type IndexController from '@cardstack/host/controllers/index'; @@ -101,15 +98,10 @@ import { clearLocalStorage } from '@cardstack/host/utils/local-storage-keys'; import { isSkillCard } from '../lib/file-def-manager'; import { getSkillSourceTools, loadSkillSource } from '../lib/skill-tools'; import { getUniqueValidToolDefinitions } from '../lib/tool-definitions'; -import { - sourceCodeEditingSkillUrl, - devSkillId, - envSkillId, -} from '../lib/utils'; +import { skillsIndexId } from '../lib/utils'; import { importResource } from '../resources/import'; import { getRoom } from '../resources/room'; -import UpdateRoomSkillsTool from '../tools/update-room-skills'; import { addPatchTools } from '../tools/utils'; import type CardService from './card-service'; @@ -1577,6 +1569,19 @@ export default class MatrixService extends Service { let enabledMarkdownSkillFileDefs = markdownSkillFileDefs.length ? await this.uploadFiles(markdownSkillFileDefs) : []; + // Re-emit the skills in the order the room already had them. Uploading + // splits them by kind, so concatenating the two buckets would reorder + // any room holding both a skill card and a `.md` skill — a rewrite that + // changes nothing but the sequence, which still writes a new state + // event on every send. Skills that no longer load drop out, as before. + let reuploadedBySourceUrl = new Map( + [...enabledSkillFileDefs, ...enabledMarkdownSkillFileDefs].map( + (fileDef) => [fileDef.sourceUrl, fileDef], + ), + ); + let orderedSkillFileDefs = enabledSkillCardFileDefs + .map((fileDef) => reuploadedBySourceUrl.get(fileDef.sourceUrl)) + .filter((fileDef): fileDef is FileDef => Boolean(fileDef)); // get the unique subset of enabledCommandDefinitions by functionName enabledCommandDefinitions = this.getUniqueToolDefinitions( enabledCommandDefinitions, @@ -1585,10 +1590,9 @@ export default class MatrixService extends Service { enabledCommandDefinitions, ); return { - enabledSkillCards: [ - ...enabledSkillFileDefs, - ...enabledMarkdownSkillFileDefs, - ].map((fileDef) => fileDef.serialize()), + enabledSkillCards: orderedSkillFileDefs.map((fileDef) => + fileDef.serialize(), + ), disabledSkillCards: currentSkillsConfig?.disabledSkillCards ?? [], toolDefinitions: enabledCommandDefFileDefs.map((fileDef) => fileDef.serialize(), @@ -2130,10 +2134,13 @@ export default class MatrixService extends Service { // The default skills for a new AI room, as skill ids. When the user's active // system card lists any default skills — legacy `Skill` cards, `.md` skill - // files, or both — 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 { + // files, or both — those win. Otherwise the room gets the skills index, whose + // body names everything the model can then pull on demand. Either way the set + // does not depend on the submode: the index covers coding and runtime work + // alike, so entering code mode needs no second activation pass. Ids may name + // a `.md` skill file or a legacy `Skill` card; callers resolve them + // kind-agnostically via `loadSkillSource`. + async loadDefaultSkills(): Promise { let configuredIds = [ ...(this.systemCard?.defaultSkillCards ?? []), ...(this.systemCard?.defaultSkillFiles ?? []), @@ -2144,18 +2151,7 @@ export default class MatrixService extends Service { return configuredIds; } - let interactModeDefaultSkills = [envSkillId]; - - // Code editing is covered by the code-mode entry-point skill (see - // activateCodingSkill), so source-code-editing is no longer pushed here. - // The two remaining defaults are still legacy pushed cards (full body in - // every prompt); they move to markdown + on-demand references once the - // bot supports commands on markdown skills, after which this list shrinks. - let codeModeDefaultSkills = [devSkillId, envSkillId]; - - return submode === 'code' - ? codeModeDefaultSkills - : interactModeDefaultSkills; + return [skillsIndexId]; } @cached @@ -3053,24 +3049,6 @@ export default class MatrixService extends Service { this.localPersistenceService.setCurrentRoomId(undefined); } - async activateCodingSkill() { - if (!this.currentRoomId) { - return; - } - - let updateRoomSkillsCommand = new UpdateRoomSkillsTool( - this.toolService.toolContext, - ); - let defaultSkillIds = await this.loadDefaultSkills('code'); - await updateRoomSkillsCommand.execute({ - roomId: this.currentRoomId, - // Dual-path window: the legacy card skills activate alongside the - // markdown source-code-editing skill. All are pushed for now; the - // on-demand entry point returns as a catalog listing. - skillCardIdsToActivate: [...defaultSkillIds, sourceCodeEditingSkillUrl], - }); - } - loadMoreAIRooms() { this.loadMoreAIRoomsTask.perform(); } diff --git a/packages/host/app/services/operator-mode-state-service.ts b/packages/host/app/services/operator-mode-state-service.ts index 8ab15930bde..99d21a382b2 100644 --- a/packages/host/app/services/operator-mode-state-service.ts +++ b/packages/host/app/services/operator-mode-state-service.ts @@ -749,13 +749,9 @@ export default class OperatorModeStateService extends Service { return this.schedulePersist(); } - async updateSubmode(submode: Submode) { + updateSubmode(submode: Submode) { this._state.submode = submode; this.schedulePersist(); - - if (submode === Submodes.Code) { - await this.matrixService.activateCodingSkill(); - } } async updateModuleInspectorView(view: ModuleInspectorView) { diff --git a/packages/host/app/tools/show-file.ts b/packages/host/app/tools/show-file.ts index 0d774b51220..16187fc0197 100644 --- a/packages/host/app/tools/show-file.ts +++ b/packages/host/app/tools/show-file.ts @@ -33,7 +33,7 @@ export default class ShowFileTool extends HostBaseTool< await operatorModeStateService.updateCodePath( new URL(input.fileIdentifier), ); - await operatorModeStateService.updateSubmode('code'); + operatorModeStateService.updateSubmode('code'); } } diff --git a/packages/host/app/tools/switch-submode.ts b/packages/host/app/tools/switch-submode.ts index d91b16cbaa0..a9ddb4500e5 100644 --- a/packages/host/app/tools/switch-submode.ts +++ b/packages/host/app/tools/switch-submode.ts @@ -99,7 +99,7 @@ export default class SwitchSubmodeTool extends HostBaseTool< throw new Error(`invalid submode specified: ${input.submode}`); } - await this.operatorModeStateService.updateSubmode(input.submode); + this.operatorModeStateService.updateSubmode(input.submode); if (this.operatorModeStateService.workspaceChooserOpened) { this.operatorModeStateService.closeWorkspaceChooser(); } diff --git a/packages/host/tests/acceptance/ai-assistant-test.gts b/packages/host/tests/acceptance/ai-assistant-test.gts index db9821f7e4d..3fbed20809c 100644 --- a/packages/host/tests/acceptance/ai-assistant-test.gts +++ b/packages/host/tests/acceptance/ai-assistant-test.gts @@ -51,7 +51,7 @@ import { type TestContextWithSave, delay, getMonacoContent, - envSkillId, + skillsIndexId, catalogRealmURL, realmConfigCardJSON, } from '../helpers'; @@ -3072,7 +3072,7 @@ module('Acceptance | AI Assistant tests', function (hooks) { ).map((el) => el.getAttribute('data-test-attached-card')); assert.ok( false, - `Default skill never rendered on the new session's skill menu. Attached cards seen: ${JSON.stringify(attached)}; expected exactly [${envSkillId}].`, + `Default skill never rendered on the new session's skill menu. Attached cards seen: ${JSON.stringify(attached)}; expected exactly [${skillsIndexId}].`, ); return; } @@ -3080,7 +3080,9 @@ module('Acceptance | AI Assistant tests', function (hooks) { .dom('[data-test-skill-menu] [data-test-attached-card]') .exists({ count: 1 }); assert - .dom(`[data-test-skill-menu] [data-test-attached-card="${envSkillId}"]`) + .dom( + `[data-test-skill-menu] [data-test-attached-card="${skillsIndexId}"]`, + ) .exists(); }); diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index 073cbb06a1c..c656ba0eadd 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -121,7 +121,7 @@ export { skillCardURL, skillFileURL, devSkillId, - envSkillId, + skillsIndexId, } from '@cardstack/host/lib/utils'; const { sqlSchema } = ENV; diff --git a/packages/host/tests/integration/components/ai-assistant-panel/skills-test.gts b/packages/host/tests/integration/components/ai-assistant-panel/skills-test.gts index 890abd7cc8b..8f9689079ef 100644 --- a/packages/host/tests/integration/components/ai-assistant-panel/skills-test.gts +++ b/packages/host/tests/integration/components/ai-assistant-panel/skills-test.gts @@ -10,6 +10,7 @@ import { REPLACE_MARKER, SEARCH_MARKER, SEPARATOR_MARKER, + buildToolFunctionNameFromResolvedRef, rri, skillCardRef, } from '@cardstack/runtime-common'; @@ -27,7 +28,7 @@ import type OperatorModeStateService from '@cardstack/host/services/operator-mod import { addSkillToAiAssistant, - envSkillId, + skillsIndexId, testRealmURL, setupCardLogs, setupIntegrationTestRealm, @@ -58,6 +59,14 @@ import type { FileDef } from '@cardstack/base/file-api'; module('Integration | ai-assistant-panel | skills', function (hooks) { const realmName = 'Operator Mode Workspace'; + // The tool `Skill/example` declares. A room's default skill is the skills + // index, which carries no tools of its own, so an applied tool call has to + // name one an attached skill actually declares — anything else is rejected + // as an unrecognized tool before the room's skills are re-uploaded. + const exampleSkillToolName = buildToolFunctionNameFromResolvedRef({ + module: `${testRealmURL}search-and-open-card-command`, + name: 'default', + }); let loader: Loader; let operatorModeStateService: OperatorModeStateService; @@ -880,10 +889,10 @@ Instructions live in the markdown body. // different serialization. sourceUrl is the stable identifier. assert.strictEqual( finalRoomStateSkillsJson.enabledSkillCards.find( - (c: FileDef) => c.sourceUrl === envSkillId, + (c: FileDef) => c.sourceUrl === skillsIndexId, ).sourceUrl, initialRoomStateSkillsJson.enabledSkillCards.find( - (c: FileDef) => c.sourceUrl === envSkillId, + (c: FileDef) => c.sourceUrl === skillsIndexId, ).sourceUrl, 'unchanged skill card is still present', ); @@ -943,7 +952,7 @@ Instructions live in the markdown body. [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: '721c8c78-d8c1-4cc1-a7e9-51d2d3143e4d', - name: 'SearchCardsByTypeAndTitleCommand_a959', + name: exampleSkillToolName, arguments: JSON.stringify({ attributes: { cardDescription: 'Searching for card', @@ -975,10 +984,10 @@ Instructions live in the markdown body. // different serialization. sourceUrl is the stable identifier. assert.strictEqual( finalRoomStateSkillsJson.enabledSkillCards.find( - (c: FileDef) => c.sourceUrl === envSkillId, + (c: FileDef) => c.sourceUrl === skillsIndexId, ).sourceUrl, initialRoomStateSkillsJson.enabledSkillCards.find( - (c: FileDef) => c.sourceUrl === envSkillId, + (c: FileDef) => c.sourceUrl === skillsIndexId, ).sourceUrl, 'unchanged skill card is still present', ); @@ -1063,10 +1072,10 @@ ${REPLACE_MARKER} // different serialization. sourceUrl is the stable identifier. assert.strictEqual( finalRoomStateSkillsJson.enabledSkillCards.find( - (c: FileDef) => c.sourceUrl === envSkillId, + (c: FileDef) => c.sourceUrl === skillsIndexId, ).sourceUrl, initialRoomStateSkillsJson.enabledSkillCards.find( - (c: FileDef) => c.sourceUrl === envSkillId, + (c: FileDef) => c.sourceUrl === skillsIndexId, ).sourceUrl, 'unchanged skill card is still present', ); @@ -1124,17 +1133,6 @@ ${REPLACE_MARKER} ); await settled(); - const afterCodeModeRoomStateSkillsJson = getRoomState( - roomId, - APP_BOXEL_ROOM_SKILLS_EVENT_TYPE, - ); - - assert.notDeepEqual( - afterCodeModeRoomStateSkillsJson, - initialRoomStateSkillsJson, - 'room state has changed to reference new skill card events', - ); - await click('[data-test-submode-switcher] button'); await click('[data-test-boxel-menu-item-text="Interact"]'); @@ -1142,8 +1140,11 @@ ${REPLACE_MARKER} await click('[data-test-send-message-btn]'); await waitFor('[data-test-message-idx]'); + // Editing the command source and moving between submodes leave the room's + // skills alone — the defaults do not vary by submode — so the state taken + // before the edit is still the pre-send baseline. let expectedCommandDefinitionCount = - afterCodeModeRoomStateSkillsJson.toolDefinitions?.length ?? 0; + initialRoomStateSkillsJson.toolDefinitions?.length ?? 0; await waitUntil( () => { @@ -1170,7 +1171,7 @@ ${REPLACE_MARKER} ); return ( JSON.stringify(skillsState?.toolDefinitions) !== - JSON.stringify(afterCodeModeRoomStateSkillsJson.toolDefinitions) + JSON.stringify(initialRoomStateSkillsJson.toolDefinitions) ); }, { @@ -1189,8 +1190,8 @@ ${REPLACE_MARKER} expectedCommandDefinitionCount ) { console.log( - `command definition count mismatch: afterCodeModeRoomStateSkills:\n${JSON.stringify( - afterCodeModeRoomStateSkillsJson, + `command definition count mismatch: initialRoomStateSkills:\n${JSON.stringify( + initialRoomStateSkillsJson, null, 2, )}\nfinalRoomStateSkillsJson:\n${JSON.stringify( @@ -1205,32 +1206,30 @@ ${REPLACE_MARKER} // to async linksTo relationship loading. Compare sourceUrls instead. assert.deepEqual( finalRoomStateSkillsJson.enabledSkillCards.map((c: any) => c.sourceUrl), - afterCodeModeRoomStateSkillsJson.enabledSkillCards.map( - (c: any) => c.sourceUrl, - ), + initialRoomStateSkillsJson.enabledSkillCards.map((c: any) => c.sourceUrl), 'enabled skill cards are the same', ); assert.deepEqual( finalRoomStateSkillsJson.disabledSkillCards.map((c: any) => c.sourceUrl), - afterCodeModeRoomStateSkillsJson.disabledSkillCards.map( + initialRoomStateSkillsJson.disabledSkillCards.map( (c: any) => c.sourceUrl, ), 'disabled skill cards are the same', ); assert.notDeepEqual( finalRoomStateSkillsJson.toolDefinitions, - afterCodeModeRoomStateSkillsJson.toolDefinitions, + initialRoomStateSkillsJson.toolDefinitions, 'command definitions are different', ); let baselineUnchangedCommandDefinitions = - afterCodeModeRoomStateSkillsJson.toolDefinitions.filter( + initialRoomStateSkillsJson.toolDefinitions.filter( (cmd: any) => cmd.sourceUrl !== `${testRealmURL}search-and-open-card-command/default`, ); let baselineChangedCommandDefinitions = - afterCodeModeRoomStateSkillsJson.toolDefinitions.filter( + initialRoomStateSkillsJson.toolDefinitions.filter( (cmd: any) => cmd.sourceUrl === `${testRealmURL}search-and-open-card-command/default`, diff --git a/packages/host/tests/integration/components/ai-assistant-panel/tools-test.gts b/packages/host/tests/integration/components/ai-assistant-panel/tools-test.gts index fe8b5e42e2c..918994378d5 100644 --- a/packages/host/tests/integration/components/ai-assistant-panel/tools-test.gts +++ b/packages/host/tests/integration/components/ai-assistant-panel/tools-test.gts @@ -6,7 +6,10 @@ import { getService } from '@universal-ember/test-support'; import { module, skip, test } from 'qunit'; -import { skillCardRef } from '@cardstack/runtime-common'; +import { + buildToolFunctionNameFromResolvedRef, + skillCardRef, +} from '@cardstack/runtime-common'; import type { Loader } from '@cardstack/runtime-common/loader'; import { @@ -54,6 +57,14 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { const realmName = 'Operator Mode Workspace'; const readOnlyRealmName = 'Read Only Workspace'; const readOnlyRealmURL = 'http://test-realm/read-only/'; + // The card-search tool as the `Skill/boxel-environment` fixture below + // declares it. A room's default skill carries no tools, so any test applying + // this tool has to attach that skill first, and name the tool the way the + // fixture spells it — the function name is a hash of `module#name`. + const searchCardsToolName = buildToolFunctionNameFromResolvedRef({ + module: '@cardstack/boxel-host/commands/search-cards', + name: 'SearchCardsByTypeAndTitleTool', + }); let loader: Loader; let operatorModeStateService: OperatorModeStateService; @@ -781,6 +792,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { test('it can search for card instances that is of the same card type as the card shared', async function (assert) { let id = `${testRealmURL}Pet/mango.json`; let roomId = await renderAiAssistantPanel(id); + await addSkillToAiAssistant(`${testRealmURL}Skill/boxel-environment`); simulateRemoteMessage(roomId, '@aibot:localhost', { msgtype: APP_BOXEL_MESSAGE_MSGTYPE, @@ -790,7 +802,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: 'search1', - name: 'SearchCardsByTypeAndTitleCommand_a959', + name: searchCardsToolName, arguments: JSON.stringify({ description: 'Searching for card', attributes: { @@ -829,6 +841,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { test('it can search for card instances based upon title of card', async function (assert) { let id = `${testRealmURL}Pet/mango.json`; let roomId = await renderAiAssistantPanel(id); + await addSkillToAiAssistant(`${testRealmURL}Skill/boxel-environment`); simulateRemoteMessage(roomId, '@aibot:localhost', { msgtype: APP_BOXEL_MESSAGE_MSGTYPE, @@ -838,7 +851,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: 'search1', - name: 'SearchCardsByTypeAndTitleCommand_a959', + name: searchCardsToolName, arguments: JSON.stringify({ description: 'Searching for card', attributes: { @@ -871,6 +884,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { test('toggle more search results', async function (assert) { let id = `${testRealmURL}Person/fadhlan.json`; let roomId = await renderAiAssistantPanel(id); + await addSkillToAiAssistant(`${testRealmURL}Skill/boxel-environment`); simulateRemoteMessage(roomId, '@aibot:localhost', { msgtype: APP_BOXEL_MESSAGE_MSGTYPE, body: 'Search for the following card', @@ -879,7 +893,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: '721c8c78-d8c1-4cc1-a7e9-51d2d3143e4d', - name: 'SearchCardsByTypeAndTitleCommand_a959', + name: searchCardsToolName, arguments: JSON.stringify({ description: 'Searching for card', attributes: { @@ -926,6 +940,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { test('it can copy search results card to workspace', async function (assert) { const id = `${testRealmURL}Person/fadhlan`; const roomId = await renderAiAssistantPanel(`${id}.json`); + await addSkillToAiAssistant(`${testRealmURL}Skill/boxel-environment`); const toolArgs = { description: 'Search for Person cards', attributes: { @@ -944,7 +959,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: 'fd4515fb-ed4d-4005-9782-4e844d7d4d9c', - name: 'SearchCardsByTypeAndTitleCommand_a959', + name: searchCardsToolName, arguments: JSON.stringify(toolArgs), }, ], @@ -998,6 +1013,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { test('copy to workspace menu item is shown for writable realm', async function (assert) { const id = `${testRealmURL}Person/fadhlan`; const roomId = await renderAiAssistantPanel(`${id}.json`); + await addSkillToAiAssistant(`${testRealmURL}Skill/boxel-environment`); const toolArgs = { description: 'Search for Person cards', attributes: { @@ -1016,7 +1032,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: '9a5b7422-87de-4a93-9f07-9b7c40b75b1e', - name: 'SearchCardsByTypeAndTitleCommand_a959', + name: searchCardsToolName, arguments: JSON.stringify(toolArgs), }, ], @@ -1037,6 +1053,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { test('copy to workspace menu item is hidden for read-only realm', async function (assert) { const id = `${readOnlyRealmURL}Person/ian`; const roomId = await renderAiAssistantPanel(`${id}.json`); + await addSkillToAiAssistant(`${testRealmURL}Skill/boxel-environment`); const toolArgs = { description: 'Search for Person cards', attributes: { @@ -1055,7 +1072,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: '6c6e2d73-8e09-4b44-a0d9-688f36b73be8', - name: 'SearchCardsByTypeAndTitleCommand_a959', + name: searchCardsToolName, arguments: JSON.stringify(toolArgs), }, ], @@ -1078,6 +1095,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { test('it can copy search results card to workspace (no cards in stack)', async function (assert) { const id = `${testRealmURL}Person/fadhlan`; const roomId = await renderAiAssistantPanel(`${id}.json`); + await addSkillToAiAssistant(`${testRealmURL}Skill/boxel-environment`); const toolArgs = { description: 'Search for Person cards', attributes: { @@ -1096,7 +1114,7 @@ module('Integration | ai-assistant-panel | tools', function (hooks) { [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: 'ffd1a3d0-0bd4-491a-a907-b96ec9d8902c', - name: 'SearchCardsByTypeAndTitleCommand_a959', + name: searchCardsToolName, arguments: JSON.stringify(toolArgs), }, ], diff --git a/packages/host/tests/integration/tools/load-default-skills-test.gts b/packages/host/tests/integration/tools/load-default-skills-test.gts index 2318784add2..03633f253a2 100644 --- a/packages/host/tests/integration/tools/load-default-skills-test.gts +++ b/packages/host/tests/integration/tools/load-default-skills-test.gts @@ -13,8 +13,7 @@ import { testRealmURL, setupRealmCacheTeardown, withCachedRealmSetup, - devSkillId, - envSkillId, + skillsIndexId, } from '../../helpers'; import { setupBaseRealm } from '../../helpers/base-realm'; import { setupMockMatrix } from '../../helpers/mock-matrix'; @@ -54,19 +53,14 @@ module('Integration | tools | load-default-skills', function (hooks) { ); }); - test('falls back to the hardcoded default skill cards when no system card is set', async function (assert) { + test('falls back to the skills index 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], - 'code mode falls back to the dev/env skill cards', - ); - assert.deepEqual( - await matrixService.loadDefaultSkills('interact'), - [envSkillId], - 'interact mode falls back to the env skill card', + await matrixService.loadDefaultSkills(), + [skillsIndexId], + 'the skills index is the default skill', ); }); @@ -78,13 +72,13 @@ module('Integration | tools | load-default-skills', function (hooks) { }; assert.deepEqual( - await matrixService.loadDefaultSkills('interact'), - [envSkillId], - 'empty default-skill lists fall through to the hardcoded default', + await matrixService.loadDefaultSkills(), + [skillsIndexId], + 'empty default-skill lists fall through to the skills index', ); }); - test("uses the system card's default skills (mode-agnostic) when set", async function (assert) { + test("uses the system card's default skills when set", async function (assert) { let matrixService = getService('matrix-service') as any; let skillCard = `${testRealmURL}Skill/my-legacy-skill`; let skillFileA = `${testRealmURL}skills/my-skill/SKILL.md`; @@ -97,14 +91,9 @@ module('Integration | tools | load-default-skills', function (hooks) { // Card ids and file ids are unioned (cards first, then files); the runtime // resolves each id kind-agnostically via `loadSkillSource`. assert.deepEqual( - await matrixService.loadDefaultSkills('code'), - [skillCard, skillFileA, skillFileB], - 'configured skill cards and skill files both win in code mode', - ); - assert.deepEqual( - await matrixService.loadDefaultSkills('interact'), + await matrixService.loadDefaultSkills(), [skillCard, skillFileA, skillFileB], - 'the same configured skills apply in interact mode (mode-agnostic)', + 'configured skill cards and skill files both replace the skills index', ); }); @@ -117,7 +106,7 @@ module('Integration | tools | load-default-skills', function (hooks) { }; assert.deepEqual( - await matrixService.loadDefaultSkills('code'), + await matrixService.loadDefaultSkills(), [skillCard], 'a card-only default-skill list is used verbatim', ); diff --git a/packages/matrix/support/isolated-realm-server.ts b/packages/matrix/support/isolated-realm-server.ts index fa640553dd0..152fc0e32ab 100644 --- a/packages/matrix/support/isolated-realm-server.ts +++ b/packages/matrix/support/isolated-realm-server.ts @@ -34,6 +34,7 @@ const skillsRealmDir = resolve( const baseRealmDir = resolve(join(import.meta.dirname, '..', '..', 'base')); const matrixDir = resolve(join(import.meta.dirname, '..')); export const appURL = 'https://localhost:4205/test'; +export const skillsRealmURL = 'https://localhost:4205/skills/'; const DEFAULT_PRERENDER_PORT = 4231; const DEFAULT_WORKER_MANAGER_READY_TIMEOUT_MS = 120_000; diff --git a/packages/matrix/tests/skills.spec.ts b/packages/matrix/tests/skills.spec.ts index 56312f7b60d..44342de21d8 100644 --- a/packages/matrix/tests/skills.spec.ts +++ b/packages/matrix/tests/skills.spec.ts @@ -16,7 +16,7 @@ import { createSubscribedUserAndLogin, createRealm, } from '../helpers/index.ts'; -import { appURL } from '../support/isolated-realm-server.ts'; +import { appURL, skillsRealmURL } from '../support/isolated-realm-server.ts'; import { randomUUID } from 'crypto'; test.describe('Skills', () => { @@ -51,11 +51,11 @@ test.describe('Skills', () => { ).toContainClass('checked'); } - const environmentSkillCardId = `@cardstack/skills/Skill/boxel-environment`; - const defaultSkillCardsForCodeMode = [ - `@cardstack/skills/Skill/boxel-development`, - environmentSkillCardId, - ]; + // The skills index is the default skill for every new room. Room state keys + // a `.md` skill by the realm's own absolute file URL, so this is the id the + // skill menu renders — not the `@cardstack/skills/` reference the system card + // stores. + const defaultSkillId = `${skillsRealmURL}index.md`; const skillCard1 = `${appURL}/skill-pirate-speak`; const skillCard2 = `${appURL}/skill-seo`; const skillCard3 = `${appURL}/skill-card-title-editing`; @@ -81,10 +81,10 @@ test.describe('Skills', () => { ); await expect(page.locator('[data-test-pill-menu-item]')).toHaveCount(1); await expect( - page.locator(`[data-test-pill-menu-item="${environmentSkillCardId}"]`), + page.locator(`[data-test-pill-menu-item="${defaultSkillId}"]`), ).toHaveCount(1); await expect( - page.locator(`[data-test-skill-toggle="${environmentSkillCardId}-on"]`), + page.locator(`[data-test-skill-toggle="${defaultSkillId}-on"]`), ).toHaveCount(1); await expect(page.locator('[data-test-pill-menu-add-button]')).toHaveCount( 1, @@ -141,37 +141,6 @@ test.describe('Skills', () => { ); }); - // TODO: restore in CS-10374 - test.skip('it will attach code editing skills in code mode by default', async ({ - page, - }) => { - await login(page, firstUser.username, firstUser.password, { - url: appURL, - openAiAssistant: true, - }); - await page.locator(`[data-test-room-settled]`).waitFor(); - - await page.locator('[data-test-submode-switcher] button').click(); - await page.locator('[data-test-boxel-menu-item-text="Code"]').click(); - await page.locator('[data-test-skill-menu]').hover(); - await page - .locator('[data-test-skill-menu][data-test-pill-menu-button]') - .click(); - - // Check that each default skill card for code mode is attached - for (const skillCardURL of defaultSkillCardsForCodeMode) { - await expect( - page.locator(`[data-test-pill-menu-item="${skillCardURL}"]`), - `Skill card ${skillCardURL} should be attached`, - ).toHaveCount(1); - - await expect( - page.locator(`[data-test-skill-toggle="${skillCardURL}-on"]`), - `Skill card ${skillCardURL} should be active`, - ).toContainClass('checked'); - } - }); - test(`room skills state does not leak when switching rooms`, async ({ page, }) => { @@ -272,12 +241,12 @@ test.describe('Skills', () => { .locator('[data-test-skill-menu][data-test-pill-menu-button]') .click(); await page - .locator(`[data-test-skill-toggle="${environmentSkillCardId}-on"]`) - .click(); // toggle off default skill card + .locator(`[data-test-skill-toggle="${defaultSkillId}-on"]`) + .click(); // toggle off the default skill await page.locator(`[data-test-skill-toggle="${skillCard1}-on"]`).click(); // toggle off skill 1 await page.locator(`[data-test-skill-toggle="${skillCard2}-on"]`).click(); // toggle off skill 2 await expect( - page.locator(`[data-test-skill-toggle="${environmentSkillCardId}-off"]`), + page.locator(`[data-test-skill-toggle="${defaultSkillId}-off"]`), ).toHaveCount(1); await expect( page.locator(`[data-test-skill-toggle="${skillCard1}-off"]`), diff --git a/packages/runtime-common/constants.ts b/packages/runtime-common/constants.ts index af5fe37d93c..5ead5c36773 100644 --- a/packages/runtime-common/constants.ts +++ b/packages/runtime-common/constants.ts @@ -28,14 +28,17 @@ export function baseRRI(path: string): RealmResourceIdentifier { return rri(`${baseRealmRRI}${path}`); } -// Hardcoded fallback default skills for new AI rooms when the user's active -// system card configures none. These are the legacy `Skill/*` cards; flipping -// the fallback to the `.md` skill files is tracked separately (CS-11783) and -// waits on those files being served in every skills realm. Note the room- -// creation path already resolves either kind, and the system card's -// `defaultSkillCards` / `defaultSkillFiles` already accept both. +// The skills realm's index — the pull-model entry point. Its body names every +// skill and command the assistant can reach and is short enough to push into +// every room, so a room needs no other skill up front: the model reads what a +// task actually calls for. This is the default skill for new AI rooms, and the +// skill the system card's `defaultSkillFiles` names. +export const skillsIndexLocalPath = 'index.md'; + +// The legacy `Skill` card carrying the Boxel coding guidance, enabled by the +// build-listing and readme-spec flows, which set up their own skill lists +// rather than going through the room defaults. export const devSkillLocalPath = 'Skill/boxel-development'; -export const envSkillLocalPath = 'Skill/boxel-environment'; export const baseRef: ResolvedCodeRef = { module: `${baseRealmRRI}card-api` as RealmResourceIdentifier, From 4356a8f1b3ec2d27266a2a821ebf02a145cca0cc Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 3 Aug 2026 14:07:49 +0200 Subject: [PATCH 2/4] Attach a tool-bearing skill in tests that apply a tool call These tests fired a tool call without attaching any skill that declares it, relying on the room's default skill to supply host tools. The skills index declares none, so the request resolved to no known codeRef: the host tests timed out waiting for the room's tools to load, and the matrix test never saw a result event. Each now attaches a skill carrying the tool it applies, and the host tests derive the function name from the code ref rather than hardcoding its hash. --- packages/host/tests/acceptance/tools-test.gts | 72 +++++++++++++------ packages/matrix/tests/tools.spec.ts | 17 +++++ .../contents/skill-search-cards.json | 26 +++++++ 3 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 packages/test-realm-cards/contents/skill-search-cards.json diff --git a/packages/host/tests/acceptance/tools-test.gts b/packages/host/tests/acceptance/tools-test.gts index 62788451da1..c4cd411c7bf 100644 --- a/packages/host/tests/acceptance/tools-test.gts +++ b/packages/host/tests/acceptance/tools-test.gts @@ -20,6 +20,7 @@ import { GridContainer } from '@cardstack/boxel-ui/components'; import { baseRealm, buildCommandFunctionName, + buildCommandFunctionNameFromResolvedRef, Command, skillCardRef, } from '@cardstack/runtime-common'; @@ -85,6 +86,12 @@ let maybeBoomShouldBoom = true; let savedMeetingCardId: string | undefined; module('Acceptance | Tools tests', function (hooks) { + // The show-card tool as `Skill/card-editing` declares it. + const showCardToolName = buildCommandFunctionNameFromResolvedRef({ + module: '@cardstack/boxel-host/commands/show-card', + name: 'default', + }); + setupApplicationTest(hooks); setupLocalIndexing(hooks); setupOnSave(hooks); @@ -497,6 +504,33 @@ module('Acceptance | Tools tests', function (hooks) { }, }, }, + // `show-card` without approval, for the auto-apply tests. A room's + // default skill is the skills index, which declares no tools of its + // own, so a test that applies a tool has to attach a skill carrying it. + 'Skill/card-editing.json': { + data: { + type: 'card', + attributes: { + instructions: + 'Use show-card to bring a card into view once you know its id.', + commands: [ + { + codeRef: { + name: 'default', + module: '@cardstack/boxel-host/commands/show-card', + }, + requiresApproval: false, + }, + ], + cardTitle: 'Card Editing', + cardDescription: null, + cardThumbnailURL: null, + }, + meta: { + adoptsFrom: skillCardRef, + }, + }, + }, 'index.json': new CardsGrid(), 'realm.json': realmConfigCardJSON({ name: 'Test Workspace B', @@ -1297,12 +1331,12 @@ module('Acceptance | Tools tests', function (hooks) { // simulate message roomId = getRoomIds().pop()!; - // The new room's default skills (env skill, which carries show-card) are - // loaded asynchronously by the room resource's processRoomTask. If we - // dispatch the bot message before loadSkills finishes, message-builder - // can't match show-card_566f to a known codeRef and the command resolves - // as invalid ("No command for the name X was found") instead of being - // auto-applied. + // The room's skills load asynchronously through the room resource's + // processRoomTask. If we dispatch the bot message before that finishes, + // message-builder can't match the request to a known codeRef and the + // command resolves as invalid ("No command for the name X was found") + // instead of being auto-applied. + await addSkillToAiAssistant(`${testRealmURL}Skill/card-editing`); await waitForNewRoomSkillsLoaded(roomId); simulateRemoteMessage(roomId, '@aibot:localhost', { body: 'Show the card', @@ -1312,7 +1346,7 @@ module('Acceptance | Tools tests', function (hooks) { [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: '1554f297-e9f2-43fe-8b95-55b29251444d', - name: 'show-card_566f', + name: showCardToolName, arguments: JSON.stringify({ description: 'Displaying the card with the Latin word for milkweed in the title.', @@ -1430,8 +1464,9 @@ module('Acceptance | Tools tests', function (hooks) { // simulate message roomId = getRoomIds().pop()!; // Same skill-load race as the sister "agentId matches" test — wait for - // default skills to be available so show-card_566f resolves to a known - // codeRef before the simulated bot message arrives. + // the attached skill's tools so the request resolves to a known codeRef + // before the simulated bot message arrives. + await addSkillToAiAssistant(`${testRealmURL}Skill/card-editing`); await waitForNewRoomSkillsLoaded(roomId); simulateRemoteMessage(roomId, '@aibot:localhost', { body: 'Show the card', @@ -1441,7 +1476,7 @@ module('Acceptance | Tools tests', function (hooks) { [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: '1554f297-e9f2-43fe-8b95-55b29251444d', - name: 'show-card_566f', + name: showCardToolName, arguments: JSON.stringify({ description: 'Displaying the card with the Latin word for milkweed in the title.', @@ -1790,9 +1825,10 @@ module('Acceptance | Tools tests', function (hooks) { // simulate message let roomId = getRoomIds().pop()!; // The JSON-schema validation failure we're asserting only runs once - // message-builder has resolved show-card_566f to its codeRef via the - // room's loaded skills. Without this wait we sometimes hit the upstream - // "No command for the name X was found" branch instead. + // message-builder has resolved the request to its codeRef via the room's + // loaded skills. Without this wait we sometimes hit the upstream "No + // command for the name X was found" branch instead. + await addSkillToAiAssistant(`${testRealmURL}Skill/card-editing`); await waitForNewRoomSkillsLoaded(roomId); simulateRemoteMessage(roomId, '@aibot:localhost', { body: 'Show the card', @@ -1802,7 +1838,7 @@ module('Acceptance | Tools tests', function (hooks) { [APP_BOXEL_TOOL_REQUESTS_KEY]: [ { id: '1554f297-e9f2-43fe-8b95-55b29251444d', - name: 'show-card_566f', + name: showCardToolName, arguments: JSON.stringify({ description: 'Displaying the card with the Latin word for milkweed in the title.', @@ -1828,8 +1864,7 @@ module('Acceptance | Tools tests', function (hooks) { // expected JSON-schema validation message. Log enough state to // distinguish the skill-load race from a genuine validation regression // before the qunit-dom assertion records the failure. - let expectedValidationText = - 'Command "show-card_566f" validation failed: data/attributes must have required property \'cardId\''; + let expectedValidationText = `Command "${showCardToolName}" validation failed: data/attributes must have required property 'cardId'`; let warningText = document .querySelector('[data-test-boxel-alert="warning"]') @@ -1871,10 +1906,7 @@ module('Acceptance | Tools tests', function (hooks) { APP_BOXEL_TOOL_RESULT_REL_TYPE, ); assert.strictEqual(message.content['m.relates_to']?.key, 'invalid'); - assert.strictEqual( - message.content.failureReason, - 'Command "show-card_566f" validation failed: data/attributes must have required property \'cardId\'', - ); + assert.strictEqual(message.content.failureReason, expectedValidationText); assert.strictEqual( message.content.commandRequestId, '1554f297-e9f2-43fe-8b95-55b29251444d', diff --git a/packages/matrix/tests/tools.spec.ts b/packages/matrix/tests/tools.spec.ts index 3429de7104b..deee00b02be 100644 --- a/packages/matrix/tests/tools.spec.ts +++ b/packages/matrix/tests/tools.spec.ts @@ -260,6 +260,23 @@ test.describe('Commands', () => { ], }; + // A room's default skill is the skills index, which declares no tools of + // its own, so the search tool has to come from a skill attached here. + await page + .locator('[data-test-skill-menu][data-test-pill-menu-button]') + .click(); + await page + .locator('[data-test-skill-menu] [data-test-pill-menu-add-button]') + .click(); + await page + .locator(`[data-test-item-button="${appURL}/skill-search-cards"]`) + .click(); + await page.locator('[data-test-card-chooser-go-button]').click(); + await expect( + page.locator(`[data-test-pill-menu-item="${appURL}/skill-search-cards"]`), + ).toHaveCount(1); + await page.locator('[data-test-pill-menu-detail-close]').click(); + await showAllCards(page); let hassanCard = page.locator(`[data-test-cards-grid-item="${cardId}"]`); await hassanCard.waitFor(); diff --git a/packages/test-realm-cards/contents/skill-search-cards.json b/packages/test-realm-cards/contents/skill-search-cards.json new file mode 100644 index 00000000000..62c2e259550 --- /dev/null +++ b/packages/test-realm-cards/contents/skill-search-cards.json @@ -0,0 +1,26 @@ +{ + "data": { + "type": "card", + "attributes": { + "instructions": "Use search-cards-by-type-and-title to find cards by their type or title.", + "commands": [ + { + "codeRef": { + "name": "SearchCardsByTypeAndTitleCommand", + "module": "@cardstack/boxel-host/commands/search-cards" + }, + "requiresApproval": true + } + ], + "cardTitle": "Card Search", + "cardDescription": null, + "cardThumbnailURL": null + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/skill", + "name": "Skill" + } + } + } +} From bdfacc1e7c9c033cf17eea6112436909ef0be0de Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 3 Aug 2026 15:00:47 +0200 Subject: [PATCH 3/4] Skip re-uploading a markdown skill whose content has not changed Re-uploading a `.md` skill fetches its source over HTTP first, and this runs on every message send, so each send re-downloaded every enabled markdown skill. The realm-indexed file-meta already carries the hash of that content, so when it matches the hash the room recorded, the stored fileDef is reused and the fetch is skipped. --- packages/host/app/services/matrix-service.ts | 53 ++++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index ae9ae56a485..6a0f2a87d8a 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -1547,6 +1547,7 @@ export default class MatrixService extends Service { // files re-upload their file content. Both contribute commands. let skillCardsToReupload: SkillModule.Skill[] = []; let markdownSkillFileDefs: FileDef[] = []; + let unchangedMarkdownSkillFileDefs: FileAPI.SerializedFile[] = []; await Promise.all( enabledSkillCardFileDefs.map(async (fileDef) => { let source = await loadSkillSource(this.store, fileDef.sourceUrl); @@ -1558,6 +1559,16 @@ export default class MatrixService extends Service { ); if (isSkillCard in source) { skillCardsToReupload.push(source as SkillModule.Skill); + } else if ( + isUnchangedMarkdownSkill(source as unknown as FileDef, fileDef) + ) { + // Re-uploading a `.md` skill means fetching its source over HTTP + // first. The realm-indexed file-meta already carries the hash of + // that content, so when it matches what the room recorded there + // is nothing to upload — keep the stored fileDef and skip the + // fetch. This runs on every message send, so without the check + // each send re-downloads every enabled markdown skill. + unchangedMarkdownSkillFileDefs.push(fileDef); } else { markdownSkillFileDefs.push(this.fileAPI.createFileDef(fileDef)); } @@ -1574,14 +1585,21 @@ export default class MatrixService extends Service { // any room holding both a skill card and a `.md` skill — a rewrite that // changes nothing but the sequence, which still writes a new state // event on every send. Skills that no longer load drop out, as before. - let reuploadedBySourceUrl = new Map( - [...enabledSkillFileDefs, ...enabledMarkdownSkillFileDefs].map( - (fileDef) => [fileDef.sourceUrl, fileDef], - ), - ); + let bySourceUrl = new Map(); + for (let fileDef of [ + ...enabledSkillFileDefs, + ...enabledMarkdownSkillFileDefs, + ]) { + bySourceUrl.set(fileDef.sourceUrl, fileDef.serialize()); + } + for (let fileDef of unchangedMarkdownSkillFileDefs) { + bySourceUrl.set(fileDef.sourceUrl, fileDef); + } let orderedSkillFileDefs = enabledSkillCardFileDefs - .map((fileDef) => reuploadedBySourceUrl.get(fileDef.sourceUrl)) - .filter((fileDef): fileDef is FileDef => Boolean(fileDef)); + .map((fileDef) => bySourceUrl.get(fileDef.sourceUrl)) + .filter((fileDef): fileDef is FileAPI.SerializedFile => + Boolean(fileDef), + ); // get the unique subset of enabledCommandDefinitions by functionName enabledCommandDefinitions = this.getUniqueToolDefinitions( enabledCommandDefinitions, @@ -1590,9 +1608,7 @@ export default class MatrixService extends Service { enabledCommandDefinitions, ); return { - enabledSkillCards: orderedSkillFileDefs.map((fileDef) => - fileDef.serialize(), - ), + enabledSkillCards: orderedSkillFileDefs, disabledSkillCards: currentSkillsConfig?.disabledSkillCards ?? [], toolDefinitions: enabledCommandDefFileDefs.map((fileDef) => fileDef.serialize(), @@ -3433,6 +3449,23 @@ async function getStorage() { return storage; } +// True when a `.md` skill's realm-indexed content matches what the room +// already recorded, so the stored fileDef can be reused as-is. Both hashes come +// from the same content, one via the realm's index and one from the upload that +// wrote the room's copy, so equality means an upload would produce the same +// bytes. A stored def with no uploaded `url` is never reusable. +function isUnchangedMarkdownSkill( + source: FileDef, + stored: FileAPI.SerializedFile, +): boolean { + return Boolean( + source.contentHash && + stored.contentHash && + source.contentHash === stored.contentHash && + stored.url, + ); +} + function serializeFileForPersistence( f: ReturnType, ): StoredPendingFile { From 5037f91506940cc7586bb9aa4770c6e3a7dd97b3 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 3 Aug 2026 15:25:39 +0200 Subject: [PATCH 4/4] Give the deliberate-send-failure wait a real budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test leaves the send un-awaited so it can assert the pending bubble, then waits for the failure alert. The mock sleeps 1000ms before failing, which is exactly waitFor's default timeout, so the wait had no margin for anything the send does first — and it re-uploads the room's skills on every send. --- .../components/ai-assistant-panel/sending-test.gts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/host/tests/integration/components/ai-assistant-panel/sending-test.gts b/packages/host/tests/integration/components/ai-assistant-panel/sending-test.gts index 7cdab036c75..359b8f9a145 100644 --- a/packages/host/tests/integration/components/ai-assistant-panel/sending-test.gts +++ b/packages/host/tests/integration/components/ai-assistant-panel/sending-test.gts @@ -218,7 +218,13 @@ module('Integration | ai-assistant-panel | sending', function (hooks) { assert.dom('[data-test-ai-assistant-message]').exists({ count: 1 }); assert.dom('[data-test-user-message]').hasClass('is-pending'); - await waitFor('[data-test-boxel-alert="error"]'); + // The send is deliberately not awaited above, so this wait races the mock's + // own delay: SENDING_DELAY_THEN_FAILURE sleeps 1000ms before it fails, + // which is exactly `waitFor`'s default budget. Anything the send does + // before reaching the mock — the room's skills are re-uploaded on every + // send — spends the whole margin. Wait long enough for the deliberate + // delay itself rather than depending on the rest of the send being free. + await waitFor('[data-test-boxel-alert="error"]', { timeout: 5000 }); await settled(); assert.dom('[data-test-message-field]').hasValue('');