fix(ai): delete the Redis chunk list on credits-exhausted terminal events (#22477)

## Rationale

The AI chat stream keeps every published chunk in a Redis list
(`agent-chat-stream-chunks:<threadId>`, 1h TTL) so late subscribers can
catch up. On `message-persisted` the list is deleted. On
`credits-exhausted` — the *other* successful terminal event — it wasn't.
Any reload/refetch within the TTL replayed the orphaned chunks, flipping
the thread into a "streaming" state that no terminal event ever closes:
an endless spinner until the user sends another message.

**Production evidence (Sentry):** `Billing Credits Exhausted` fired for
**290 users / 937 events in 90 days**, ongoing
([TWENTY-SERVER-G42](https://twenty-v7.sentry.io/issues/TWENTY-SERVER-G42))
— every one of those users who reloads the chat within an hour hits
this.

## Why this is the root cause, not a symptom patch

The chunk list's lifecycle contract is "cleared when the turn settles".
`credits-exhausted` resolves the job successfully **without** persisting
a `lastStreamError`, so unlike `stream-error` there is no persisted
terminator for catchup to replay after the chunks — the replay is
unconditionally un-closeable. Deleting on both settle events restores
the contract exactly where it's already enforced for
`message-persisted`. `stream-error` deliberately keeps the list: the
persisted error acts as the replay terminator, letting a reloading
client still see the failed turn's partial output.

## User impact

Users who hit their billing cap mid-answer (~100/month) no longer come
back to a permanently spinning thread after a reload — they see the
settled conversation and the billing state.

## Test plan

- [x] Unit spec: chunk accumulation with 1-based seq, deletion on both
terminal events (`it.each`), retention on `stream-error`
- [ ] CI green

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22477?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-02 21:05:19 +02:00
committed by GitHub
parent 6ac8ebcd18
commit ca8ab32253
2 changed files with 80 additions and 1 deletions
@@ -0,0 +1,76 @@
import { AgentChatEventPublisherService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-event-publisher.service';
describe('AgentChatEventPublisherService', () => {
const buildService = () => {
const redis = {
rpush: jest.fn().mockResolvedValue(1),
expire: jest.fn().mockResolvedValue(1),
del: jest.fn().mockResolvedValue(1),
};
const subscriptionService = {
publishToAgentChat: jest.fn().mockResolvedValue(undefined),
};
const service = new AgentChatEventPublisherService(
subscriptionService as never,
{ getClient: () => redis } as never,
);
return { service, redis, subscriptionService };
};
it('accumulates stream chunks with a 1-based sequence number', async () => {
const { service, redis, subscriptionService } = buildService();
redis.rpush.mockResolvedValue(7);
await service.publish({
threadId: 'thread-id',
workspaceId: 'workspace-id',
event: { type: 'stream-chunk', chunk: { type: 'text-delta' } } as never,
});
expect(redis.rpush).toHaveBeenCalledWith(
'agent-chat-stream-chunks:thread-id',
JSON.stringify({ type: 'text-delta' }),
);
expect(subscriptionService.publishToAgentChat).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
onAgentChatEvent: {
threadId: 'thread-id',
event: expect.objectContaining({ seq: 7 }),
},
},
}),
);
});
it.each(['message-persisted', 'credits-exhausted'] as const)(
'deletes the accumulated chunk list on terminal %s',
async (type) => {
const { service, redis } = buildService();
await service.publish({
threadId: 'thread-id',
workspaceId: 'workspace-id',
event: { type } as never,
});
expect(redis.del).toHaveBeenCalledWith(
'agent-chat-stream-chunks:thread-id',
);
},
);
it('keeps the chunk list on stream-error so catchup can replay the partial turn', async () => {
const { service, redis } = buildService();
await service.publish({
threadId: 'thread-id',
workspaceId: 'workspace-id',
event: { type: 'stream-error', code: 'X', message: 'boom' } as never,
});
expect(redis.del).not.toHaveBeenCalled();
});
});
@@ -38,7 +38,10 @@ export class AgentChatEventPublisherService {
await redis.expire(key, STREAM_CHUNKS_TTL_SECONDS);
publishedEvent = { ...event, seq };
} else if (event.type === 'message-persisted') {
} else if (
event.type === 'message-persisted' ||
event.type === 'credits-exhausted'
) {
const redis = this.redisClientService.getClient();
await redis.del(this.getStreamChunksKey(threadId));
}