fix(core): Preserve code-builder thread suffix when parsing session (#30829)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Declan Carroll <declan@n8n.io>
This commit is contained in:
n8n-cat-bot[bot] 2026-05-28 16:25:16 +01:00 committed by GitHub
parent e9631b336f
commit 483752e8df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 115 additions and 19 deletions

View File

@ -417,9 +417,10 @@ export class AiWorkflowBuilderService {
this.onTelemetryEvent('Builder replied to user message', properties);
}
async getSessions(workflowId: string | undefined, user?: IUser) {
async getSessions(workflowId: string | undefined, user?: IUser, isCodeBuilder?: boolean) {
const userId = user?.id?.toString();
return await this.sessionManager.getSessions(workflowId, userId, 'code-builder');
const agentType = isCodeBuilder ? 'code-builder' : undefined;
return await this.sessionManager.getSessions(workflowId, userId, agentType);
}
async getBuilderInstanceCredits(

View File

@ -550,7 +550,7 @@ describe('AiWorkflowBuilderService', () => {
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
undefined,
'test-user-id',
'code-builder',
undefined,
);
});
@ -578,6 +578,19 @@ describe('AiWorkflowBuilderService', () => {
lastUpdated: '2023-12-01T12:00:00Z',
});
expect(result.sessions[0].messages).toHaveLength(2);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
workflowId,
'test-user-id',
undefined,
);
});
it('should request code-builder threads when isCodeBuilder is true', async () => {
const workflowId = 'test-workflow';
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({ sessions: [] });
await service.getSessions(workflowId, mockUser, true);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
workflowId,
'test-user-id',
@ -597,7 +610,7 @@ describe('AiWorkflowBuilderService', () => {
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
workflowId,
'test-user-id',
'code-builder',
undefined,
);
});
@ -621,7 +634,7 @@ describe('AiWorkflowBuilderService', () => {
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
workflowId,
'test-user-id',
'code-builder',
undefined,
);
});
@ -645,7 +658,7 @@ describe('AiWorkflowBuilderService', () => {
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
workflowId,
'test-user-id',
'code-builder',
undefined,
);
});
@ -658,11 +671,7 @@ describe('AiWorkflowBuilderService', () => {
const result = await service.getSessions(workflowId);
expect(result.sessions).toEqual([]);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
workflowId,
undefined,
'code-builder',
);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, undefined, undefined);
});
});
@ -709,7 +718,7 @@ describe('AiWorkflowBuilderService', () => {
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
workflowId,
'test-user-id',
'code-builder',
undefined,
);
});
});

View File

@ -4,4 +4,5 @@ import { Z } from '../../zod-class';
export class AiSessionRetrievalRequestDto extends Z.class({
workflowId: z.string().optional(),
codeBuilder: z.boolean().optional(),
}) {}

View File

@ -530,6 +530,51 @@ describe('AiController', () => {
});
});
describe('getSessions', () => {
it('should call workflowBuilderService.getSessions with correct parameters', async () => {
const mockSessions = { sessions: [] };
const payload = { workflowId: 'workflow123' };
workflowBuilderService.getSessions.mockResolvedValue(mockSessions);
const result = await controller.getSessions(request, response, payload);
expect(workflowBuilderService.getSessions).toHaveBeenCalledWith(
payload.workflowId,
request.user,
undefined,
);
expect(result).toEqual(mockSessions);
});
it('should forward the codeBuilder flag to the service', async () => {
const mockSessions = { sessions: [] };
const payload = { workflowId: 'workflow123', codeBuilder: true as const };
workflowBuilderService.getSessions.mockResolvedValue(mockSessions);
const result = await controller.getSessions(request, response, payload);
expect(workflowBuilderService.getSessions).toHaveBeenCalledWith(
payload.workflowId,
request.user,
true,
);
expect(result).toEqual(mockSessions);
});
it('should throw InternalServerError when service throws an error', async () => {
const payload = { workflowId: 'workflow123' };
const mockError = new Error('Database error');
workflowBuilderService.getSessions.mockRejectedValue(mockError);
await expect(controller.getSessions(request, response, payload)).rejects.toThrow(
InternalServerError,
);
});
});
describe('truncateMessages', () => {
it('should call workflowBuilderService.truncateMessagesAfter with correct parameters', async () => {
const payload = {

View File

@ -251,7 +251,11 @@ export class AiController {
@Body payload: AiSessionRetrievalRequestDto,
) {
try {
const sessions = await this.workflowBuilderService.getSessions(payload.workflowId, req.user);
const sessions = await this.workflowBuilderService.getSessions(
payload.workflowId,
req.user,
payload.codeBuilder,
);
return sessions;
} catch (e) {
assert(e instanceof Error);

View File

@ -69,6 +69,27 @@ describe('WorkflowBuilderSessionRepository', () => {
it('should throw error for empty thread ID', async () => {
await expect(repository.getSession('')).rejects.toThrow('Invalid thread ID format: ');
});
it('should strip the code-builder "-code" suffix from the userId', async () => {
entityManager.findOne.mockResolvedValueOnce(null);
const userUuid = 'af6cc09d-e809-41c6-9e92-ca26dfd11487';
await repository.getSession(`workflow-wf123-user-${userUuid}-code`);
expect(entityManager.findOne).toHaveBeenCalledWith(WorkflowBuilderSession, {
where: { workflowId: 'wf123', userId: userUuid },
});
});
it('should not strip "-code" from a non-suffixed userId', async () => {
entityManager.findOne.mockResolvedValueOnce(null);
await repository.getSession('workflow-wf123-user-user456');
expect(entityManager.findOne).toHaveBeenCalledWith(WorkflowBuilderSession, {
where: { workflowId: 'wf123', userId: 'user456' },
});
});
});
describe('getSession', () => {

View File

@ -76,8 +76,11 @@ export class WorkflowBuilderSessionRepository
}
private parseThreadId(threadId: string): { workflowId: string; userId: string } {
// Format: "workflow-{workflowId}-user-{userId}"
const match = threadId.match(/^workflow-(.+)-user-(.+)$/);
// Format: "workflow-{workflowId}-user-{userId}" with an optional "-code" suffix
// for the code-builder agent thread variant. Strip the suffix before parsing so
// the greedy userId capture doesn't swallow it (userId is a uuid column in PG).
const normalized = threadId.endsWith('-code') ? threadId.slice(0, -'-code'.length) : threadId;
const match = normalized.match(/^workflow-(.+)-user-(.+)$/);
if (!match) {
throw new Error(`Invalid thread ID format: ${threadId}`);
}

View File

@ -273,7 +273,7 @@ describe('WorkflowBuilderService', () => {
const result = await service.getSessions('workflow-123', mockUser);
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledTimes(1);
expect(mockAiService.getSessions).toHaveBeenCalledWith('workflow-123', mockUser);
expect(mockAiService.getSessions).toHaveBeenCalledWith('workflow-123', mockUser, undefined);
expect(result).toEqual(mockSessions);
});
@ -286,9 +286,21 @@ describe('WorkflowBuilderService', () => {
const result = await service.getSessions(undefined, mockUser);
expect(mockAiService.getSessions).toHaveBeenCalledWith(undefined, mockUser);
expect(mockAiService.getSessions).toHaveBeenCalledWith(undefined, mockUser, undefined);
expect(result).toEqual(mockSessions);
});
it('should forward the isCodeBuilder flag to the inner service', async () => {
const mockSessions = { sessions: [] };
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.getSessions as jest.Mock).mockResolvedValue(mockSessions);
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
await service.getSessions('workflow-123', mockUser, true);
expect(mockAiService.getSessions).toHaveBeenCalledWith('workflow-123', mockUser, true);
});
});
describe('onCreditsUpdated callback', () => {

View File

@ -187,9 +187,9 @@ export class WorkflowBuilderService {
yield* service.chat(payload, user, abortSignal);
}
async getSessions(workflowId: string | undefined, user: IUser) {
async getSessions(workflowId: string | undefined, user: IUser, isCodeBuilder?: boolean) {
const service = await this.getService();
const sessions = await service.getSessions(workflowId, user);
const sessions = await service.getSessions(workflowId, user, isCodeBuilder);
return sessions;
}