refactor(server): rename Agent exception to Ai; add THREAD_NOT_FOUND / MESSAGE_NOT_FOUND codes (fixes 500s) (#19831)

## Summary

- The exception class under `ai-agent/` was serving every AI surface
(agent, chat, role, models, generate-text), so `Agent` was a misnomer.
Promoted to the `ai/` namespace; renamed `AgentException` →
`AiException`, `AgentExceptionCode` → `AiExceptionCode`, and related
interceptor / filter / handler / file names accordingly.
- Split the single `AGENT_NOT_FOUND` code into entity-specific codes.
Chat-thread lookups no longer reuse the agent identifier.
- **Fixes Sentry 500s on `GetChatMessages` / `chatThread`.** Every
"Thread not found" and "Queued message not found" throw site in ai-chat
was previously wired to `AGENT_EXECUTION_FAILED`, which maps to
`InternalServerError` (HTTP 500). They now use `THREAD_NOT_FOUND` /
`MESSAGE_NOT_FOUND`, both of which map to `NotFoundError` (HTTP 404) in
the GraphQL and REST handlers.

The underlying cause of *why* clients are asking for threads that no
longer resolve for them — per-user chat-thread create events being
broadcast workspace-wide — is addressed separately in a follow-up PR.

### Code map

- Added: `ai/ai.exception.ts`,
`ai/utils/ai-graphql-api-exception-handler.util.ts` (+ spec with new
THREAD/MESSAGE cases),
`ai/interceptors/ai-graphql-api-exception.interceptor.ts`,
`ai/filters/ai-api-exception.filter.ts`
- Deleted: `ai/ai-agent/agent.exception.ts`,
`ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts` (+
spec),
`ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor.ts`,
`ai/ai-agent/filters/agent-api-exception.filter.ts`
- Updated: 21 call sites across ai-agent, ai-agent-execution,
ai-agent-role, ai-chat, ai-generate-text, ai-models, role, and
workspace-migration validators.

## Test plan

- [x] `npx nx typecheck twenty-server`
- [x] `npx jest ai-graphql-api-exception-handler` (3/3 including new
THREAD_NOT_FOUND and MESSAGE_NOT_FOUND cases)
- [x] `npx jest agent-role.service` (9/9)
- [x] `npx oxlint --type-aware` on all changed files (0 warnings/errors)
- [x] `npx prettier --check` on all changed files
- [ ] CI
This commit is contained in:
Félix Malfait
2026-04-18 21:08:33 +02:00
committed by GitHub
parent 4c94699376
commit c28c20143b
28 changed files with 313 additions and 279 deletions
@@ -0,0 +1,63 @@
import {
AiException,
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.exception';
import { aiGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util';
import {
ErrorCode,
type BaseGraphQLError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
const catchGraphqlError = (error: Error): BaseGraphQLError => {
try {
aiGraphqlApiExceptionHandler(error);
throw new Error('Expected aiGraphqlApiExceptionHandler to throw');
} catch (graphqlError) {
return graphqlError as BaseGraphQLError;
}
};
describe('aiGraphqlApiExceptionHandler', () => {
it('maps API key configuration failures to INTERNAL_SERVER_ERROR with a subCode', () => {
const error = new AiException(
'No AI models are available',
AiExceptionCode.API_KEY_NOT_CONFIGURED,
);
const graphqlError = catchGraphqlError(error);
expect(graphqlError.extensions.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
expect(graphqlError.extensions.subCode).toBe(
AiExceptionCode.API_KEY_NOT_CONFIGURED,
);
expect(graphqlError.extensions.userFriendlyMessage).toBeDefined();
});
it('maps THREAD_NOT_FOUND to NOT_FOUND', () => {
const error = new AiException(
'Thread not found',
AiExceptionCode.THREAD_NOT_FOUND,
);
const graphqlError = catchGraphqlError(error);
expect(graphqlError.extensions.code).toBe(ErrorCode.NOT_FOUND);
expect(graphqlError.extensions.subCode).toBe(
AiExceptionCode.THREAD_NOT_FOUND,
);
});
it('maps MESSAGE_NOT_FOUND to NOT_FOUND', () => {
const error = new AiException(
'Message not found',
AiExceptionCode.MESSAGE_NOT_FOUND,
);
const graphqlError = catchGraphqlError(error);
expect(graphqlError.extensions.code).toBe(ErrorCode.NOT_FOUND);
expect(graphqlError.extensions.subCode).toBe(
AiExceptionCode.MESSAGE_NOT_FOUND,
);
});
});
@@ -0,0 +1,41 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
ConflictError,
ForbiddenError,
InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
AiException,
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.exception';
export const aiGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof AiException) {
switch (error.code) {
case AiExceptionCode.AGENT_NOT_FOUND:
case AiExceptionCode.THREAD_NOT_FOUND:
case AiExceptionCode.MESSAGE_NOT_FOUND:
case AiExceptionCode.ROLE_NOT_FOUND:
throw new NotFoundError(error);
case AiExceptionCode.INVALID_AGENT_INPUT:
throw new UserInputError(error);
case AiExceptionCode.AGENT_ALREADY_EXISTS:
throw new ConflictError(error);
case AiExceptionCode.AGENT_IS_STANDARD:
case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS:
throw new ForbiddenError(error);
case AiExceptionCode.AGENT_EXECUTION_FAILED:
case AiExceptionCode.API_KEY_NOT_CONFIGURED:
case AiExceptionCode.USER_WORKSPACE_ID_NOT_FOUND:
throw new InternalServerError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};