Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
}
118 changes: 99 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 and drainCodePatchProcessingQueue wait
// 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,
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,68 @@ 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) {
let commandRequestId = messageCommand.commandRequest.id;
// Without a tool call id we can't address a command result event, so
// there's nothing to invalidate.
if (!commandRequestId) {
continue;
}
if (this.currentlyExecutingCommandRequestIds.has(commandRequestId)) {
continue;
}
if (this.executedCommandRequestIds.has(commandRequestId)) {
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, commandRequestId) ??
messageCommand.eventId;
await this.matrixService.sendCommandResultEvent({
roomId,
invokedToolFromEventId,
toolCallId: commandRequestId,
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 {
Expand All @@ -444,7 +524,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