Skip to content

Commit 7352af1

Browse files
committed
Merge branch 'main' into ehco/call-recording-converge-on-meeting-start
2 parents 298fd55 + e7e9924 commit 7352af1

1,170 files changed

Lines changed: 119640 additions & 1673 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"packages/twenty-utils",
7575
"packages/twenty-zapier",
7676
"packages/twenty-website",
77+
"packages/twenty-website-redone",
7778
"packages/twenty-docs",
7879
"packages/twenty-e2e-testing",
7980
"packages/twenty-shared",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { type TranscriptMarker } from 'src/logic-functions/types/transcript-marker.type';
2+
3+
export const buildPendingTranscriptMarker = ({
4+
recallTranscriptId,
5+
requestedAt,
6+
}: {
7+
recallTranscriptId: string;
8+
requestedAt: string;
9+
}): TranscriptMarker => ({
10+
recallTranscriptId,
11+
status: 'PENDING',
12+
requestedAt,
13+
});

packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,15 +163,15 @@ describe('convergeDivergedCallRecordings', () => {
163163
callRecordingId: 'call-recording-1',
164164
});
165165
expect(client.mutations).toEqual([
166-
{
166+
expect.objectContaining({
167167
id: 'call-recording-1',
168-
data: {
168+
data: expect.objectContaining({
169169
status: 'PROCESSING',
170170
startedAt: '2026-06-09T13:02:00.000Z',
171171
endedAt: '2026-06-09T14:00:00.000Z',
172172
externalRecordingId: 'recall-recording-1',
173-
},
174-
},
173+
}),
174+
}),
175175
]);
176176
expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled();
177177
expect(result).toEqual({
@@ -470,6 +470,11 @@ describe('convergeDivergedCallRecordings', () => {
470470
id: 'call-recording-1',
471471
data: {
472472
endedAt: '2026-06-09T14:00:00.000Z',
473+
transcript: {
474+
recallTranscriptId: 'recall-transcript-1',
475+
status: 'PENDING',
476+
requestedAt: NOW.toISOString(),
477+
},
473478
},
474479
},
475480
]);

packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -750,25 +750,17 @@ describe('handleRecallWebhook', () => {
750750
status: 'PROCESSING',
751751
externalBotId: 'recall-bot-1',
752752
externalRecordingId: 'recall-recording-1',
753+
transcript: {
754+
recallTranscriptId: 'recall-transcript-1',
755+
status: 'PENDING',
756+
requestedAt: expect.any(String),
757+
},
753758
},
754759
},
755760
]);
756761
});
757762

758-
it('does not re-request a transcript on a redelivered done event', async () => {
759-
listRecallTranscriptsMock.mockResolvedValue({
760-
ok: true,
761-
transcripts: [
762-
{
763-
id: 'recall-transcript-1',
764-
createdAt: '2026-01-01T14:06:00.000Z',
765-
downloadUrl: undefined,
766-
provider: 'recallai_async',
767-
statusCode: 'processing',
768-
statusSubCode: undefined,
769-
},
770-
],
771-
});
763+
it('does not re-request a transcript on a redelivered done event while Recall list is stale', async () => {
772764
const client = new FakeCoreApiClient([
773765
{
774766
id: 'call-recording-1',
@@ -800,6 +792,9 @@ describe('handleRecallWebhook', () => {
800792
expect(listRecallTranscriptsMock).toHaveBeenCalledWith({
801793
externalRecordingId: 'recall-recording-1',
802794
});
795+
expect(retrieveRecallTranscriptMock).toHaveBeenCalledWith({
796+
transcriptId: 'recall-transcript-1',
797+
});
803798
expect(client.mutations).toEqual([
804799
{
805800
id: 'call-recording-1',
@@ -858,14 +853,14 @@ describe('handleRecallWebhook', () => {
858853
callRecordingId: 'call-recording-1',
859854
});
860855
expect(client.mutations).toEqual([
861-
{
856+
expect.objectContaining({
862857
id: 'call-recording-1',
863-
data: {
858+
data: expect.objectContaining({
864859
status: 'PROCESSING',
865860
externalBotId: 'recall-bot-1',
866861
externalRecordingId: 'recall-recording-9',
867-
},
868-
},
862+
}),
863+
}),
869864
]);
870865
});
871866

@@ -972,15 +967,15 @@ describe('handleRecallWebhook', () => {
972967
callRecordingId: 'call-recording-1',
973968
});
974969
expect(client.mutations).toEqual([
975-
{
970+
expect.objectContaining({
976971
id: 'call-recording-1',
977-
data: {
972+
data: expect.objectContaining({
978973
status: 'PROCESSING',
979974
externalBotId: 'recall-bot-1',
980975
externalRecordingId: 'recall-recording-1',
981976
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
982-
},
983-
},
977+
}),
978+
}),
984979
]);
985980
expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled();
986981
});

packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export const convergeDivergedCallRecordings = async ({
119119
client,
120120
candidate,
121121
externalBotId: candidate.externalBotId,
122+
now,
122123
result,
123124
});
124125
}
@@ -229,11 +230,13 @@ const convergeCallRecording = async ({
229230
client,
230231
candidate,
231232
externalBotId,
233+
now,
232234
result,
233235
}: {
234236
client: CoreApiClient;
235237
candidate: DivergedCallRecordingCandidate;
236238
externalBotId: string;
239+
now: Date;
237240
result: ConvergeDivergedCallRecordingsResult;
238241
}): Promise<void> => {
239242
const botResult = await getRecallBot({ externalBotId });
@@ -269,6 +272,7 @@ const convergeCallRecording = async ({
269272
callRecordingId: candidate.id,
270273
currentStatus: candidate.status,
271274
externalRecordingId,
275+
requestedAt: now.toISOString(),
272276
transcript: candidate.transcript,
273277
});
274278

packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ const buildTranscriptArtifactUpdate = async ({
353353
callRecordingId: callRecording.id,
354354
currentStatus: callRecording.status,
355355
externalRecordingId,
356+
requestedAt: new Date().toISOString(),
356357
transcript: callRecording.transcript,
357358
});
358359

packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { isNull, isUndefined } from '@sniptt/guards';
33
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
44
import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util';
55
import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util';
6+
import { buildPendingTranscriptMarker } from 'src/logic-functions/domain/build-pending-transcript-marker.util';
67
import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util';
78
import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util';
89
import { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util';
@@ -26,11 +27,13 @@ export const reconcileCallRecordingTranscriptArtifact = async ({
2627
callRecordingId,
2728
currentStatus,
2829
externalRecordingId,
30+
requestedAt,
2931
transcript,
3032
}: {
3133
callRecordingId: string;
3234
currentStatus: string | undefined;
3335
externalRecordingId: string;
36+
requestedAt: string;
3437
transcript: unknown;
3538
}): Promise<ReconcileCallRecordingTranscriptArtifactResult> => {
3639
const existingTranscriptMarker = parseTranscriptMarker(transcript);
@@ -60,8 +63,17 @@ export const reconcileCallRecordingTranscriptArtifact = async ({
6063
const transcriptArtifact = selectRecallTranscriptArtifact(
6164
listResult.transcripts,
6265
);
66+
const pendingTranscriptMarkerRecallTranscriptId =
67+
existingTranscriptMarker?.status === 'PENDING'
68+
? (existingTranscriptMarker.recallTranscriptId ?? undefined)
69+
: undefined;
70+
const transcriptIdToDownload =
71+
transcriptArtifact?.id ?? pendingTranscriptMarkerRecallTranscriptId;
6372

64-
if (isUndefined(transcriptArtifact)) {
73+
if (
74+
isUndefined(transcriptArtifact) &&
75+
isUndefined(pendingTranscriptMarkerRecallTranscriptId)
76+
) {
6577
const createResult = await createAsyncRecallTranscript({
6678
externalRecordingId,
6779
callRecordingId,
@@ -75,12 +87,21 @@ export const reconcileCallRecordingTranscriptArtifact = async ({
7587
return buildEmptyTranscriptArtifactResult();
7688
}
7789

78-
return { updateData: {}, requestedTranscript: true };
90+
return {
91+
updateData: {
92+
transcript: buildPendingTranscriptMarker({
93+
recallTranscriptId: createResult.transcriptId,
94+
requestedAt,
95+
}),
96+
},
97+
requestedTranscript: true,
98+
};
7999
}
80100

81101
if (
82-
transcriptArtifact.statusCode === 'failed' ||
83-
transcriptArtifact.statusCode === 'error'
102+
!isUndefined(transcriptArtifact) &&
103+
(transcriptArtifact.statusCode === 'failed' ||
104+
transcriptArtifact.statusCode === 'error')
84105
) {
85106
return {
86107
updateData: buildTranscriptFailureUpdate({
@@ -92,12 +113,19 @@ export const reconcileCallRecordingTranscriptArtifact = async ({
92113
};
93114
}
94115

95-
if (transcriptArtifact.statusCode !== 'done') {
116+
if (
117+
!isUndefined(transcriptArtifact) &&
118+
transcriptArtifact.statusCode !== 'done'
119+
) {
120+
return buildEmptyTranscriptArtifactResult();
121+
}
122+
123+
if (isUndefined(transcriptIdToDownload)) {
96124
return buildEmptyTranscriptArtifactResult();
97125
}
98126

99127
const downloadResult = await downloadTranscript({
100-
transcriptId: transcriptArtifact.id,
128+
transcriptId: transcriptIdToDownload,
101129
});
102130

103131
if (downloadResult.outcome === 'filled') {
@@ -113,7 +141,7 @@ export const reconcileCallRecordingTranscriptArtifact = async ({
113141
return {
114142
updateData: buildTranscriptFailureUpdate({
115143
currentStatus,
116-
transcriptId: transcriptArtifact.id,
144+
transcriptId: transcriptIdToDownload,
117145
subCode: downloadResult.subCode,
118146
}),
119147
requestedTranscript: false,

packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-tool-executor.service.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ import {
44
MCP_PROGRESS_NOTIFICATION_METHOD,
55
TOOL_CALL_PROGRESS_TOKEN_PREFIX,
66
} from 'src/engine/api/mcp/constants/mcp-progress-notification.const';
7-
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
87
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
8+
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
99

1010
describe('McpToolExecutorService', () => {
1111
let service: McpToolExecutorService;
1212

1313
beforeEach(() => {
1414
const metricsService = {
1515
incrementCounterBy: jest.fn().mockResolvedValue(undefined),
16+
recordHistogram: jest.fn(),
1617
} as unknown as MetricsService;
1718

1819
service = new McpToolExecutorService(metricsService);

packages/twenty-server/src/engine/api/mcp/services/mcp-tool-executor.service.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { Injectable } from '@nestjs/common';
22

3+
import { isNonEmptyString } from '@sniptt/guards';
34
import { type ToolSet } from 'ai';
45
import { isDefined } from 'twenty-shared/utils';
56

6-
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
77
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
8+
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
9+
import { estimateToolOutputTokens } from 'src/engine/core-modules/tool-provider/utils/estimate-tool-output-tokens.util';
10+
import { getToolMetricName } from 'src/engine/core-modules/tool-provider/utils/get-tool-metric-name.util';
11+
import { isToolOutputSuccessful } from 'src/engine/core-modules/tool-provider/utils/is-tool-output-successful.util';
12+
import { resolveToolName } from 'src/engine/core-modules/tool-provider/utils/resolve-tool-name.util';
813

914
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
1015
import {
@@ -33,14 +38,23 @@ export class McpToolExecutorService {
3338
params: Record<string, unknown>,
3439
sseWriter?: (data: Record<string, unknown>) => void,
3540
) {
36-
const toolName = params.name as keyof typeof toolSet;
41+
if (!isNonEmptyString(params.name)) {
42+
return wrapJsonRpcResponse(id, {
43+
error: {
44+
code: JSON_RPC_ERROR_CODE.INVALID_PARAMS,
45+
message: 'Tool name is required',
46+
},
47+
});
48+
}
49+
50+
const toolName = params.name;
3751
const tool = toolSet[toolName];
3852

3953
if (!isDefined(tool) || !isDefined(tool.execute)) {
4054
return wrapJsonRpcResponse(id, {
4155
error: {
4256
code: JSON_RPC_ERROR_CODE.INVALID_PARAMS,
43-
message: `Unknown tool: ${String(params.name)}`,
57+
message: `Unknown tool: ${toolName}`,
4458
},
4559
});
4660
}
@@ -57,15 +71,34 @@ export class McpToolExecutorService {
5771
});
5872
}
5973

74+
const metricToolName = getToolMetricName(
75+
resolveToolName({
76+
toolName,
77+
input: params.arguments,
78+
}),
79+
);
80+
6081
try {
6182
const result = await tool.execute(params.arguments, {
6283
toolCallId: '1',
6384
messages: [],
6485
});
6586

66-
void this.metricsService.incrementCounterBy({
67-
key: MetricsKeys.McpToolExecutionSucceeded,
87+
const succeeded = isToolOutputSuccessful(result);
88+
89+
this.metricsService.incrementCounterBy({
90+
key: succeeded
91+
? MetricsKeys.McpToolExecutionSucceeded
92+
: MetricsKeys.McpToolExecutionFailed,
6893
amount: 1,
94+
attributes: { tool: metricToolName },
95+
});
96+
97+
this.metricsService.recordHistogram({
98+
key: MetricsKeys.McpToolOutputTokens,
99+
value: estimateToolOutputTokens(result),
100+
unit: 'token',
101+
attributes: { tool: metricToolName },
69102
});
70103

71104
return wrapJsonRpcResponse(id, {
@@ -75,9 +108,10 @@ export class McpToolExecutorService {
75108
},
76109
});
77110
} catch (executionError) {
78-
void this.metricsService.incrementCounterBy({
111+
this.metricsService.incrementCounterBy({
79112
key: MetricsKeys.McpToolExecutionFailed,
80113
amount: 1,
114+
attributes: { tool: metricToolName },
81115
});
82116

83117
return wrapJsonRpcResponse(id, {

0 commit comments

Comments
 (0)