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
3 changes: 2 additions & 1 deletion packages/ai-bot/lib/matrix/response-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,13 @@ export default class MatrixResponsePublisher {
return sendOperation;
}

async sendError(error: any) {
async sendError(error: any, opts?: { reloadBillingData?: boolean }) {
sendErrorEvent(
this.client,
this.roomId,
error,
this.originalResponseEventId,
opts,
);
}

Expand Down
7 changes: 5 additions & 2 deletions packages/ai-bot/lib/responder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,10 @@ export class Responder {
};
}

async onError(error: OpenAIError | string) {
async onError(
error: OpenAIError | string,
opts?: { reloadBillingData?: boolean },
) {
Sentry.captureException(error, {
extra: {
roomId: this.matrixResponsePublisher.roomId,
Expand All @@ -191,7 +194,7 @@ export class Responder {
if (this.responseState.isStreamingFinished) {
return;
}
return await this.matrixResponsePublisher.sendError(error);
return await this.matrixResponsePublisher.sendError(error, opts);
}

async flush() {
Expand Down
1 change: 1 addition & 0 deletions packages/ai-bot/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ Common issues are:
// Careful when changing this message, it's used in the UI as a detection of whether to show the "Buy credits" button.
return responder.onError(
`You need a minimum of ${MINIMUM_AI_CREDITS_TO_CONTINUE} credits to continue using the AI bot. Please upgrade to a larger plan, or top up your account.`,
{ reloadBillingData: true },
);
}

Expand Down
55 changes: 55 additions & 0 deletions packages/host/app/components/ai-assistant/message/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import type { TemplateOnlyComponent } from '@ember/component/template-only';
import { registerDestructor } from '@ember/destroyable';
import { hash } from '@ember/helper';
import { action } from '@ember/object';
import { scheduleOnce } from '@ember/runloop';
import { service } from '@ember/service';
import type { SafeString } from '@ember/template';
import Component from '@glimmer/component';

import { task } from 'ember-concurrency';
import perform from 'ember-concurrency/helpers/perform';
import Modifier from 'ember-modifier';
import throttle from 'lodash/throttle';

Expand Down Expand Up @@ -45,6 +48,7 @@ interface Signature {
isFromAssistant: boolean;
isStreaming: boolean;
isLastAssistantMessage: boolean;
isMostRecentMessage?: boolean;
userMessageThisMessageIsRespondingTo?: Message;
profileAvatar?: ComponentLike;
collectionResource?: ReturnType<getCardCollection>;
Expand All @@ -64,6 +68,7 @@ interface Signature {
element: HTMLElement;
}) => void;
errorMessage?: string;
reloadBillingData?: boolean;
isDebugMessage?: boolean;
isPending?: boolean;
retryAction?: () => void;
Expand Down Expand Up @@ -234,6 +239,40 @@ class ScrollPosition extends Modifier<ScrollPositionSignature> {
}
}

interface ReloadBillingOnInsertSignature {
Args: {
Named: {
shouldReloadBillingData: boolean;
reload: () => void;
};
};
}

// In the future if we implement subscription to credit consumption, we can remove this modifier
// It's currently used to reload the billing data when an out of credits error message is shown so that we can
// conditionally display the "buy more credits" button, or "credits added" message + retry button
class ReloadBillingOnInsert extends Modifier<ReloadBillingOnInsertSignature> {
private hasReloaded = false;

private runReload(reload: () => void) {
reload();
}

modify(
_element: HTMLElement,
_positional: [],
{
shouldReloadBillingData,
reload,
}: ReloadBillingOnInsertSignature['Args']['Named'],
) {
if (shouldReloadBillingData && !this.hasReloaded) {
this.hasReloaded = true;
scheduleOnce('afterRender', this, this.runReload, reload);
}
}
}

function isThinkingMessage(s: string | null | undefined) {
if (!s) {
return false;
Expand Down Expand Up @@ -328,6 +367,12 @@ export default class AiAssistantMessage extends Component<Signature> {
}
}

private reloadBillingDataTask = task(async () => {
if (!this.billingService.loadingSubscriptionData) {
await this.billingService.loadSubscriptionData();
Comment on lines +371 to +372

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Force a post-load refresh before deciding credit state

This guard skips the reload entirely whenever a subscription fetch is already in progress, but the in-flight request may have started before the out-of-credits event was emitted (for example during initial panel load or a concurrent billing notification). In that case the request can resolve with stale credits, and because no second fetch is triggered, the message can keep showing the wrong action state. The reload path should ensure a fresh fetch happens after any in-flight load completes instead of returning early.

Useful? React with 👍 / 👎.

}
});

Comment on lines +371 to +375
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reloadBillingDataTask skips calling loadSubscriptionData() when loadingSubscriptionData is already true. If the in-flight request started before the AI bot consumed credits (or before the out-of-credits error), its response can still be stale; skipping here means the UI may never refresh to the post-consumption values. Consider making BillingService.loadSubscriptionData() de-dupe/return an in-flight promise (so callers can await it) or, at minimum, queue a follow-up reload after the current load completes when this flag is set.

Suggested change
if (!this.billingService.loadingSubscriptionData) {
await this.billingService.loadSubscriptionData();
}
});
await this.billingService.loadSubscriptionData();
});

Copilot uses AI. Check for mistakes.
<template>
<section
class={{cn
Expand All @@ -340,6 +385,10 @@ export default class AiAssistantMessage extends Component<Signature> {
registerScroller=@registerScroller
unregisterScroller=@unregisterScroller
}}
{{ReloadBillingOnInsert
shouldReloadBillingData=this.shouldReloadBillingData
reload=(perform this.reloadBillingDataTask)
}}
data-test-ai-assistant-message
data-test-ai-assistant-message-pending={{@isPending}}
...attributes
Expand Down Expand Up @@ -542,6 +591,12 @@ export default class AiAssistantMessage extends Component<Signature> {
return Boolean(this.args.waitAction || this.args.retryAction);
}

private get shouldReloadBillingData() {
return Boolean(
this.args.reloadBillingData && this.args.isMostRecentMessage,
);
}

private get isOutOfCreditsErrorMessage(): boolean {
return this.errorMessages.some((error) =>
/You need a minimum of \d+ credits to continue using the AI bot\. Please upgrade to a larger plan, or top up your account\./.test(
Expand Down
3 changes: 3 additions & 0 deletions packages/host/app/components/matrix/room-message.gts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface Signature {
index: number;
monacoSDK: MonacoSDK;
isStreaming: boolean;
isMostRecentMessage?: boolean;
retryAction?: () => void;
isPending?: boolean;
registerScroller: (args: {
Expand Down Expand Up @@ -185,6 +186,7 @@ export default class RoomMessage extends Component<Signature> {
@eventId={{this.message.eventId}}
@index={{@index}}
@isLastAssistantMessage={{this.isLastAssistantMessage}}
@isMostRecentMessage={{@isMostRecentMessage}}
@userMessageThisMessageIsRespondingTo={{this.userMessageThisMessageIsRespondingTo}}
@registerScroller={{@registerScroller}}
@unregisterScroller={{@unregisterScroller}}
Expand All @@ -200,6 +202,7 @@ export default class RoomMessage extends Component<Signature> {
@files={{this.message.attachedFiles}}
@attachedCardsAsFiles={{this.message.attachedCardsAsFiles}}
@errorMessage={{this.errorMessage}}
@reloadBillingData={{this.message.reloadBillingData}}
@isDebugMessage={{this.message.isDebugMessage}}
@isStreaming={{@isStreaming}}
@retryAction={{@retryAction}}
Expand Down
1 change: 1 addition & 0 deletions packages/host/app/components/matrix/room.gts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export default class Room extends Component<Signature> {
@roomId={{@roomId}}
@roomResource={{@roomResource}}
@index={{i}}
@isMostRecentMessage={{this.isLastMessage i}}
@registerScroller={{this.registerMessageScroller}}
@unregisterScroller={{this.unregisterMessageScroller}}
@isPending={{this.isPendingMessage message}}
Expand Down
12 changes: 12 additions & 0 deletions packages/host/app/lib/matrix-classes/message-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
APP_BOXEL_CONTINUATION_OF_CONTENT_KEY,
APP_BOXEL_HAS_CONTINUATION_CONTENT_KEY,
APP_BOXEL_MESSAGE_MSGTYPE,
APP_BOXEL_RELOAD_BILLING_DATA_KEY,
APP_BOXEL_REASONING_CONTENT_KEY,
APP_BOXEL_DEBUG_MESSAGE_EVENT_TYPE,
APP_BOXEL_CODE_PATCH_RESULT_EVENT_TYPE,
Expand Down Expand Up @@ -60,6 +61,14 @@ const ErrorMessage: Record<string, string> = {
['M_TOO_LARGE']: 'Message is too large',
};

function shouldReloadBillingData(content: object) {
return Boolean(
(content as { [APP_BOXEL_RELOAD_BILLING_DATA_KEY]?: boolean })[
APP_BOXEL_RELOAD_BILLING_DATA_KEY
],
);
}

export default class MessageBuilder {
constructor(
private event: MessageEvent | CardMessageEvent | DebugMessageEvent,
Expand Down Expand Up @@ -169,6 +178,7 @@ export default class MessageBuilder {
message.clientGeneratedId = this.clientGeneratedId;
message.setIsStreamingFinished(!!event.content.isStreamingFinished);
message.setIsCanceled(!!event.content.isCanceled);
message.reloadBillingData = shouldReloadBillingData(event.content);
message.attachedCardIds = this.attachedCardIds;
message.attachedCardsAsFiles = this.attachedCardsAsFiles;
if (event.content[APP_BOXEL_COMMAND_REQUESTS_KEY]) {
Expand All @@ -178,6 +188,7 @@ export default class MessageBuilder {
} else if (event.content.msgtype === 'm.text') {
message.setIsStreamingFinished(!!event.content.isStreamingFinished);
message.setIsCanceled(!!event.content.isCanceled);
message.reloadBillingData = shouldReloadBillingData(event.content);
}
if (event.type === APP_BOXEL_DEBUG_MESSAGE_EVENT_TYPE) {
message.isDebugMessage = true;
Expand Down Expand Up @@ -214,6 +225,7 @@ export default class MessageBuilder {
: null;
message.setUpdated(new Date());
message.errorMessage = this.errorMessage;
message.reloadBillingData = shouldReloadBillingData(this.event.content);

let encodedCommandRequests =
(this.event.content as CardMessageContent)[
Expand Down
2 changes: 2 additions & 0 deletions packages/host/app/lib/matrix-classes/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface RoomMessageOptional {
clientGeneratedId?: string | null;
reasoningContent?: string | null;
isDebugMessage?: boolean;
reloadBillingData?: boolean;
hasContinuation?: boolean;
continuationOf?: string | null;
agentId?: string;
Expand Down Expand Up @@ -73,6 +74,7 @@ export class Message implements RoomMessageInterface {
errorMessage?: string;
clientGeneratedId?: string;
isDebugMessage?: boolean;
reloadBillingData?: boolean;
isCodePatchCorrectness?: boolean;

author: RoomMember;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
APP_BOXEL_CONTINUATION_OF_CONTENT_KEY,
APP_BOXEL_HAS_CONTINUATION_CONTENT_KEY,
APP_BOXEL_MESSAGE_MSGTYPE,
APP_BOXEL_RELOAD_BILLING_DATA_KEY,
APP_BOXEL_REASONING_CONTENT_KEY,
} from '@cardstack/runtime-common/matrix-constants';

Expand Down Expand Up @@ -863,6 +864,58 @@ module('Integration | ai-assistant-panel | general', function (hooks) {
assert.dom('[data-test-credits-added]').exists();
});

test('it reloads billing data for the latest flagged out-of-credits message', async function (assert) {
let roomId = await renderAiAssistantPanel();
let billingService = getService('billing-service');
let requestCount = 0;

billingService.fetchSubscriptionData = async () => {
requestCount++;

let attributes =
requestCount === 1
? {
creditsAvailableInPlanAllowance: 1,
extraCreditsAvailableInBalance: 2,
}
: {
creditsAvailableInPlanAllowance: 1,
extraCreditsAvailableInBalance: 1000,
};

return new Response(JSON.stringify({ data: { attributes } }));
};

await billingService.loadSubscriptionData();
assert.strictEqual(billingService.availableCredits, 3);

simulateRemoteMessage(roomId, '@aibot:localhost', {
body: 'You need a minimum of 10 credits to continue using the AI bot. Please upgrade to a larger plan, or top up your account.',
msgtype: APP_BOXEL_MESSAGE_MSGTYPE,
format: 'org.matrix.custom.html',
isStreamingFinished: true,
errorMessage:
'You need a minimum of 10 credits to continue using the AI bot. Please upgrade to a larger plan, or top up your account.',
[APP_BOXEL_RELOAD_BILLING_DATA_KEY]: true,
});

await waitUntil(() => requestCount === 2);
await waitUntil(() => billingService.availableCredits >= 10, {
timeout: 2000,
});

assert.strictEqual(requestCount, 2, 'billing data reloads exactly once');
assert
.dom('[data-test-alert-action-button="Retry"]')
.exists('retry is shown after the reload confirms credits are available');
assert
.dom('[data-test-credits-added]')
.exists('credits added notice is shown after the reload');
assert
.dom('[data-test-alert-action-button="Buy More Credits"]')
.doesNotExist();
});

test('it can retry a message when receiving an error from the AI bot', async function (assert) {
let roomId = await renderAiAssistantPanel();

Expand Down
3 changes: 3 additions & 0 deletions packages/runtime-common/ai/matrix-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { CommandRequest } from '../commands';
import { encodeCommandRequests } from '../commands';
import {
APP_BOXEL_COMMAND_REQUESTS_KEY,
APP_BOXEL_RELOAD_BILLING_DATA_KEY,
APP_BOXEL_REASONING_CONTENT_KEY,
APP_BOXEL_MESSAGE_MSGTYPE,
APP_BOXEL_DEBUG_MESSAGE_EVENT_TYPE,
Expand Down Expand Up @@ -77,6 +78,7 @@ export async function sendErrorEvent(
roomId: string,
error: any,
eventIdToReplace: string | undefined,
opts?: { reloadBillingData?: boolean },
) {
try {
let errorMessage = getErrorMessage(error);
Expand All @@ -89,6 +91,7 @@ export async function sendErrorEvent(
{
isStreamingFinished: true,
errorMessage,
[APP_BOXEL_RELOAD_BILLING_DATA_KEY]: opts?.reloadBillingData ?? false,
},
);
} catch (e) {
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-common/matrix-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const APP_BOXEL_HAS_CONTINUATION_CONTENT_KEY =
export const APP_BOXEL_CONTINUATION_OF_CONTENT_KEY =
'app.boxel.continuation-of';
export const APP_BOXEL_LLM_MODE = 'app.boxel.llm-mode';
export const APP_BOXEL_RELOAD_BILLING_DATA_KEY = 'app.boxel.reloadBillingData';
export type LLMMode = 'ask' | 'act';
export const DEFAULT_LLM = 'anthropic/claude-sonnet-4.6';
export const DEFAULT_CODING_LLM = 'anthropic/claude-sonnet-4.6';
Expand Down
Loading