From 6b004e71afac5b4b83ee324dc93814b479eb2a11 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 21 Aug 2026 11:25:40 +0200 Subject: [PATCH 1/2] Decode the aggregation bundle's wire data so rebuilt messages keep usage and agentId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content.data is a JSON string on the wire; addEvent decodes it for the event itself, but backfilled events carry their latest edit as a server-side aggregation bundle under unsigned['m.relations']['m.replace'], and getAggregatedReplacement substitutes that bundle's content without decoding — so every field read off data on the rebuild path came back undefined. Observed as the session token total shrinking after a reload (pre-reload turns whose usage rode an edit lost it: $1.32 of recorded usage displayed as $0.62); data.context.agentId rides the same field, so rebuilt bot messages could also lose the agent identity that gates tool auto-execution. Co-Authored-By: Claude Fable 5 --- packages/host/app/lib/matrix-classes/room.ts | 16 +++++ packages/host/tests/unit/room-test.ts | 70 ++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 packages/host/tests/unit/room-test.ts diff --git a/packages/host/app/lib/matrix-classes/room.ts b/packages/host/app/lib/matrix-classes/room.ts index 24e7c3bfd18..e79c354f0ee 100644 --- a/packages/host/app/lib/matrix-classes/room.ts +++ b/packages/host/app/lib/matrix-classes/room.ts @@ -139,6 +139,22 @@ export default class Room { event.content.data = JSON.parse(event.content.data); } } + // Backfilled events carry their latest edit as a server-side aggregation + // bundle, and getAggregatedReplacement substitutes that bundle's content + // for the event's own — so its data needs the same decoding. Without + // this, every rebuilt message whose usage/context arrived on an edit + // reads `data` as a wire string and silently loses those fields (the + // session token total visibly shrank after a reload because pre-reload + // turns' usage vanished this way). + let bundledReplace = (event.unsigned as any)?.['m.relations']?.[ + 'm.replace' + ]; + if ( + bundledReplace?.content?.data && + typeof bundledReplace.content.data === 'string' + ) { + bundledReplace.content.data = JSON.parse(bundledReplace.content.data); + } eventId = eventId ?? stateKey; // room state may not necessary have an event ID if (!eventId) { throw new Error( diff --git a/packages/host/tests/unit/room-test.ts b/packages/host/tests/unit/room-test.ts new file mode 100644 index 00000000000..64a20b289ea --- /dev/null +++ b/packages/host/tests/unit/room-test.ts @@ -0,0 +1,70 @@ +import { module, test } from 'qunit'; + +import Room from '@cardstack/host/lib/matrix-classes/room'; + +import type { EventStatus } from 'matrix-js-sdk'; + +module('Unit | matrix | room', function () { + test('addEvent decodes the wire-encoded data of the event and of its aggregation bundle', function (assert) { + // `content.data` is a JSON string on the wire. Backfilled events also + // carry their latest edit as a server-side aggregation bundle under + // unsigned['m.relations']['m.replace'], whose content the room resource + // substitutes for the event's own (getAggregatedReplacement) — so a + // string left undecoded there silently drops every field read off + // `data` on rebuild: token usage, context.agentId, and with it the + // agent gate for tool auto-execution. + let room = new Room('!room1:localhost'); + let usage = { promptTokens: 10, completionTokens: 2, costUsd: 0.01 }; + let event = { + event_id: '$original', + type: 'm.room.message', + room_id: '!room1:localhost', + origin_server_ts: 1, + status: null as EventStatus | null, + content: { + msgtype: 'app.boxel.message', + body: 'streaming…', + data: JSON.stringify({ context: { agentId: 'agent-1' } }), + }, + unsigned: { + age: 0, + 'm.relations': { + 'm.replace': { + event_id: '$edit', + type: 'm.room.message', + origin_server_ts: 2, + content: { + msgtype: 'app.boxel.message', + body: 'the whole answer', + data: JSON.stringify({ context: { agentId: 'agent-1' }, usage }), + 'm.relates_to': { + rel_type: 'm.replace', + event_id: '$original', + }, + }, + }, + }, + }, + }; + + room.addEvent(event as any); + + let added = room.events[0] as any; + assert.deepEqual( + added.content.data.context, + { agentId: 'agent-1' }, + 'the event’s own data is decoded', + ); + let bundled = added.unsigned['m.relations']['m.replace']; + assert.deepEqual( + bundled.content.data.usage, + usage, + 'the aggregation bundle’s data is decoded too, so a rebuilt message keeps its usage', + ); + assert.strictEqual( + bundled.content.data.context.agentId, + 'agent-1', + 'and keeps its agent id', + ); + }); +}); From 98c3279bc5be8a247970014aabf2c8b132221b2a Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 21 Aug 2026 14:13:12 +0200 Subject: [PATCH 2/2] Drop the duplicated eventId fallback in addEvent Both occurrences predate the bundle-decode change; one is enough. Co-Authored-By: Claude Fable 5 --- packages/host/app/lib/matrix-classes/room.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/host/app/lib/matrix-classes/room.ts b/packages/host/app/lib/matrix-classes/room.ts index e79c354f0ee..3c932957a72 100644 --- a/packages/host/app/lib/matrix-classes/room.ts +++ b/packages/host/app/lib/matrix-classes/room.ts @@ -155,7 +155,6 @@ export default class Room { ) { bundledReplace.content.data = JSON.parse(bundledReplace.content.data); } - eventId = eventId ?? stateKey; // room state may not necessary have an event ID if (!eventId) { throw new Error( `bug: event ID is undefined for event ${JSON.stringify(