Skip to content
Draft
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
28 changes: 28 additions & 0 deletions docs/cs-10438-assistant-credit-message-timing-plan.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 53 additions & 4 deletions packages/host/app/components/ai-assistant/message/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -233,6 +234,28 @@ class ScrollPosition extends Modifier<ScrollPositionSignature> {
}
}

interface OutOfCreditsSnapshotSignature {
Args: {
Named: {
isOutOfCredits: boolean;
recordSnapshot: (isOutOfCredits: boolean) => void;
};
};
}

class OutOfCreditsSnapshot extends Modifier<OutOfCreditsSnapshotSignature> {
modify(
_element: HTMLElement,
_positional: [],
{
isOutOfCredits,
recordSnapshot,
}: OutOfCreditsSnapshotSignature['Args']['Named'],
) {
recordSnapshot(isOutOfCredits);
}
}

function isThinkingMessage(s: string | null | undefined) {
if (!s) {
return false;
Expand All @@ -256,6 +279,8 @@ export default class AiAssistantMessage extends Component<Signature> {
@service declare private operatorModeStateService: OperatorModeStateService;
@service declare private billingService: BillingService;

@tracked private wasOutOfCreditsAtError: boolean | undefined;

private get isReasoningExpandedByDefault() {
let result =
this.args.isStreaming &&
Expand Down Expand Up @@ -354,7 +379,14 @@ export default class AiAssistantMessage extends Component<Signature> {

{{#if this.errorMessages.length}}
{{#if this.isOutOfCreditsErrorMessage}}
<Alert @type='error' as |Alert|>
<Alert
@type='error'
{{OutOfCreditsSnapshot
isOutOfCredits=this.isOutOfCredits
recordSnapshot=this.recordOutOfCreditsSnapshot
}}
as |Alert|
>
<Alert.Messages @messages={{this.errorMessages}} />
{{#if this.isOutOfCredits}}
<Alert.Action
Expand All @@ -363,9 +395,11 @@ export default class AiAssistantMessage extends Component<Signature> {
/>
{{else if @retryAction}}
<div class='credits-action-row'>
<div class='credits-added' data-test-credits-added>
Credits added!
</div>
{{#if this.shouldShowCreditsAdded}}
<div class='credits-added' data-test-credits-added>
Credits added!
</div>
{{/if}}
<Alert.Action @actionName='Retry' @action={{@retryAction}} />
</div>
{{/if}}
Expand Down Expand Up @@ -506,6 +540,21 @@ export default class AiAssistantMessage extends Component<Signature> {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading