diff --git a/packages/host/app/lib/matrix-classes/message-builder.ts b/packages/host/app/lib/matrix-classes/message-builder.ts index 97526371770..55bc4603c25 100644 --- a/packages/host/app/lib/matrix-classes/message-builder.ts +++ b/packages/host/app/lib/matrix-classes/message-builder.ts @@ -322,11 +322,14 @@ export default class MessageBuilder { this.builderContext.commandResultEvent ?? (this.builderContext.events.find((e: any) => { let r = e.content['m.relates_to']; + // CS-11736: correlate purely by commandRequestId (the globally-unique + // LLM tool-call id), not the result's m.relates_to.event_id. After a + // reload the m.replace edits are stripped, so the result's link id (the + // final edit Y) no longer matches the loaded original event X — but + // commandRequestId is stable across edits and survives reload. return ( e.type === APP_BOXEL_COMMAND_RESULT_EVENT_TYPE && - r.rel_type === APP_BOXEL_COMMAND_RESULT_REL_TYPE && - (r.event_id === this.event.event_id || - r.event_id === this.builderContext.effectiveEventId) && + r?.rel_type === APP_BOXEL_COMMAND_RESULT_REL_TYPE && e.content.commandRequestId === commandRequest.id ); }) as CommandResultEvent | undefined); diff --git a/packages/host/app/resources/room.ts b/packages/host/app/resources/room.ts index a199eb19204..dda03ac0ff5 100644 --- a/packages/host/app/resources/room.ts +++ b/packages/host/app/resources/room.ts @@ -724,29 +724,32 @@ export class RoomResource extends Resource { event: CommandResultEvent; index: number; }) { - let effectiveEventId = this.getEffectiveEventId(event); + // CS-11736: locate the owning bot message by commandRequestId rather than + // by event_id. The commandResult's m.relates_to.event_id is the latest + // m.replace id Y, but after a reload the m.replace edits are stripped and + // only the original event X is loaded — so matching on event_id misses and + // the status flip is silently lost. commandRequestId is the globally-unique + // LLM tool-call id, stable across edits and present on every edit, so it + // resolves the same bot message on both the live and reload paths. let messageEventWithCommand = this.events.find( (e: any) => e.type === 'm.room.message' && - e.content[APP_BOXEL_COMMAND_REQUESTS_KEY]?.length && - (e.event_id === effectiveEventId || - e.content['m.relates_to']?.event_id === effectiveEventId), - )! as CardMessageEvent | undefined; + e.content[APP_BOXEL_COMMAND_REQUESTS_KEY]?.some( + (cr: any) => cr.id === event.content.commandRequestId, + ), + ) as CardMessageEvent | undefined; + if (!messageEventWithCommand) { + return; + } // CS-11045: _messageCache is keyed by the bot message's effective/parent // event_id — getEffectiveEventId resolves an m.replace event back to its // parent, so when an m.replace Y of original X arrives loadRoomMessage - // keys the cache by X. The commandResult event's own effectiveEventId is - // its m.relates_to.event_id verbatim, which under the CS-11045 host fix - // is the latest m.replace id Y rather than the parent X. Looking up the - // cache by Y would miss and silently fail to flip MessageCommand status - // to 'applied'. Instead, derive the cache key from the bot-message event - // we just located (messageEventWithCommand): for the m.replace event Y, + // keys the cache by X. Derive the cache key from the bot-message event we + // just located (messageEventWithCommand): for the m.replace event Y, // getEffectiveEventId returns parent X — which is what the cache holds. - let messageCacheKey = messageEventWithCommand - ? this.getEffectiveEventId(messageEventWithCommand) - : effectiveEventId; + let messageCacheKey = this.getEffectiveEventId(messageEventWithCommand); let message = this._messageCache.get(messageCacheKey); - if (!message || !messageEventWithCommand) { + if (!message) { return; } 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 eb1f39e0c97..795b12d574a 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 @@ -13,6 +13,7 @@ import { APP_BOXEL_COMMAND_REQUESTS_KEY, APP_BOXEL_COMMAND_RESULT_EVENT_TYPE, APP_BOXEL_COMMAND_RESULT_REL_TYPE, + APP_BOXEL_COMMAND_RESULT_WITH_NO_OUTPUT_MSGTYPE, APP_BOXEL_CONTINUATION_OF_CONTENT_KEY, APP_BOXEL_HAS_CONTINUATION_CONTENT_KEY, APP_BOXEL_MESSAGE_MSGTYPE, @@ -1642,6 +1643,180 @@ module('Integration | ai-assistant-panel | commands', function (hooks) { ); }); + // Regression coverage for CS-11736 (host side). + // After a command is applied on a streamed bot message, its commandResult is + // linked to the latest m.replace edit id Y (CS-11045 wire format). On reload + // the timeline filter strips all m.replace edits, so only the original event + // X is loaded (with aggregated content) and Y no longer exists as a distinct + // event. The host's reload-side matchers used to key off event_id, so the + // dangling Y link broke and the command fell back to 'ready'. The fix keys + // both matchers off the stable commandRequestId instead. + test('CS-11736: an applied command on a streamed bot message still renders applied after reload (m.replace edits stripped)', async function (assert) { + setCardInOperatorModeState(`${testRealmURL}Person/fadhlan`); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + await waitFor('[data-test-person="Fadhlan"]'); + let roomId = createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'test room 1', + }); + + let commandRequestId = 'cs-11736-cmd-request-id'; + // The edit event Y that owned the live commandResult link. On reload it has + // been stripped by the m.replace timeline filter, so no loaded event has + // this id — the result's m.relates_to.event_id dangles. + let strippedEditEventId = 'cs-11736-stripped-edit-event-id'; + + // The only bot message that survives reload: the original event X with the + // final edit's content aggregated in, so it carries the command request. + simulateRemoteMessage(roomId, '@aibot:localhost', { + msgtype: APP_BOXEL_MESSAGE_MSGTYPE, + body: 'Changing first name to Evie', + format: 'org.matrix.custom.html', + isStreamingFinished: true, + [APP_BOXEL_COMMAND_REQUESTS_KEY]: [ + { + id: commandRequestId, + name: 'patchCardInstance', + arguments: JSON.stringify({ + attributes: { + cardId: `${testRealmURL}Person/fadhlan`, + patch: { attributes: { firstName: 'Evie' } }, + }, + }), + }, + ], + }); + + // The persisted commandResult, linked to the stripped edit id Y rather than + // the surviving original X — exactly what reload loads from the server. + simulateRemoteMessage( + roomId, + '@aibot:localhost', + { + msgtype: APP_BOXEL_COMMAND_RESULT_WITH_NO_OUTPUT_MSGTYPE, + commandRequestId, + 'm.relates_to': { + rel_type: APP_BOXEL_COMMAND_RESULT_REL_TYPE, + key: 'applied', + event_id: strippedEditEventId, + }, + data: {}, + }, + { type: APP_BOXEL_COMMAND_RESULT_EVENT_TYPE }, + ); + + await settled(); + await click('[data-test-open-ai-assistant]'); + await waitFor('[data-test-room-name="test room 1"]'); + await waitFor('[data-test-message-idx="0"]'); + + assert + .dom('[data-test-message-idx="0"] [data-test-apply-state="applied"]') + .exists( + 'a command applied before reload still renders applied even though its commandResult links to a stripped m.replace edit id; correlation is by commandRequestId, not the dangling event_id', + ); + }); + + // Regression coverage for CS-11736 (host side): commandRequestId correlation + // must resolve the *specific* owning bot message, not just the first one that + // happens to carry a command request. With two streamed bot messages, an + // applied result for one must flip only that message; the other stays ready. + test('CS-11736: an applied commandResult flips only its own bot message, not a sibling message that also carries a command', async function (assert) { + setCardInOperatorModeState(`${testRealmURL}Person/fadhlan`); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + await waitFor('[data-test-person="Fadhlan"]'); + let roomId = createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'test room 1', + }); + + let firstCommandRequestId = 'cs-11736-first-cmd-request-id'; + let secondCommandRequestId = 'cs-11736-second-cmd-request-id'; + let strippedEditEventId = 'cs-11736-uniqueness-stripped-edit-event-id'; + + // First bot message (idx 0): carries a command but is never applied. + simulateRemoteMessage(roomId, '@aibot:localhost', { + msgtype: APP_BOXEL_MESSAGE_MSGTYPE, + body: 'Changing first name to Evie', + format: 'org.matrix.custom.html', + isStreamingFinished: true, + [APP_BOXEL_COMMAND_REQUESTS_KEY]: [ + { + id: firstCommandRequestId, + name: 'patchCardInstance', + arguments: JSON.stringify({ + attributes: { + cardId: `${testRealmURL}Person/fadhlan`, + patch: { attributes: { firstName: 'Evie' } }, + }, + }), + }, + ], + }); + + // Second bot message (idx 1): the one whose command was applied pre-reload. + simulateRemoteMessage(roomId, '@aibot:localhost', { + msgtype: APP_BOXEL_MESSAGE_MSGTYPE, + body: 'Changing first name to Mango', + format: 'org.matrix.custom.html', + isStreamingFinished: true, + [APP_BOXEL_COMMAND_REQUESTS_KEY]: [ + { + id: secondCommandRequestId, + name: 'patchCardInstance', + arguments: JSON.stringify({ + attributes: { + cardId: `${testRealmURL}Person/fadhlan`, + patch: { attributes: { firstName: 'Mango' } }, + }, + }), + }, + ], + }); + + // Applied result for the second message only, linked to a stripped edit id + // so correlation has to fall through to commandRequestId. + simulateRemoteMessage( + roomId, + '@aibot:localhost', + { + msgtype: APP_BOXEL_COMMAND_RESULT_WITH_NO_OUTPUT_MSGTYPE, + commandRequestId: secondCommandRequestId, + 'm.relates_to': { + rel_type: APP_BOXEL_COMMAND_RESULT_REL_TYPE, + key: 'applied', + event_id: strippedEditEventId, + }, + data: {}, + }, + { type: APP_BOXEL_COMMAND_RESULT_EVENT_TYPE }, + ); + + await settled(); + await click('[data-test-open-ai-assistant]'); + await waitFor('[data-test-room-name="test room 1"]'); + await waitFor('[data-test-message-idx="1"]'); + + assert + .dom('[data-test-message-idx="1"] [data-test-apply-state="applied"]') + .exists( + 'the bot message whose commandRequestId owns the applied result renders applied', + ); + assert + .dom('[data-test-message-idx="0"] [data-test-apply-state="ready"]') + .exists( + 'the sibling bot message, which has no result of its own, stays ready — the applied status did not bleed across messages', + ); + }); + test('Accept All bar does not flash for an always-auto-executed command (checkCorrectness)', async function (assert) { let roomId = await renderAiAssistantPanel();