Skip to content
Open
33 changes: 30 additions & 3 deletions packages/host/app/components/matrix/room-message-command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -77,7 +78,33 @@ export default class RoomMessageCommand extends Component<Signature> {
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(() => {
Expand Down Expand Up @@ -233,7 +260,7 @@ export default class RoomMessageCommand extends Component<Signature> {
@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|
>
Expand All @@ -253,7 +280,7 @@ export default class RoomMessageCommand extends Component<Signature> {
@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|
>
Expand Down
15 changes: 14 additions & 1 deletion packages/host/app/components/matrix/room.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1861,13 +1862,25 @@ export default class Room extends Component<Signature> {
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),
);
}

Expand Down
35 changes: 35 additions & 0 deletions packages/host/app/lib/command-auto-execute.ts
Original file line number Diff line number Diff line change
@@ -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<MessageCommand, 'name' | 'requiresApproval'>,
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';
}
114 changes: 95 additions & 19 deletions packages/host/app/services/command-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Comment on lines +58 to +62

type GenericCommand = Command<
typeof CardDef | undefined,
Expand Down Expand Up @@ -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 (
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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(),
});
Comment on lines +483 to +497
}
}

private async drainCodePatchProcessingQueue() {
let waiterToken = commandProcessingWaiter.beginAsync();
try {
Expand All @@ -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 (
Expand Down
Loading
Loading