diff --git a/packages/host/app/components/matrix/room-message-command.gts b/packages/host/app/components/matrix/room-message-command.gts index b409fe470b..1327bc15d2 100644 --- a/packages/host/app/components/matrix/room-message-command.gts +++ b/packages/host/app/components/matrix/room-message-command.gts @@ -26,6 +26,7 @@ import { import type { CommandRequest } from '@cardstack/runtime-common/commands'; +import { isAutoExecutableCommand } from '@cardstack/host/lib/command-auto-execute'; import type MessageCommand from '@cardstack/host/lib/matrix-classes/message-command'; import type { RoomResource } from '@cardstack/host/resources/room'; @@ -77,7 +78,33 @@ export default class RoomMessageCommand extends Component { if (this.didFailCorrectnessCheck) { return 'applied-with-error'; } - return this.args.messageCommand?.status ?? 'ready'; + let status = this.args.messageCommand?.status; + // Mirror the Accept All bar fix: for any command the host will + // auto-execute (checkCorrectness, requiresApproval=false, LLM mode + // 'act'), present the applying spinner immediately on message-landed + // instead of the clickable Run button. Without this, the per-command + // Apply button flashes through 'ready' for the ~100ms debounce window + // before command-service starts the run. If validation later fails + // in the drain, command-service dispatches an `invalid` commandResult + // event and the button transitions to its invalid state — no risk of + // the spinner sticking. + if ((status === 'ready' || status === undefined) && this.willAutoExecute) { + return 'applying'; + } + return status ?? 'ready'; + } + + private get willAutoExecute() { + let activeMode = this.args.roomResource.getActiveLLMModeForMessage( + this.args.messageCommand.eventId, + ); + let isOwnedByCurrentAgent = + this.args.messageCommand.message.agentId === this.matrixService.agentId; + return isAutoExecutableCommand( + this.args.messageCommand, + activeMode, + isOwnedByCurrentAgent, + ); } @use private commandResultCard = resource(() => { @@ -233,7 +260,7 @@ export default class RoomMessageCommand extends Component { @monacoSDK={{@monacoSDK}} @codeData={{hash code=this.previewCommandCode language='json'}} data-test-command-card-idle={{not - (eq @messageCommand.status 'applying') + (eq this.applyButtonState 'applying') }} as |codeBlock| > @@ -253,7 +280,7 @@ export default class RoomMessageCommand extends Component { @monacoSDK={{@monacoSDK}} @codeData={{hash code=this.previewCommandCode language='json'}} data-test-command-card-idle={{not - (eq @messageCommand.status 'applying') + (eq this.applyButtonState 'applying') }} as |codeBlock| > diff --git a/packages/host/app/components/matrix/room.gts b/packages/host/app/components/matrix/room.gts index 9658112b29..d3845deb5f 100644 --- a/packages/host/app/components/matrix/room.gts +++ b/packages/host/app/components/matrix/room.gts @@ -55,6 +55,7 @@ import { DEFAULT_FALLBACK_MODELS } from '@cardstack/runtime-common/matrix-consta import UpdateRoomSkillsCommand from '@cardstack/host/commands/update-room-skills'; import ENV from '@cardstack/host/config/environment'; +import { isAutoExecutableCommand } from '@cardstack/host/lib/command-auto-execute'; import type { FileUploadState } from '@cardstack/host/lib/file-upload-state'; import type { Message } from '@cardstack/host/lib/matrix-classes/message'; import type { StackItem } from '@cardstack/host/lib/stack-item'; @@ -1861,13 +1862,25 @@ export default class Room extends Component { if (!lastMessage || !lastMessage.commands) { return []; } + let roomResource = this.matrixService.roomResources.get(this.args.roomId); + let activeMode = roomResource?.getActiveLLMModeForMessage( + lastMessage.eventId, + ); + let isOwnedByCurrentAgent = + lastMessage.agentId === this.matrixService.agentId; return lastMessage.commands.filter( (command) => (command.status === 'ready' || command.status === undefined) && !this.commandService.currentlyExecutingCommandRequestIds.has( command.id!, ) && - !this.commandService.executedCommandRequestIds.has(command.id!), + !this.commandService.executedCommandRequestIds.has(command.id!) && + // Commands destined for auto-execution must not surface the manual + // Accept All / Cancel bar, even during the ~100ms debounce before + // command-service flips `acceptingAllRoomIds`. Without this filter, + // the bar paints and then yanks itself once auto-execution starts, + // which is the CS-11647 glitch. + !isAutoExecutableCommand(command, activeMode, isOwnedByCurrentAgent), ); } diff --git a/packages/host/app/lib/command-auto-execute.ts b/packages/host/app/lib/command-auto-execute.ts new file mode 100644 index 0000000000..5488edff1a --- /dev/null +++ b/packages/host/app/lib/command-auto-execute.ts @@ -0,0 +1,35 @@ +import type { LLMMode } from '@cardstack/runtime-common/matrix-constants'; + +import type MessageCommand from './matrix-classes/message-command'; + +export const CHECK_CORRECTNESS_COMMAND_NAME = 'checkCorrectness'; + +// Single source of truth for "this command runs without user approval". +// Used by command-service (to decide whether to auto-run) and by the +// room / room-message-command components (to decide whether to render +// the Accept All bar and the per-command Apply button). Keeping all +// call sites on the same predicate prevents them from drifting and +// reintroducing the action-bar flash that prompted CS-11647. +// +// `isOwnedByCurrentAgent` mirrors the agentId gate in +// command-service.drainCommandProcessingQueue: a command sent by +// another agent is never auto-executed, even if it would otherwise +// satisfy one of the three branches below. Callers that don't track +// agents (e.g. unit tests) can pass `true` to focus on the other +// conditions. +export function isAutoExecutableCommand( + command: Pick, + activeLLMMode: LLMMode | undefined, + isOwnedByCurrentAgent: boolean, +): boolean { + if (!isOwnedByCurrentAgent) { + return false; + } + if (command.name === CHECK_CORRECTNESS_COMMAND_NAME) { + return true; + } + if (command.requiresApproval === false) { + return true; + } + return activeLLMMode === 'act'; +} diff --git a/packages/host/app/services/command-service.ts b/packages/host/app/services/command-service.ts index af34780c10..643fd27d75 100644 --- a/packages/host/app/services/command-service.ts +++ b/packages/host/app/services/command-service.ts @@ -36,6 +36,10 @@ import type Realm from '@cardstack/host/services/realm'; import type { CardDef } from 'https://cardstack.com/base/card-api'; import type { CodePatchStatus } from 'https://cardstack.com/base/matrix-event'; +import { + CHECK_CORRECTNESS_COMMAND_NAME, + isAutoExecutableCommand, +} from '../lib/command-auto-execute'; import LimitedSet from '../lib/limited-set'; import type LoaderService from './loader-service'; @@ -47,10 +51,15 @@ import type StoreService from './store'; import type { CodeData } from '../lib/formatted-message/utils'; import type MessageCodePatchResult from '../lib/matrix-classes/message-code-patch-result'; import type MessageCommand from '../lib/matrix-classes/message-command'; +import type { RoomResource } from '../resources/room'; import type { IEvent } from 'matrix-js-sdk'; const DELAY_FOR_APPLYING_UI = isTesting() ? 50 : 500; -const CHECK_CORRECTNESS_COMMAND_NAME = 'checkCorrectness'; +// How long drainCommandProcessingQueue waits for a room resource that's +// still processing before giving up on the event. In tests we shorten this +// so the stuck-timeout invalidation path can be exercised in a single test +// without holding a real test open for a minute. +const STUCK_PROCESSING_TIMEOUT_MS = isTesting() ? 1000 : 60_000; type GenericCommand = Command< typeof CardDef | undefined, @@ -320,7 +329,7 @@ export default class CommandService extends Service { `Room resource not found for room id ${roomId}, this should not happen`, ); } - let timeout = Date.now() + 60_000; // reset the timer to avoid a long wait if the room resource is processing + let timeout = Date.now() + STUCK_PROCESSING_TIMEOUT_MS; // reset the timer to avoid a long wait if the room resource is processing let currentRoomProcessingTimestamp = roomResource.processingLastStartedAt; while ( @@ -337,9 +346,23 @@ export default class CommandService extends Service { currentRoomProcessingTimestamp === roomResource.processingLastStartedAt ) { - // room seems to be stuck processing, so we will log and skip this event + // Room processing is wedged. The synthetic 'applying' state in + // room-message-command.gts shows the spinner the moment an + // auto-executable command lands and only clears when we dispatch + // a terminal commandResult ('applied' or 'invalid'). If we just + // logged and continued, the spinner would hang indefinitely with + // no manual Run fallback. Mark each auto-executable command on + // this message invalid so the UI falls through to the + // invalidCommandState "Try Anyway" branch; manual-approval + // commands are left in 'ready' so the action bar's Run button + // remains the user's fallback. console.error( - `Room resource for room ${roomId} seems to be stuck processing, skipping event ${eventId}`, + `Room resource for room ${roomId} seems to be stuck processing, invalidating auto-executable commands on event ${eventId}`, + ); + await this.invalidateAutoExecutableCommandsForStuckProcessing( + roomResource, + roomId!, + eventId!, ); continue; } @@ -379,26 +402,21 @@ export default class CommandService extends Service { continue; } - // Get the LLM mode that was active when this message was created let activeModeAtMessageTime = roomResource.getActiveLLMModeForMessage( message.eventId, ); - // Auto-execute if LLM mode is 'act' AND the command came after the LLM mode was set to 'act', - // or if requiresApproval is false - let shouldAutoExecute = false; - let isCheckCorrectnessCommand = - messageCommand.name === CHECK_CORRECTNESS_COMMAND_NAME; - + // The outer `message.agentId !== this.matrixService.agentId` + // gate above already short-circuited the not-our-agent case, so + // every command reaching this point is owned by the current + // agent. if ( - isCheckCorrectnessCommand || - messageCommand.requiresApproval === false || - activeModeAtMessageTime === 'act' + isAutoExecutableCommand( + messageCommand, + activeModeAtMessageTime, + true, + ) ) { - shouldAutoExecute = true; - } - - if (shouldAutoExecute) { readyCommands.push(messageCommand); } } @@ -422,6 +440,64 @@ export default class CommandService extends Service { } } + private async invalidateAutoExecutableCommandsForStuckProcessing( + roomResource: RoomResource, + roomId: string, + eventId: string, + ) { + let message = roomResource.messages.find((m) => m.eventId === eventId); + if (!message) { + return; + } + if (message.agentId !== this.matrixService.agentId) { + return; + } + let activeModeAtMessageTime = roomResource.getActiveLLMModeForMessage( + message.eventId, + ); + for (let messageCommand of message.commands) { + if (this.currentlyExecutingCommandRequestIds.has(messageCommand.id!)) { + continue; + } + if (this.executedCommandRequestIds.has(messageCommand.id!)) { + continue; + } + if ( + messageCommand.status === 'applied' || + messageCommand.status === 'invalid' + ) { + continue; + } + if (!messageCommand.name) { + continue; + } + // The outer agentId gate already verified ownership, so this command + // is owned by the current agent. + if ( + !isAutoExecutableCommand(messageCommand, activeModeAtMessageTime, true) + ) { + // Manual-approval commands stay 'ready' — the action bar's Run + // button is still the user's fallback for those. + continue; + } + let invokedToolFromEventId = + this.getCurrentEventIdForCommandRequest( + roomId, + messageCommand.commandRequest.id, + ) ?? messageCommand.eventId; + await this.matrixService.sendCommandResultEvent({ + roomId, + invokedToolFromEventId, + toolCallId: messageCommand.commandRequest.id!, + status: 'invalid', + failureReason: `Room processing did not finish within ${Math.round( + STUCK_PROCESSING_TIMEOUT_MS / 1000, + )}s; command was not started`, + context: await this.operatorModeStateService.getSummaryForAIBot(), + }); + } + } + private async drainCodePatchProcessingQueue() { let waiterToken = commandProcessingWaiter.beginAsync(); try { @@ -444,7 +520,7 @@ export default class CommandService extends Service { `Room resource not found for room id ${roomId}, this should not happen`, ); } - let timeout = Date.now() + 60_000; // reset the timer to avoid a long wait if the room resource is processing + let timeout = Date.now() + STUCK_PROCESSING_TIMEOUT_MS; // reset the timer to avoid a long wait if the room resource is processing let currentRoomProcessingTimestamp = roomResource.processingLastStartedAt; while ( diff --git a/packages/host/tests/integration/components/ai-assistant-panel/commands-test.gts b/packages/host/tests/integration/components/ai-assistant-panel/commands-test.gts index d842037b5d..eb1f39e0c9 100644 --- a/packages/host/tests/integration/components/ai-assistant-panel/commands-test.gts +++ b/packages/host/tests/integration/components/ai-assistant-panel/commands-test.gts @@ -1641,4 +1641,285 @@ module('Integration | ai-assistant-panel | commands', function (hooks) { 'commandResult should not reference the original/streaming event_id once a later event in room.events owns the commandRequest', ); }); + + test('Accept All bar does not flash for an always-auto-executed command (checkCorrectness)', async function (assert) { + let roomId = await renderAiAssistantPanel(); + + // checkCorrectness is on the always-auto-execute list (one of three + // branches in isAutoExecutableCommand). Before the fix, the manual + // approval bar painted for the ~100ms debounce window before + // command-service flipped `acceptingAllRoomIds`; the user saw + // Accept All / Cancel briefly appear then disappear. The bar must + // never paint in its manual-approval branch for any auto-executed + // command, regardless of which condition triggers auto-execute. + // + // agentId must match the host's matrix service so the + // agent-ownership gate in isAutoExecutableCommand passes — otherwise + // the predicate short-circuits to false (the not-our-agent case + // exercised by acceptance/commands-test.gts) and the bar would show + // for an unrelated reason. + simulateRemoteMessage(roomId, '@aibot:localhost', { + body: 'checking correctness', + msgtype: APP_BOXEL_MESSAGE_MSGTYPE, + format: 'org.matrix.custom.html', + isStreamingFinished: true, + [APP_BOXEL_COMMAND_REQUESTS_KEY]: [ + { + id: 'cs-11647-check-correctness', + name: 'checkCorrectness', + arguments: '{}', + }, + ], + data: { + context: { + agentId: getService('matrix-service').agentId, + }, + }, + }); + + await waitFor('[data-test-message-idx="0"]'); + assert + .dom('[data-test-accept-all]') + .doesNotExist( + 'Accept All button must not paint in the debounce window before auto-execute starts', + ); + + await settled(); + assert + .dom('[data-test-accept-all]') + .doesNotExist( + 'Accept All button still hidden after the auto-execute debounce window elapses', + ); + }); + + test('Accept All bar does not flash for a requiresApproval=false command', async function (assert) { + setCardInOperatorModeState(`${testRealmURL}Person/fadhlan`); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + await waitFor('[data-test-person="Fadhlan"]'); + createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'auto-exec via skill', + }); + await settled(); + await click('[data-test-open-ai-assistant]'); + await waitFor('[data-test-room-name="auto-exec via skill"]', { + timeout: 10000, + }); + + // The boxel-environment skill declares read-file-for-ai-assistant with + // requiresApproval=false (see the skill JSON earlier in this module), + // so MessageCommand.requiresApproval is false here — the second + // isAutoExecutableCommand branch. The fix must also suppress the + // Accept All bar for this path. + await addSkillToAiAssistant(`${testRealmURL}Skill/boxel-environment`); + + let roomId = document + .querySelector('[data-test-room]')! + .getAttribute('data-test-room')!; + simulateRemoteMessage(roomId, '@aibot:localhost', { + msgtype: APP_BOXEL_MESSAGE_MSGTYPE, + body: 'Reading hello file', + format: 'org.matrix.custom.html', + isStreamingFinished: true, + [APP_BOXEL_COMMAND_REQUESTS_KEY]: [ + { + id: 'cs-11647-no-approval', + name: 'read-file-for-ai-assistant_a831', + arguments: JSON.stringify({ + attributes: { fileIdentifier: `${testRealmURL}hello.txt` }, + }), + }, + ], + data: { + context: { + agentId: getService('matrix-service').agentId, + }, + }, + }); + + await waitFor('[data-test-message-idx="0"]'); + assert + .dom('[data-test-accept-all]') + .doesNotExist( + 'Accept All button suppressed for requiresApproval=false commands', + ); + }); + + test('per-command Apply button does not flash Run before auto-execute starts', async function (assert) { + let roomId = await renderAiAssistantPanel(); + + // The per-command Apply button (rendered next to each tool-call message) + // has the same race as the Accept All bar: between "message lands" + // and "command-service starts the run", a ready Run button would + // briefly render. The fix presents the applying-spinner immediately + // for any auto-executable command. + simulateRemoteMessage(roomId, '@aibot:localhost', { + body: 'checking correctness', + msgtype: APP_BOXEL_MESSAGE_MSGTYPE, + format: 'org.matrix.custom.html', + isStreamingFinished: true, + [APP_BOXEL_COMMAND_REQUESTS_KEY]: [ + { + id: 'cs-11647-apply-button', + name: 'checkCorrectness', + arguments: '{}', + }, + ], + data: { + context: { + agentId: getService('matrix-service').agentId, + }, + }, + }); + + await waitFor('[data-test-message-idx="0"] [data-test-command-apply]'); + assert + .dom('[data-test-message-idx="0"] [data-test-command-apply="ready"]') + .doesNotExist( + 'per-command Apply button must not show the ready/Run state for an auto-executed command', + ); + assert + .dom('[data-test-message-idx="0"] [data-test-command-apply="applying"]') + .exists('per-command Apply button shows the applying spinner instead'); + // The data-test-command-card-idle attribute is computed from + // applyButtonState (not the raw status); while the synthetic 'applying' + // is on it must NOT mark the card idle. Glimmer omits an attribute + // bound to a falsy expression, so the coherence check is on attribute + // presence — the apply button + the card must agree the spinner is + // up, not just one of them. + assert + .dom('[data-test-message-idx="0"] [data-test-command-card-idle]') + .doesNotExist( + 'data-test-command-card-idle agrees with applyButtonState while the synthetic spinner is on', + ); + }); + + test('stuck-processing helper dispatches an invalid commandResult for each auto-executable command', async function (assert) { + let roomId = await renderAiAssistantPanel(); + + // Verifies the followup-fix for the synthetic-spinner hang flagged in + // the self-review of this branch: drainCommandProcessingQueue must + // dispatch an `invalid` commandResult when a room is wedged, so the + // synthetic 'applying' state in room-message-command.gts falls through + // to the invalidCommandState ("Try Anyway") branch instead of pinning + // a spinner that no terminal event ever clears. + // + // Driving the real wait-loop end-to-end is unstable: roomResource is + // an ember-resources proxy so own-property defines for isProcessing / + // processingLastStartedAt silently no-op, and there's no public seam + // to keep the processRoomTask "running" without rewriting the + // resource itself. Instead, exercise the helper directly with a + // spied sendCommandResultEvent on matrixService — this proves the + // dispatch shape, the per-command iteration, and the failureReason + // text without depending on the proxy internals. + let matrixService = getService('matrix-service'); + let commandService = getService('command-service'); + + simulateRemoteMessage(roomId, '@aibot:localhost', { + body: 'checking correctness', + msgtype: APP_BOXEL_MESSAGE_MSGTYPE, + format: 'org.matrix.custom.html', + isStreamingFinished: true, + [APP_BOXEL_COMMAND_REQUESTS_KEY]: [ + { + id: 'cs-11647-stuck-auto', + name: 'checkCorrectness', + arguments: '{}', + }, + { + id: 'cs-11647-stuck-manual', + name: 'patchCardInstance', + arguments: JSON.stringify({ + attributes: { + cardId: `${testRealmURL}Person/fadhlan`, + patch: { attributes: { firstName: 'Dave' } }, + }, + }), + }, + ], + data: { + context: { + agentId: matrixService.agentId, + }, + }, + }); + await waitFor('[data-test-message-idx="0"] [data-test-command-apply]'); + + let roomResource = matrixService.roomResources.get(roomId)!; + let message = roomResource.messages.find( + (m: any) => m.commands?.length === 2, + ); + assert.ok(message, 'two-command bot message lands in the room resource'); + + let captured: Array<{ toolCallId: string; failureReason?: string }> = []; + let originalSend = matrixService.sendCommandResultEvent.bind(matrixService); + (matrixService as any).sendCommandResultEvent = async (params: any) => { + captured.push({ + toolCallId: params.toolCallId, + failureReason: params.failureReason, + }); + }; + try { + await ( + commandService as any + ).invalidateAutoExecutableCommandsForStuckProcessing( + roomResource, + roomId, + message!.eventId, + ); + } finally { + (matrixService as any).sendCommandResultEvent = originalSend; + } + + assert.strictEqual( + captured.length, + 1, + 'only the auto-executable command is invalidated; manual-approval command is left in ready', + ); + assert.strictEqual( + captured[0]?.toolCallId, + 'cs-11647-stuck-auto', + 'the dispatched invalid event targets the auto-executable command', + ); + assert.true( + (captured[0]?.failureReason ?? '').startsWith( + 'Room processing did not finish within', + ), + 'failureReason surfaces the stuck-processing cause for the invalidCommandState alert', + ); + }); + + test('Accept All bar still renders for a command that requires user approval', async function (assert) { + let roomId = await renderAiAssistantPanel(`${testRealmURL}Person/fadhlan`); + + simulateRemoteMessage(roomId, '@aibot:localhost', { + body: 'patching', + msgtype: APP_BOXEL_MESSAGE_MSGTYPE, + format: 'org.matrix.custom.html', + isStreamingFinished: true, + [APP_BOXEL_COMMAND_REQUESTS_KEY]: [ + { + id: 'cs-11647-patch', + name: 'patchCardInstance', + arguments: JSON.stringify({ + attributes: { + cardId: `${testRealmURL}Person/fadhlan`, + patch: { attributes: { firstName: 'Dave' } }, + }, + }), + }, + ], + }); + + await waitFor('[data-test-accept-all]'); + assert + .dom('[data-test-accept-all]') + .exists( + 'manual approval bar still renders for commands that need user approval', + ); + }); }); diff --git a/packages/host/tests/unit/lib/command-auto-execute-test.ts b/packages/host/tests/unit/lib/command-auto-execute-test.ts new file mode 100644 index 0000000000..5cca7448f8 --- /dev/null +++ b/packages/host/tests/unit/lib/command-auto-execute-test.ts @@ -0,0 +1,85 @@ +import { module, test } from 'qunit'; + +import { + CHECK_CORRECTNESS_COMMAND_NAME, + isAutoExecutableCommand, +} from '@cardstack/host/lib/command-auto-execute'; + +type AutoExecCommandInput = Parameters[0]; + +function cmd( + name: string | undefined, + requiresApproval = true, +): AutoExecCommandInput { + return { name, requiresApproval }; +} + +module('Unit | Lib | command-auto-execute', function () { + test('check-correctness commands auto-execute regardless of mode or approval flag', function (assert) { + assert.true( + isAutoExecutableCommand( + cmd(CHECK_CORRECTNESS_COMMAND_NAME, true), + 'ask', + true, + ), + 'checkCorrectness in ask mode with requiresApproval=true still auto-executes', + ); + assert.true( + isAutoExecutableCommand( + cmd(CHECK_CORRECTNESS_COMMAND_NAME, true), + undefined, + true, + ), + 'checkCorrectness with unknown mode still auto-executes', + ); + }); + + test('commands with requiresApproval=false auto-execute', function (assert) { + assert.true( + isAutoExecutableCommand(cmd('searchCard', false), 'ask', true), + 'requiresApproval=false bypasses approval even in ask mode', + ); + }); + + test('act mode auto-executes commands that would otherwise require approval', function (assert) { + assert.true( + isAutoExecutableCommand(cmd('patchCardInstance', true), 'act', true), + 'patchCardInstance in act mode auto-executes', + ); + }); + + test('ask mode with requiresApproval=true does not auto-execute', function (assert) { + assert.false( + isAutoExecutableCommand(cmd('patchCardInstance', true), 'ask', true), + 'manual approval is required in ask mode', + ); + assert.false( + isAutoExecutableCommand(cmd('patchCardInstance', true), undefined, true), + 'manual approval is required when mode is unknown', + ); + }); + + test('commands owned by another agent never auto-execute', function (assert) { + // Mirrors the agentId gate in command-service.drainCommandProcessingQueue: + // a command whose message came from a different agent must not auto-run + // on this host, even if it would otherwise satisfy one of the auto-exec + // branches. UI callers rely on this so the manual approval bar / per- + // command Apply button stay clickable for non-current-agent commands. + assert.false( + isAutoExecutableCommand( + cmd(CHECK_CORRECTNESS_COMMAND_NAME, true), + 'act', + false, + ), + 'checkCorrectness from another agent does not auto-execute', + ); + assert.false( + isAutoExecutableCommand(cmd('searchCard', false), 'act', false), + 'requiresApproval=false from another agent does not auto-execute', + ); + assert.false( + isAutoExecutableCommand(cmd('patchCardInstance', true), 'act', false), + 'act mode from another agent does not auto-execute', + ); + }); +});