From 7e85f7cc41f7dcb81b2b6fa8618a28b45f3e9e17 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 17 Dec 2024 18:06:09 -0500 Subject: [PATCH] WIP Updating command results to a) always be a CardDef or undefined, b) added as a card to the room, and c) references by eventID in the reaction event created once a command runs aibot will need to be updated to read from this Message model and room-message will need to updated to display the result card --- packages/base/command.gts | 16 ++ packages/base/matrix-event.gts | 34 +--- .../host/app/commands/get-boxel-ui-state.ts | 27 +++ .../app/lib/matrix-classes/message-builder.ts | 40 +--- .../app/lib/matrix-classes/message-command.ts | 1 + packages/host/app/lib/matrix-classes/room.ts | 2 + packages/host/app/resources/room.ts | 6 +- packages/host/app/services/command-service.ts | 104 ++++------- packages/host/app/services/matrix-service.ts | 172 +++++++++--------- .../host/tests/acceptance/commands-test.gts | 123 ++++++++++++- packages/runtime-common/commands.ts | 12 ++ 11 files changed, 318 insertions(+), 219 deletions(-) create mode 100644 packages/host/app/commands/get-boxel-ui-state.ts diff --git a/packages/base/command.gts b/packages/base/command.gts index 37af929489..266ff3d114 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -113,3 +113,19 @@ export class SendAiAssistantMessageInput extends CardDef { export class SendAiAssistantMessageResult extends CardDef { @field eventId = contains(StringField); } + +export class GetBoxelUIStateResult extends CardDef { + @field submode = contains(StringField); + //TODO expand this to include more of the UI state: + // - open cards + // - current room ID +} + +export class SearchCardsResult extends CardDef { + @field cardDocs = containsMany(JsonField); +} + +export class LegacyGenerateAppModuleResult extends CardDef { + @field moduleId = contains(StringField); + @field source = contains(StringField); +} diff --git a/packages/base/matrix-event.gts b/packages/base/matrix-event.gts index febbe23c59..cc14a87d6b 100644 --- a/packages/base/matrix-event.gts +++ b/packages/base/matrix-event.gts @@ -150,6 +150,13 @@ export interface ReactionEventContent { }; } +export type CommandReactionEventContent = ReactionEventContent & { + msgtype: 'org.boxel.command_result'; + data: { + card_event_id: string | null; + }; +}; + export interface CardMessageEvent extends BaseMatrixEvent { type: 'm.room.message'; content: CardMessageContent | CardFragmentContent; @@ -227,39 +234,12 @@ export interface SkillsConfigEvent extends RoomStateEvent { }; } -export interface CommandResultEvent extends BaseMatrixEvent { - type: 'm.room.message'; - content: CommandResultContent; - unsigned: { - age: number; - transaction_id: string; - prev_content?: any; - prev_sender?: string; - }; -} - -export interface CommandResultContent { - 'm.relates_to'?: { - rel_type: 'm.annotation'; - key: string; - event_id: string; - 'm.in_reply_to'?: { - event_id: string; - }; - }; - formatted_body: string; - body: string; - msgtype: 'org.boxel.commandResult'; - result: any; -} - export type MatrixEvent = | RoomCreateEvent | RoomJoinRules | RoomPowerLevels | MessageEvent | CommandEvent - | CommandResultEvent | ReactionEvent | CardMessageEvent | RoomNameEvent diff --git a/packages/host/app/commands/get-boxel-ui-state.ts b/packages/host/app/commands/get-boxel-ui-state.ts new file mode 100644 index 0000000000..9af8caba2b --- /dev/null +++ b/packages/host/app/commands/get-boxel-ui-state.ts @@ -0,0 +1,27 @@ +import { inject as service } from '@ember/service'; + +import { GetBoxelUIStateResult } from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type OperatorModeStateService from '../services/operator-mode-state-service'; + +export default class GetBoxelUIStateCommand extends HostBaseCommand< + undefined, + GetBoxelUIStateResult +> { + @service declare operatorModeStateService: OperatorModeStateService; + static displayName = 'GetBoxelUIStateCommand'; + description = + 'Get information about the current state of the Boxel UI, including the current submode, what cards are open, and what room, if any, the AI assistant is showing.'; + async getInputType() { + return undefined; + } + protected async run() { + let commandModule = await this.loadCommandModule(); + const { GetBoxelUIStateResult } = commandModule; + return new GetBoxelUIStateResult({ + submode: this.operatorModeStateService.state.submode, + }); + } +} diff --git a/packages/host/app/lib/matrix-classes/message-builder.ts b/packages/host/app/lib/matrix-classes/message-builder.ts index 568eb116fb..180118fd8b 100644 --- a/packages/host/app/lib/matrix-classes/message-builder.ts +++ b/packages/host/app/lib/matrix-classes/message-builder.ts @@ -14,7 +14,7 @@ import type { CardMessageContent, CardMessageEvent, CommandEvent, - CommandResultEvent, + CommandReactionEventContent, MatrixEvent as DiscreteMatrixEvent, MessageEvent, ReactionEvent, @@ -30,11 +30,7 @@ const ErrorMessage: Record = { export default class MessageBuilder { constructor( - private event: - | MessageEvent - | CommandEvent - | CardMessageEvent - | CommandResultEvent, + private event: MessageEvent | CommandEvent | CardMessageEvent, owner: Owner, private builderContext: { effectiveEventId: string; @@ -60,7 +56,6 @@ export default class MessageBuilder { transactionId: this.event.unsigned?.transaction_id || null, attachedCardIds: null, command: null, - commandResult: null, status: this.event.status, eventId: this.builderContext.effectiveEventId, index: this.builderContext.index, @@ -126,7 +121,6 @@ export default class MessageBuilder { ) { messageArgs.formattedMessage = this.formattedMessageForCommand; messageArgs.command = await this.buildMessageCommand(); - messageArgs.commandResult = await this.buildCommandResultCard(); messageArgs.isStreamingFinished = true; } return messageArgs; @@ -139,6 +133,7 @@ export default class MessageBuilder { let r = e.content['m.relates_to']; return ( e.type === 'm.reaction' && + e.content.msgtype === 'org.boxel.command_result' && r?.rel_type === 'm.annotation' && (r?.event_id === event.content.data.eventId || r?.event_id === event.event_id || @@ -146,38 +141,23 @@ export default class MessageBuilder { ); }) as ReactionEvent | undefined; let status: CommandStatus = 'ready'; - if (annotation?.content['m.relates_to'].key === 'applied') { + let reactionContent = annotation?.content as + | CommandReactionEventContent + | undefined; + if (reactionContent && reactionContent['m.relates_to'].key === 'applied') { status = 'applied'; } + let commandResultCardId: string | undefined = + reactionContent?.data.card_event_id ?? undefined; let messageCommand = new MessageCommand( command.id, command.name, command.arguments, this.builderContext.effectiveEventId, status, + commandResultCardId, getOwner(this)!, ); return messageCommand; } - - private async buildCommandResultCard() { - let event = this.event as CommandEvent; - let commandResultEvent = this.builderContext.events.find( - (e) => - e.type === 'm.room.message' && - e.content.msgtype === 'org.boxel.commandResult' && - e.content['m.relates_to']?.rel_type === 'm.annotation' && - e.content['m.relates_to'].event_id === event.content.data.eventId, - ) as CommandResultEvent; - let r = commandResultEvent?.content?.result - ? await this.commandService.createCommandResultArgs( - event, - commandResultEvent, - ) - : undefined; - let commandResult = r - ? await this.commandService.createCommandResult(r) - : undefined; - return commandResult; - } } diff --git a/packages/host/app/lib/matrix-classes/message-command.ts b/packages/host/app/lib/matrix-classes/message-command.ts index b3dd18e292..1759a4f75e 100644 --- a/packages/host/app/lib/matrix-classes/message-command.ts +++ b/packages/host/app/lib/matrix-classes/message-command.ts @@ -13,6 +13,7 @@ export default class MessageCommand { public payload: any, //arguments of toolCall. Its not called arguments due to lint public eventId: string, private commandStatus: CommandStatus, + public commandResultCardId: string | undefined, owner: Owner, ) { setOwner(this, owner); diff --git a/packages/host/app/lib/matrix-classes/room.ts b/packages/host/app/lib/matrix-classes/room.ts index 758f8ae184..062ae7d505 100644 --- a/packages/host/app/lib/matrix-classes/room.ts +++ b/packages/host/app/lib/matrix-classes/room.ts @@ -24,6 +24,8 @@ export default class Room { @tracked private _events: DiscreteMatrixEvent[] = []; @tracked private _roomState: MatrixSDK.RoomState | undefined; + constructor(public readonly roomId: string) {} + readonly mutex = new Mutex(); get events() { diff --git a/packages/host/app/resources/room.ts b/packages/host/app/resources/room.ts index 997033dada..1ab0d0d087 100644 --- a/packages/host/app/resources/room.ts +++ b/packages/host/app/resources/room.ts @@ -252,8 +252,8 @@ export class RoomResource extends Resource { let effectiveEventId = event.event_id; let update = false; if (event.content['m.relates_to']?.rel_type == 'm.annotation') { - // we have to trigger a message field update if there is a reaction event so apply button state reliably updates - // otherwise, the message field (may) still but it occurs only accidentally because of a ..thinking event + // ensure that we update a message when we see a reaction event for it, since we merge data from the reaction event + // into the message state (i.e. apply button, command result) update = true; } else if (event.content['m.relates_to']?.rel_type === 'm.replace') { effectiveEventId = event.content['m.relates_to'].event_id; @@ -293,7 +293,7 @@ export class RoomResource extends Resource { return; } if (event.content.msgtype === 'org.boxel.commandResult') { - //don't display command result in the room as a message + // Legacy data return; } diff --git a/packages/host/app/services/command-service.ts b/packages/host/app/services/command-service.ts index eb55531221..01a2fedb98 100644 --- a/packages/host/app/services/command-service.ts +++ b/packages/host/app/services/command-service.ts @@ -13,11 +13,10 @@ import { v4 as uuidv4 } from 'uuid'; import { Command, - type LooseSingleCardDocument, type PatchData, - baseRealm, CommandContext, CommandContextStamp, + baseRealm, } from '@cardstack/runtime-common'; import { type CardTypeFilter, @@ -30,11 +29,8 @@ import type OperatorModeStateService from '@cardstack/host/services/operator-mod import type Realm from '@cardstack/host/services/realm'; import type { CardDef } from 'https://cardstack.com/base/card-api'; -import type { CommandResult } from 'https://cardstack.com/base/command-result'; -import type { - CommandEvent, - CommandResultEvent, -} from 'https://cardstack.com/base/matrix-event'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; import MessageCommand from '../lib/matrix-classes/message-command'; import { shortenUuid } from '../utils/uuid'; @@ -42,12 +38,15 @@ import { shortenUuid } from '../utils/uuid'; import CardService from './card-service'; import RealmServerService from './realm-server'; +import type LoaderService from './loader-service'; + const DELAY_FOR_APPLYING_UI = isTesting() ? 50 : 500; export default class CommandService extends Service { @service private declare operatorModeStateService: OperatorModeStateService; @service private declare matrixService: MatrixService; @service private declare cardService: CardService; + @service private declare loaderService: LoaderService; @service private declare realm: Realm; @service private declare realmServer: RealmServerService; currentlyExecutingCommandEventIds = new TrackedSet(); @@ -94,19 +93,12 @@ export default class CommandService extends Service { } else { typedInput = undefined; } - let res = await command.execute(typedInput); - await this.matrixService.sendReactionEvent( + let resultCard = await command.execute(typedInput); + await this.matrixService.sendCommandResultEvent( event.room_id!, event.event_id!, - 'applied', + resultCard, ); - if (res) { - await this.matrixService.sendCommandResultMessage( - event.room_id!, - event.event_id!, - res, - ); - } } finally { this.currentlyExecutingCommandEventIds.delete(event.event_id!); } @@ -124,7 +116,7 @@ export default class CommandService extends Service { //TODO: Convert to non-EC async method after fixing CS-6987 run = task(async (command: MessageCommand, roomId: string) => { let { payload, eventId } = command; - let res: any; + let resultCard: CardDef | undefined; try { this.matrixService.failedCommandState.delete(eventId); this.currentlyExecutingCommandEventIds.add(eventId); @@ -146,7 +138,7 @@ export default class CommandService extends Service { } else { typedInput = undefined; } - [res] = await all([ + [resultCard] = await all([ await commandToRun.execute(typedInput), await timeout(DELAY_FOR_APPLYING_UI), // leave a beat for the "applying" state of the UI to be shown ]); @@ -156,7 +148,7 @@ export default class CommandService extends Service { "Patch command can't run because it doesn't have all the fields in arguments returned by open ai", ); } - res = await this.operatorModeStateService.patchCard.perform( + await this.operatorModeStateService.patchCard.perform( payload?.attributes?.cardId, { attributes: payload?.attributes?.patch?.attributes, @@ -179,9 +171,16 @@ export default class CommandService extends Service { ), ), ); - res = await Promise.all( + let resultCardDocs = await Promise.all( instances.map((c) => this.cardService.serializeCard(c)), ); + let commandModule = await this.loaderService.loader.import< + typeof BaseCommandModule + >(`${baseRealm.url}command`); + let { SearchCardsResult } = commandModule; + resultCard = new SearchCardsResult({ + cardDocs: resultCardDocs, + }); } else if (command.name === 'generateAppModule') { let realmURL = this.operatorModeStateService.realmURL; @@ -197,10 +196,15 @@ export default class CommandService extends Service { `untitled-app-${timestamp}`; let moduleId = `${realmURL}AppModules/${fileName}-${timestamp}`; let content = (payload.moduleCode as string) ?? ''; - res = await this.cardService.saveSource( - new URL(`${moduleId}.gts`), - content, - ); + let commandModule = await this.loaderService.loader.import< + typeof BaseCommandModule + >(`${baseRealm.url}command`); + let { LegacyGenerateAppModuleResult } = commandModule; + await this.cardService.saveSource(new URL(`${moduleId}.gts`), content); + resultCard = new LegacyGenerateAppModuleResult({ + moduleId: `${moduleId}.gts`, + source: content, + }); if (!payload.attached_card_id) { throw new Error( `Could not update 'moduleURL' with a link to the generated module.`, @@ -216,10 +220,11 @@ export default class CommandService extends Service { `Unrecognized command: ${command.name}. This command may have been associated with a previous browser session.`, ); } - await this.matrixService.sendReactionEvent(roomId, eventId, 'applied'); - if (res) { - await this.matrixService.sendCommandResultMessage(roomId, eventId, res); - } + await this.matrixService.sendCommandResultEvent( + roomId, + eventId, + resultCard, + ); } catch (e) { let error = typeof e === 'string' @@ -233,47 +238,6 @@ export default class CommandService extends Service { this.currentlyExecutingCommandEventIds.delete(eventId); } }); - - async createCommandResult(args: Record) { - return await this.matrixService.createCard( - { - name: 'CommandResult', - module: `${baseRealm.url}command-result`, - }, - args, - ); - } - - deserializeResults(event: CommandResultEvent) { - let serializedResults: LooseSingleCardDocument[] = - typeof event?.content?.result === 'string' - ? JSON.parse(event.content.result) - : event.content.result; - return Array.isArray(serializedResults) ? serializedResults : []; - } - - async createCommandResultArgs( - commandEvent: CommandEvent, - commandResultEvent: CommandResultEvent, - ) { - let toolCall = commandEvent.content.data.toolCall; - if (toolCall.name === 'searchCard') { - let results = this.deserializeResults(commandResultEvent); - return { - toolCallName: toolCall.name, - toolCallId: toolCall.id, - toolCallArgs: toolCall.arguments, - cardIds: results.map((r) => r.data.id), - }; - } else if (toolCall.name === 'patchCard') { - return { - toolCallName: toolCall.name, - toolCallId: toolCall.id, - toolCallArgs: toolCall.arguments, - }; - } - return; - } } type PatchPayload = { attributes: { cardId: string; patch: PatchData } }; diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index e387930ef8..e1e442aee0 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -24,7 +24,6 @@ import { markdownToHtml, splitStringIntoChunks, baseRealm, - loaderFor, LooseCardResource, ResolvedCodeRef, } from '@cardstack/runtime-common'; @@ -50,12 +49,14 @@ import { getMatrixProfile } from '@cardstack/host/resources/matrix-profile'; import type { Base64ImageField as Base64ImageFieldType } from 'https://cardstack.com/base/base64-image'; import { BaseDef, type CardDef } from 'https://cardstack.com/base/card-api'; import type * as CardAPI from 'https://cardstack.com/base/card-api'; -import type { MatrixEvent as DiscreteMatrixEvent } from 'https://cardstack.com/base/matrix-event'; +import type { + CommandReactionEventContent, + MatrixEvent as DiscreteMatrixEvent, +} from 'https://cardstack.com/base/matrix-event'; import type { CardMessageContent, CardFragmentContent, ReactionEventContent, - CommandResultContent, } from 'https://cardstack.com/base/matrix-event'; import { SkillCard } from 'https://cardstack.com/base/skill-card'; @@ -450,11 +451,7 @@ export default class MatrixService extends Service { async sendEvent( roomId: string, eventType: string, - content: - | CardMessageContent - | CardFragmentContent - | ReactionEventContent - | CommandResultContent, + content: CardMessageContent | CardFragmentContent | ReactionEventContent, ) { let roomData = await this.ensureRoomData(roomId); return roomData.mutex.dispatch(async () => { @@ -470,49 +467,31 @@ export default class MatrixService extends Service { }); } - async sendReactionEvent(roomId: string, eventId: string, status: string) { - let content: ReactionEventContent = { - 'm.relates_to': { - event_id: eventId, - key: status, - rel_type: 'm.annotation', - }, - }; - try { - return await this.sendEvent(roomId, 'm.reaction', content); - } catch (e) { - throw new Error( - `Error sending reaction event: ${ - 'message' in (e as Error) ? (e as Error).message : e - }`, - ); - } - } - - async sendCommandResultMessage( + async sendCommandResultEvent( roomId: string, - eventId: string, - result: Record, + invokedToolFromEventId: string, + resultCard?: CardDef, ) { - let body = `Command Results from command event ${eventId}`; - let html = markdownToHtml(body); - let jsonStringResult = JSON.stringify(result); - let content: CommandResultContent = { + let resultCardEventId: string | undefined; + if (resultCard) { + [resultCardEventId] = await this.addCardsToRoom([resultCard], roomId); + } + let content: CommandReactionEventContent = { + msgtype: 'org.boxel.command_result', 'm.relates_to': { - event_id: eventId, + event_id: invokedToolFromEventId, + key: 'applied', rel_type: 'm.annotation', - key: 'applied', //this is aggregated key. All annotations must have one. This identifies the reaction event. }, - body, - formatted_body: html, - msgtype: 'org.boxel.commandResult', - result: jsonStringResult, + data: { + card_event_id: resultCardEventId ?? null, + }, }; try { - return await this.sendEvent(roomId, 'm.room.message', content); + return await this.sendEvent(roomId, 'm.reaction', content); } catch (e) { throw new Error( - `Error sending reaction event: ${ + `Error sending command result reaction event: ${ 'message' in (e as Error) ? (e as Error).message : e }`, ); @@ -538,7 +517,7 @@ export default class MatrixService extends Service { } let serializedCards = await Promise.all( cards.map(async (card) => { - let { Base64ImageField } = await loaderFor(card).import<{ + let { Base64ImageField } = await this.loaderService.loader.import<{ Base64ImageField: typeof Base64ImageFieldType; }>(`${baseRealm.url}base64-image`); return await this.cardService.serializeCard(card, { @@ -971,7 +950,7 @@ export default class MatrixService extends Service { private async ensureRoomData(roomId: string) { let roomData = this.getRoomData(roomId); if (!roomData) { - roomData = new Room(); + roomData = new Room(roomId); let rs = await this.getRoomState(roomId); if (rs) { roomData.notifyRoomStateUpdated(rs); @@ -1146,6 +1125,53 @@ export default class MatrixService extends Service { eventsDrained!(); } + private async ensureCardFragmentsLoaded(cardEventId: string, roomData: Room) { + let currentFragmentId: string | undefined = cardEventId; + do { + let fragmentEvent = roomData.events.find( + (e: DiscreteMatrixEvent) => e.event_id === currentFragmentId, + ); + let fragmentData: CardFragmentContent['data']; + if (!fragmentEvent) { + fragmentEvent = (await this.client?.fetchRoomEvent( + roomData.roomId, + currentFragmentId ?? '', + )) as DiscreteMatrixEvent; + if ( + fragmentEvent.type !== 'm.room.message' || + fragmentEvent.content.msgtype !== 'org.boxel.cardFragment' + ) { + throw new Error( + `Expected event ${currentFragmentId} to be 'org.boxel.card' but was ${JSON.stringify( + fragmentEvent, + )}`, + ); + } + await this.addRoomEvent({ + ...fragmentEvent, + }); + fragmentData = ( + typeof fragmentEvent.content.data === 'string' + ? JSON.parse((fragmentEvent.content as any).data) + : fragmentEvent.content.data + ) as CardFragmentContent['data']; + } else { + if ( + fragmentEvent.type !== 'm.room.message' || + fragmentEvent.content.msgtype !== 'org.boxel.cardFragment' + ) { + throw new Error( + `Expected event to be 'org.boxel.cardFragment' but was ${JSON.stringify( + fragmentEvent, + )}`, + ); + } + fragmentData = fragmentEvent.content.data; + } + currentFragmentId = fragmentData?.nextFragment; // using '?' so we can be kind to older event schemas + } while (currentFragmentId); + } + private async processDecryptedEvent(event: TempEvent, oldEventId?: string) { let { room_id: roomId } = event; if (!roomId) { @@ -1193,52 +1219,22 @@ export default class MatrixService extends Service { Array.isArray(data.attachedCardsEventIds) ) { for (let attachedCardEventId of data.attachedCardsEventIds) { - let currentFragmentId: string | undefined = attachedCardEventId; - do { - let fragmentEvent = roomData.events.find( - (e: DiscreteMatrixEvent) => e.event_id === currentFragmentId, - ); - let fragmentData: CardFragmentContent['data']; - if (!fragmentEvent) { - fragmentEvent = (await this.client?.fetchRoomEvent( - roomId, - currentFragmentId ?? '', - )) as DiscreteMatrixEvent; - if ( - fragmentEvent.type !== 'm.room.message' || - fragmentEvent.content.msgtype !== 'org.boxel.cardFragment' - ) { - throw new Error( - `Expected event ${currentFragmentId} to be 'org.boxel.card' but was ${JSON.stringify( - fragmentEvent, - )}`, - ); - } - await this.addRoomEvent({ - ...fragmentEvent, - }); - fragmentData = ( - typeof fragmentEvent.content.data === 'string' - ? JSON.parse((fragmentEvent.content as any).data) - : fragmentEvent.content.data - ) as CardFragmentContent['data']; - } else { - if ( - fragmentEvent.type !== 'm.room.message' || - fragmentEvent.content.msgtype !== 'org.boxel.cardFragment' - ) { - throw new Error( - `Expected event to be 'org.boxel.cardFragment' but was ${JSON.stringify( - fragmentEvent, - )}`, - ); - } - fragmentData = fragmentEvent.content.data; - } - currentFragmentId = fragmentData?.nextFragment; // using '?' so we can be kind to older event schemas - } while (currentFragmentId); + this.ensureCardFragmentsLoaded(attachedCardEventId, roomData); } } + } else if ( + roomData && + event.type === 'm.reaction' && + event.content?.msgtype === 'org.boxel.command_result' + ) { + let data = ( + typeof event.content.data === 'string' + ? JSON.parse(event.content.data) + : event.content.data + ) as CommandReactionEventContent['data']; + if (data.card_event_id) { + this.ensureCardFragmentsLoaded(data.card_event_id, roomData); + } } else if ( event.type === 'm.room.message' && event.content?.msgtype === 'org.boxel.realm-server-event' diff --git a/packages/host/tests/acceptance/commands-test.gts b/packages/host/tests/acceptance/commands-test.gts index b1f4455e17..aa2bb3f611 100644 --- a/packages/host/tests/acceptance/commands-test.gts +++ b/packages/host/tests/acceptance/commands-test.gts @@ -12,7 +12,10 @@ import { import { module, test } from 'qunit'; -import { GridContainer } from '@cardstack/boxel-ui/components'; +import { + BoxelInputValidationState, + GridContainer, +} from '@cardstack/boxel-ui/components'; import { baseRealm, Command } from '@cardstack/runtime-common'; @@ -51,6 +54,7 @@ import { import { setupMockMatrix } from '../helpers/mock-matrix'; import { setupApplicationTest } from '../helpers/setup'; +import GetBoxelUIStateCommand from '@cardstack/host/commands/get-boxel-ui-state'; let matrixRoomId = ''; module('Acceptance | Commands tests', function (hooks) { @@ -263,6 +267,30 @@ module('Acceptance | Commands tests', function (hooks) { }); await sleepCommand.execute(new ScheduleMeetingInput()); }; + runWhatSubmodeAmIIn = async () => { + let commandContext = this.args.context?.commandContext; + if (!commandContext) { + console.error('No command context found'); + return; + } + let createAIAssistantRoomCommand = new CreateAIAssistantRoomCommand( + commandContext, + ); + let { roomId } = await createAIAssistantRoomCommand.execute({ + name: 'Submode Check', + }); + let getBoxelUIStateCommand = new GetBoxelUIStateCommand( + commandContext, + ); + let sendAiAssistantMessageCommand = new SendAiAssistantMessageCommand( + commandContext, + ); + await sendAiAssistantMessageCommand.execute({ + roomId, + prompt: 'What submode am I in?', + commands: [{ command: getBoxelUIStateCommand, autoExecute: true }], + }); + }; }; } @@ -705,4 +737,93 @@ module('Acceptance | Commands tests', function (hooks) { ) .includesText('Meeting with Hassan'); }); + + test('a command executed via the AI Assistant shows the result as an embedded card', async function (assert) { + await visitOperatorMode({ + stacks: [ + [ + { + id: `${testRealmURL}index`, + format: 'isolated', + }, + ], + ], + }); + const testCard = `${testRealmURL}Person/hassan`; + + await click('[data-test-boxel-filter-list-button="All Cards"]'); + await click( + `[data-test-stack-card="${testRealmURL}index"] [data-test-cards-grid-item="${testCard}"]`, + ); + await click('[data-test-what-submode-am-i-in]'); + await click('[data-test-open-ai-assistant]'); + await waitUntil(() => getRoomIds().length > 0); + let roomId = getRoomIds().pop()!; + let message = getRoomEvents(roomId).pop()!; + assert.strictEqual(message.content.msgtype, 'org.boxel.message'); + let boxelMessageData = JSON.parse(message.content.data); + assert.strictEqual(boxelMessageData.context.tools.length, 1); + assert.strictEqual(boxelMessageData.context.tools[0].type, 'function'); + let toolName = boxelMessageData.context.tools[0].function.name; + assert.ok( + /^GetBoxelUIState/.test(toolName), + 'The function name starts with GetBoxelUIStateCommand_', + ); + assert.strictEqual( + boxelMessageData.context.tools[0].function.description, + 'Get information about the current state of the Boxel UI, including the current submode, what cards are open, and what room, if any, the AI assistant is showing.', + ); + // TODO: do we need to include `required: ['attributes'],` in the parameters object? If so, how? + assert.deepEqual(boxelMessageData.context.tools[0].function.parameters, { + type: 'object', + properties: { + description: { + type: 'string', + }, + attributes: { + type: 'object', + properties: {}, + }, + relationships: { + properties: {}, + type: 'object', + }, + }, + required: ['attributes', 'description'], + }); + simulateRemoteMessage(roomId, '@aibot:localhost', { + body: 'Inspecting the current UI state', + msgtype: 'org.boxel.command', + formatted_body: 'Inspecting the current UI state', + format: 'org.matrix.custom.html', + data: JSON.stringify({ + toolCall: { + name: toolName, + arguments: { + attributes: {}, + }, + }, + eventId: '__EVENT_ID__', + }), + 'm.relates_to': { + rel_type: 'm.replace', + event_id: '__EVENT_ID__', + }, + }); + await settled(); + assert + .dom( + '[data-test-message-idx="0"][data-test-boxel-message-from="testuser"]', + ) + .containsText('What submode am I in?'); + assert + .dom('[data-test-message-idx="1"][data-test-boxel-message-from="aibot"]') + .containsText('Inspecting the current UI state'); + assert + .dom('[data-test-message-idx="1"] [data-test-apply-state="applied"]') + .exists(); + assert + .dom('[data-test-message-idx="1"] [data-test-boxel-command-result]') + .containsText('Submode: Interact'); + }); }); diff --git a/packages/runtime-common/commands.ts b/packages/runtime-common/commands.ts index d7f0d0b178..d3474daab5 100644 --- a/packages/runtime-common/commands.ts +++ b/packages/runtime-common/commands.ts @@ -119,6 +119,18 @@ export abstract class Command< mappings: Map, ): Promise { let InputType = await this.getInputType(); + if (!InputType) { + return { + attributes: { + type: 'object', + properties: {}, + }, + relationships: { + type: 'object', + properties: {}, + }, + }; + } return generateJsonSchemaForCardType( InputType as unknown as typeof CardDef, // TODO: can we do better type-wise? cardApi,