diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index 23203d1888..96a6f2d166 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -11,7 +11,7 @@ import type { import type SchemaCache from '../schema-cache'; import type { StepUser } from '../types/execution-context'; import type { RecordData } from '../types/validated/collection'; -import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; +import type { ActionEndpointsByCollection, SelectOptions } from '@forestadmin/agent-client'; import { HttpRequester, createRemoteAgentClient } from '@forestadmin/agent-client'; import jsonwebtoken from 'jsonwebtoken'; @@ -109,7 +109,7 @@ export default class AgentClientAgentPort implements AgentPort { } async getRelatedData( - { collection, id, relation, relatedSchema, limit, fields }: GetRelatedDataQuery, + { collection, id, relation, relatedSchema, limit, fields, filters }: GetRelatedDataQuery, user: StepUser, ): Promise { return this.callAgent('getRelatedData', async () => { @@ -120,6 +120,7 @@ export default class AgentClientAgentPort implements AgentPort { .list>({ ...(limit !== null && { pagination: { size: limit, number: 1 } }), ...(fields?.length && { fields }), + ...(filters !== undefined && { filters: filters as SelectOptions['filters'] }), }); return rows.map(row => { diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index 48ffda96ac..b67dda0510 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -562,6 +562,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { ); }); + it('forwards a build-time filter (PRD-553) to getRelatedData on the 1–n list fetch', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'r' }, + id: 'c3', + }, + ], + }); + const model = { + bindTools: jest.fn().mockReturnValue({ invoke }), + } as unknown as ExecutionContext['model']; + + const filters = { field: 'city', operator: 'equal', value: 'Lyon' }; + const context = makeContext({ + model, + agentPort, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { relationName: 'address', filters }, + }), + }); + + await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(agentPort.getRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ collection: 'customers', relation: 'address', filters }), + expect.anything(), + ); + }); + + it('also forwards the filter on the AutomatedWithConfirmation path (the user-facing list)', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'r' }, + id: 'c3', + }, + ], + }); + const model = { + bindTools: jest.fn().mockReturnValue({ invoke }), + } as unknown as ExecutionContext['model']; + + const filters = { field: 'city', operator: 'equal', value: 'Lyon' }; + const context = makeContext({ + model, + agentPort, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.AutomatedWithConfirmation, + preRecordedArgs: { relationName: 'address', filters }, + }), + }); + + await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(agentPort.getRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ collection: 'customers', relation: 'address', filters }), + expect.anything(), + ); + }); + it('skips field-selection AI call when related collection has no non-relation fields', async () => { const hasManySchema = makeCollectionSchema({ fields: [