Skip to content
Open
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
9 changes: 6 additions & 3 deletions packages/host/app/lib/matrix-classes/message-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 18 additions & 15 deletions packages/host/app/resources/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,29 +724,32 @@ export class RoomResource extends Resource<Args> {
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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
<template><OperatorMode @onClose={{noop}} /></template>
},
);
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 {
<template><OperatorMode @onClose={{noop}} /></template>
},
);
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();

Expand Down
Loading