diff --git a/docs/cs-10438-assistant-credit-message-timing-plan.md b/docs/cs-10438-assistant-credit-message-timing-plan.md new file mode 100644 index 0000000000..7c654d08a4 --- /dev/null +++ b/docs/cs-10438-assistant-credit-message-timing-plan.md @@ -0,0 +1,28 @@ +## Goal +- Prevent the AI assistant from showing “Credits added!” unless we have + evidence the balance increased after an out-of-credits error. + +## Assumptions +- The out-of-credits error text can appear even when the cached balance + is already above the minimum, so we should avoid claiming credits were + added in that case. +- It is still desirable to show “Credits added!” after the balance + transitions from below-minimum to above-minimum while the error is + displayed. + +## Plan +1. Update `AiAssistantMessage` to track whether the balance was below the + minimum the first time an out-of-credits error is shown. +2. Only render the “Credits added!” label when the error was first shown + while below the minimum and the balance is now above it. +3. Add an integration test that simulates an out-of-credits error while + the billing service already reports sufficient credits, asserting that + “Credits added!” does not render (but Retry does). + +## Target Files +- `packages/host/app/components/ai-assistant/message/index.gts` +- `packages/host/tests/integration/components/ai-assistant-panel/general-test.gts` + +## Testing Notes +- Run `pnpm lint` in `packages/host`. +- If feasible, run a focused Ember test for the new scenario. diff --git a/packages/host/app/components/ai-assistant/message/index.gts b/packages/host/app/components/ai-assistant/message/index.gts index c275360a6c..8fc37fc444 100644 --- a/packages/host/app/components/ai-assistant/message/index.gts +++ b/packages/host/app/components/ai-assistant/message/index.gts @@ -5,6 +5,7 @@ import { action } from '@ember/object'; import { service } from '@ember/service'; import type { SafeString } from '@ember/template'; import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import Modifier from 'ember-modifier'; import throttle from 'lodash/throttle'; @@ -233,6 +234,28 @@ class ScrollPosition extends Modifier { } } +interface OutOfCreditsSnapshotSignature { + Args: { + Named: { + isOutOfCredits: boolean; + recordSnapshot: (isOutOfCredits: boolean) => void; + }; + }; +} + +class OutOfCreditsSnapshot extends Modifier { + modify( + _element: HTMLElement, + _positional: [], + { + isOutOfCredits, + recordSnapshot, + }: OutOfCreditsSnapshotSignature['Args']['Named'], + ) { + recordSnapshot(isOutOfCredits); + } +} + function isThinkingMessage(s: string | null | undefined) { if (!s) { return false; @@ -256,6 +279,8 @@ export default class AiAssistantMessage extends Component { @service declare private operatorModeStateService: OperatorModeStateService; @service declare private billingService: BillingService; + @tracked private wasOutOfCreditsAtError: boolean | undefined; + private get isReasoningExpandedByDefault() { let result = this.args.isStreaming && @@ -354,7 +379,14 @@ export default class AiAssistantMessage extends Component { {{#if this.errorMessages.length}} {{#if this.isOutOfCreditsErrorMessage}} - + {{#if this.isOutOfCredits}} { /> {{else if @retryAction}}
-
- Credits added! -
+ {{#if this.shouldShowCreditsAdded}} +
+ Credits added! +
+ {{/if}}
{{/if}} @@ -506,6 +540,21 @@ export default class AiAssistantMessage extends Component { private get isOutOfCredits() { return !this.hasMinimumCreditsToContinue; } + + @action + private recordOutOfCreditsSnapshot(isOutOfCredits: boolean) { + if (this.wasOutOfCreditsAtError === undefined) { + this.wasOutOfCreditsAtError = isOutOfCredits; + } + } + + private get shouldShowCreditsAdded() { + return ( + this.isOutOfCreditsErrorMessage && + !this.isOutOfCredits && + this.wasOutOfCreditsAtError === true + ); + } } interface AiAssistantConversationSignature { diff --git a/packages/host/tests/integration/components/ai-assistant-panel/general-test.gts b/packages/host/tests/integration/components/ai-assistant-panel/general-test.gts index c06037c1fe..e7c4fe1525 100644 --- a/packages/host/tests/integration/components/ai-assistant-panel/general-test.gts +++ b/packages/host/tests/integration/components/ai-assistant-panel/general-test.gts @@ -857,6 +857,39 @@ module('Integration | ai-assistant-panel | general', function (hooks) { assert.dom('[data-test-credits-added]').exists(); }); + test('it does not claim credits added when balance is already sufficient', async function (assert) { + let roomId = await renderAiAssistantPanel(); + + let billingService = getService('billing-service'); + let attributes = { + creditsAvailableInPlanAllowance: 20, + extraCreditsAvailableInBalance: 0, + }; + + billingService.fetchSubscriptionData = async () => { + return new Response(JSON.stringify({ data: { attributes } })); + }; + + await billingService.loadSubscriptionData(); + await settled(); + + 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: 'm.text', + 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.', + }); + + await waitFor('[data-test-message-idx="0"]'); + assert.dom('[data-test-alert-action-button="Retry"]').exists(); + assert.dom('[data-test-credits-added]').doesNotExist(); + 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();