diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c2dec33f0b7..fd07459f346 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -323,8 +323,9 @@ jobs: matrix: shardIndex: [1, 2, 3] shardTotal: [3] + repeat: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] concurrency: - group: matrix-client-test-${{ matrix.shardIndex }}-${{ github.head_ref || github.run_id }} + group: matrix-client-test-${{ matrix.shardIndex }}-r${{ matrix.repeat }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -368,42 +369,42 @@ jobs: uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 if: ${{ !cancelled() }} with: - name: matrix-test-realm-server-log-${{ matrix.shardIndex }} + name: matrix-test-realm-server-log-${{ matrix.shardIndex }}-r${{ matrix.repeat }} path: /tmp/server.log retention-days: 30 - name: Upload worker manager log uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 if: ${{ !cancelled() }} with: - name: matrix-test-worker-manager-log-${{ matrix.shardIndex }} + name: matrix-test-worker-manager-log-${{ matrix.shardIndex }}-r${{ matrix.repeat }} path: /tmp/worker-manager.log retention-days: 30 - name: Upload prerender server log uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 if: ${{ !cancelled() }} with: - name: matrix-test-prerender-server-log-${{ matrix.shardIndex }} + name: matrix-test-prerender-server-log-${{ matrix.shardIndex }}-r${{ matrix.repeat }} path: /tmp/prerender-server.log retention-days: 30 - name: Upload prerender manager log uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 if: ${{ !cancelled() }} with: - name: matrix-test-prerender-manager-log-${{ matrix.shardIndex }} + name: matrix-test-prerender-manager-log-${{ matrix.shardIndex }}-r${{ matrix.repeat }} path: /tmp/prerender-manager.log retention-days: 30 - name: Upload icon server log uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 if: ${{ !cancelled() }} with: - name: matrix-test-icon-server-log-${{ matrix.shardIndex }} + name: matrix-test-icon-server-log-${{ matrix.shardIndex }}-r${{ matrix.repeat }} path: /tmp/icon-server.log retention-days: 30 - name: Upload host-dist log uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 if: ${{ !cancelled() }} with: - name: matrix-test-host-dist-log-${{ matrix.shardIndex }} + name: matrix-test-host-dist-log-${{ matrix.shardIndex }}-r${{ matrix.repeat }} path: /tmp/host-dist.log retention-days: 30 @@ -411,7 +412,7 @@ jobs: if: ${{ !cancelled() }} uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 with: - name: blob-report-${{ matrix.shardIndex }} + name: blob-report-${{ matrix.shardIndex }}-r${{ matrix.repeat }} path: packages/matrix/blob-report retention-days: 1 @@ -419,7 +420,7 @@ jobs: if: ${{ !cancelled() }} uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 with: - name: playwright-traces-${{ matrix.shardIndex }} + name: playwright-traces-${{ matrix.shardIndex }}-r${{ matrix.repeat }} path: packages/matrix/test-results/**/trace.zip retention-days: 30 if-no-files-found: ignore @@ -457,10 +458,16 @@ jobs: with: path: all-blob-reports pattern: blob-report-* - merge-multiple: true + + - name: Flatten blob reports into a single directory + run: | + mkdir -p all-blob-reports-flat + i=0; for f in all-blob-reports/**/*.zip; do + cp "$f" "all-blob-reports-flat/$((i++))-$(basename "$f")" + done - name: Merge blobs into one single report - run: pnpm exec playwright merge-reports --reporter html ./all-blob-reports + run: pnpm exec playwright merge-reports --reporter html ./all-blob-reports-flat - name: Upload HTML report uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 diff --git a/packages/host/app/commands/create-ai-assistant-room.ts b/packages/host/app/commands/create-ai-assistant-room.ts index aa0faa03eab..eea10e56f4e 100644 --- a/packages/host/app/commands/create-ai-assistant-room.ts +++ b/packages/host/app/commands/create-ai-assistant-room.ts @@ -86,7 +86,7 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< // Run room creation and module loading in parallel const [roomResult, commandModule] = await Promise.all([ - await matrixService.createRoom({ + matrixService.createRoom({ preset: matrixService.privateChatPreset, invite: [aiBotFullId], name: input.name, @@ -134,7 +134,7 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< }, ], }), - await this.loadCommandModule(), + this.loadCommandModule(), ]); const { room_id: roomId } = roomResult; diff --git a/packages/host/app/services/ai-assistant-panel-service.ts b/packages/host/app/services/ai-assistant-panel-service.ts index cb75bbd4075..3a23c0e4cd9 100644 --- a/packages/host/app/services/ai-assistant-panel-service.ts +++ b/packages/host/app/services/ai-assistant-panel-service.ts @@ -10,7 +10,13 @@ import { timeout } from 'ember-concurrency'; import window from 'ember-window-mock'; import { isCardInstance } from '@cardstack/runtime-common'; -import type { LLMMode } from '@cardstack/runtime-common/matrix-constants'; +import { + APP_BOXEL_ACTIVE_LLM, + APP_BOXEL_LLM_MODE, + APP_BOXEL_ROOM_SKILLS_EVENT_TYPE, + DEFAULT_LLM, + type LLMMode, +} from '@cardstack/runtime-common/matrix-constants'; import type { CardDef, Format } from 'https://cardstack.com/base/card-api'; import type * as CommandModule from 'https://cardstack.com/base/command'; @@ -60,6 +66,10 @@ export default class AiAssistantPanelService extends Service { @tracked displayRoomError = false; @tracked isShowingPastSessions = false; + // Rooms the user has explicitly deleted this session. Used to filter + // aiSessionRooms because sync events can re-add deleted rooms to the + // cache before the leave event propagates through the room state. + private deletedRoomIds = new Set(); @tracked roomToRename: SessionRoomData | undefined = undefined; @tracked roomToDelete: { id: string; name: string } | undefined = undefined; @tracked roomDeleteError: string | undefined = undefined; @@ -236,6 +246,7 @@ export default class AiAssistantPanelService extends Service { addSameSkills: boolean; shouldCopyFileHistory: boolean; shouldSummarizeSession: boolean; + deferDefaultSkills?: boolean; } = { addSameSkills: false, shouldCopyFileHistory: false, @@ -393,47 +404,66 @@ export default class AiAssistantPanelService extends Service { addSameSkills: boolean; shouldCopyFileHistory: boolean; shouldSummarizeSession: boolean; + deferDefaultSkills?: boolean; }, ) => { - let { addSameSkills, shouldCopyFileHistory, shouldSummarizeSession } = - opts; + let { + addSameSkills, + shouldCopyFileHistory, + shouldSummarizeSession, + deferDefaultSkills, + } = opts; try { - let createRoomCommand = new CreateAiAssistantRoomCommand( - this.commandService.commandContext, - ); - - let input: any = { name }; - let llmMode = this.getPreferredLLMMode(); - if (llmMode) { - input.llmMode = llmMode; - } - let enabledSkills: SkillCard[] = []; - let disabledSkills: SkillCard[] = []; - - if (addSameSkills) { - const extractedSkills = await this.extractSkillsFromCurrentRoom(); - enabledSkills = extractedSkills.enabledSkills; - disabledSkills = extractedSkills.disabledSkills; - } + let roomId: string; + let oldRoomId = this.matrixService.currentRoomId; - if (enabledSkills.length || disabledSkills.length) { - input.enabledSkills = enabledSkills; - input.disabledSkills = disabledSkills; + if (deferDefaultSkills) { + // Fast path: create room directly without going through the + // command system (which loads a JS module from the realm server + // that can hang on 404s). Skills are applied in the background. + roomId = await this.createFallbackRoom(name); } else { - // Use default skills - input.enabledSkills = await this.matrixService.loadDefaultSkills( - this.operatorModeStateService.state.submode, + let createRoomCommand = new CreateAiAssistantRoomCommand( + this.commandService.commandContext, ); - } - let oldRoomId = this.matrixService.currentRoomId; - let { roomId } = await createRoomCommand.execute(input); + let input: any = { name }; + let llmMode = this.getPreferredLLMMode(); + if (llmMode) { + input.llmMode = llmMode; + } + let enabledSkills: SkillCard[] = []; + let disabledSkills: SkillCard[] = []; + + if (addSameSkills) { + const extractedSkills = await this.extractSkillsFromCurrentRoom(); + enabledSkills = extractedSkills.enabledSkills; + disabledSkills = extractedSkills.disabledSkills; + } + + if (enabledSkills.length || disabledSkills.length) { + input.enabledSkills = enabledSkills; + input.disabledSkills = disabledSkills; + } else { + // Use default skills + input.enabledSkills = await this.matrixService.loadDefaultSkills( + this.operatorModeStateService.state.submode, + ); + } + + ({ roomId } = await createRoomCommand.execute(input)); + } window.localStorage.setItem(NewSessionIdPersistenceKey, roomId); // Enter room immediately this.enterRoom(roomId); + // Load default skills in the background after room creation + if (deferDefaultSkills) { + this.applyDefaultSkillsToRoom(roomId); + } + // Start background tasks for session preparation if (oldRoomId && (shouldSummarizeSession || shouldCopyFileHistory)) { this.prepareSessionContextTask.perform(oldRoomId, roomId, { @@ -450,6 +480,80 @@ export default class AiAssistantPanelService extends Service { }, ); + private async createFallbackRoom(name: string): Promise { + let userId = this.matrixService.userId; + let aiBotFullId = this.matrixService.aiBotUserId; + let llmMode = this.getPreferredLLMMode(); + let systemCard = this.matrixService.systemCard; + let configuration = + systemCard?.defaultModelConfiguration ?? + systemCard?.modelConfigurations?.[0]; + + let { room_id: roomId } = await this.matrixService.createRoom({ + preset: this.matrixService.privateChatPreset, + invite: [aiBotFullId], + name, + room_alias_name: encodeURIComponent( + `${name} - ${new Date().toISOString()} - ${userId}`, + ), + power_level_content_override: { + users: { + [userId!]: 100, + [aiBotFullId]: this.matrixService.aiBotPowerLevel, + }, + }, + initial_state: [ + { + type: APP_BOXEL_ACTIVE_LLM, + content: { + model: configuration?.modelId ?? DEFAULT_LLM, + toolsSupported: Boolean(configuration?.toolsSupported), + reasoningEffort: configuration?.reasoningEffort ?? undefined, + }, + }, + { + type: APP_BOXEL_LLM_MODE, + content: { mode: llmMode || 'ask' }, + }, + { + type: APP_BOXEL_ROOM_SKILLS_EVENT_TYPE, + content: { + enabledSkillCards: [], + disabledSkillCards: [], + commandDefinitions: [], + }, + }, + ], + }); + return roomId; + } + + private async applyDefaultSkillsToRoom(roomId: string) { + try { + let skills = await this.matrixService.loadDefaultSkills( + this.operatorModeStateService.state.submode, + ); + if (!skills.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()), + }, + ); + } catch (e) { + console.error('Failed to apply default skills to room:', e); + } + } + // Background tasks for session preparation private summarizeSessionTask = restartableTask( async (oldRoomId: string, newRoomId: string) => { @@ -612,6 +716,18 @@ export default class AiAssistantPanelService extends Service { ) { continue; } + // Skip rooms the user has deleted this session, or rooms whose state + // shows the user has left. Sync events can re-add deleted rooms to + // the cache with stale state before the leave event propagates. + if (this.deletedRoomIds.has(resource.roomId)) { + continue; + } + if ( + this.matrixService.userId && + !resource.matrixRoom.hasActiveMember(this.matrixService.userId) + ) { + continue; + } if (resource.name && resource.roomId) { sessions.push({ roomId: resource.roomId, @@ -677,12 +793,19 @@ export default class AiAssistantPanelService extends Service { private doLeaveRoom = restartableTask(async (roomId: string) => { try { + this.deletedRoomIds.add(roomId); await this.matrixService.leave(roomId); await this.matrixService.forget(roomId); await timeout(eventDebounceMs); // this makes it feel a bit more responsive this.matrixService.roomResourcesCache.delete(roomId); - if (this.newSessionId === roomId) { + // Check localStorage directly instead of using the newSessionId getter, + // which checks roomResources.has(id). Since we just deleted the room from + // roomResourcesCache above, the getter would return undefined and this + // comparison would always be false — leaving a stale ID in localStorage. + // A subsequent sync event can re-add the room to the cache, causing + // createNewSession to enter the deleted room instead of creating a new one. + if (window.localStorage.getItem(NewSessionIdPersistenceKey) === roomId) { window.localStorage.removeItem(NewSessionIdPersistenceKey); } @@ -691,7 +814,12 @@ export default class AiAssistantPanelService extends Service { if (this.latestRoom) { this.enterRoom(this.latestRoom.roomId, false); } else { - this.createNewSession(); + await this.createNewSession({ + addSameSkills: false, + shouldCopyFileHistory: false, + shouldSummarizeSession: false, + deferDefaultSkills: true, + }); } } this.roomToDelete = undefined; diff --git a/packages/matrix/tests/room-creation.spec.ts b/packages/matrix/tests/room-creation.spec.ts index 6bc5ef6cdcd..17ffee3c0be 100644 --- a/packages/matrix/tests/room-creation.spec.ts +++ b/packages/matrix/tests/room-creation.spec.ts @@ -342,6 +342,9 @@ test.describe('Room creation', () => { test('it opens latest room available (or creates new) when current room is deleted', async ({ page, }) => { + // This test creates 3 rooms, sends messages, deletes all 3, then waits + // for auto-creation — needs more than the default 60s timeout. + test.setTimeout(120_000); await login(page, firstUser.username, firstUser.password, { url: appURL }); await page.locator(`[data-test-room-settled]`).waitFor(); let room1 = await getRoomId(page); @@ -363,18 +366,30 @@ test.describe('Room creation', () => { await deleteRoom(page, room2); // current room is deleted await page.locator('[data-test-ai-assistant-panel]').click(); let newRoom: string | undefined; + // Poll without using getRoomId — it blocks on waitFor('[data-test-room-settled]') + // which can consume the entire waitUntil budget in a single attempt. await waitUntil(async () => { try { - let roomId = await getRoomId(page); - if (roomId !== room1 && roomId !== room2 && roomId !== room3) { + if ((await page.locator('[data-test-room-error]').count()) > 0) { + throw new Error( + 'Room creation failed — [data-test-room-error] is visible', + ); + } + let roomEl = page.locator('[data-test-room]'); + if ((await roomEl.count()) === 0) return false; + let roomId = await roomEl.getAttribute('data-test-room'); + if (roomId && roomId !== room1 && roomId !== room2 && roomId !== room3) { newRoom = roomId; return true; } return false; - } catch { + } catch (e) { + if (e instanceof Error && e.message.includes('Room creation failed')) { + throw e; + } return false; } - }, 30000); + }, 60_000); if (!newRoom) { throw new Error('expected to enter a newly-created room after deletion'); }