Skip to content

Commit 961d184

Browse files
committed
Add TargetAgentName support for custom engine agents in Copilot audit events
- CopilotEventData: Add TargetAgentName property matching real audit event schema - FromJson: Implement priority resolution for agent name: TargetAgentName (CopilotEventData) > AgentName (top-level) > AppIdentity fallback - FromJson: Remove all IsCustomAgent determination logic (deferred to future work) - SQL: Remove is_custom_agent backfill statements from merge script - SQL: Agent name/id upsert logic unchanged (handles renames across batches) - Tests: Add tests for TargetAgentName resolution, priority, AgentId fallback - Tests: Add DeclarativeAgentIsNotCustom and TargetAgentName DB update tests - Tests: Update all IsCustomAgent assertions to expect null
1 parent ffedea0 commit 961d184

File tree

3 files changed

+320
-31
lines changed

3 files changed

+320
-31
lines changed

src/AnalyticsEngine/Tests.UnitTests/CopilotTests.cs

Lines changed: 279 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,68 @@ public async Task CopilotEventManagerAgentNameUpdateSaveTest()
309309
}
310310
}
311311

312+
/// <summary>
313+
/// Tests that when TargetAgentName changes for the same AgentId, the DB is updated with the new name
314+
/// </summary>
315+
[TestMethod]
316+
public async Task CopilotEventManagerTargetAgentNameUpdateSaveTest()
317+
{
318+
using (var _db = new AnalyticsEntitiesContext(_config.ConnectionStrings.SQL, true, false))
319+
{
320+
await ClearEvents(_db);
321+
322+
var adaptor = new FakeCopilotMetadataLoader();
323+
324+
// First save with initial TargetAgentName resolved through FromJson
325+
var agentId = "TargetAgentTest_" + DateTime.Now.Ticks;
326+
var initialTargetName = "InitialCustomEngine_" + DateTime.Now.Ticks;
327+
var firstChatEvents = await ExecuteCopilotEventManagerSaveFlow(adaptor, _db, Tuple.Create(agentId, initialTargetName));
328+
329+
// Verify first events saved with initial agent name
330+
foreach (var evt in firstChatEvents)
331+
{
332+
var id = evt.Id;
333+
var reloaded = await _db.CopilotChats.Include(x => x.Agent).FirstOrDefaultAsync(x => x.AuditEvent.Id == id);
334+
Assert.IsNotNull(reloaded, $"CopilotChat not found for initial event {id}");
335+
Assert.IsNotNull(reloaded.Agent, $"Agent navigation null for initial event {id}");
336+
Assert.AreEqual(agentId, reloaded.Agent.AgentID, $"AgentID mismatch for initial event {id}");
337+
Assert.AreEqual(initialTargetName, reloaded.Agent.Name, $"Agent Name mismatch for initial event {id}");
338+
}
339+
340+
// Second save with updated TargetAgentName (same AgentId) - simulates custom engine agent rename
341+
var updatedTargetName = "UpdatedCustomEngine_" + DateTime.Now.Ticks;
342+
var secondChatEvents = await ExecuteCopilotEventManagerSaveFlow(adaptor, _db, Tuple.Create(agentId, updatedTargetName));
343+
344+
// Verify ALL second events saved and agent name updated
345+
foreach (var evt in secondChatEvents)
346+
{
347+
var id = evt.Id;
348+
var reloaded = await _db.CopilotChats.Include(x => x.Agent).FirstOrDefaultAsync(x => x.AuditEvent.Id == id);
349+
if (reloaded?.Agent != null)
350+
{
351+
await _db.Entry(reloaded.Agent).ReloadAsync();
352+
}
353+
Assert.IsNotNull(reloaded, $"CopilotChat not found for second event {id}");
354+
Assert.IsNotNull(reloaded.Agent, $"Agent navigation null for second event {id}");
355+
Assert.AreEqual(agentId, reloaded.Agent.AgentID, $"AgentID mismatch for second event {id}");
356+
Assert.AreEqual(updatedTargetName, reloaded.Agent.Name, $"Updated Agent Name mismatch for second event {id}");
357+
}
358+
359+
// Assert previously created events now reflect updated agent name
360+
var previouslyCreatedIds = firstChatEvents.Select(e => e.Id).ToList();
361+
var previouslyCreatedChats = await _db.CopilotChats.Include(x => x.Agent)
362+
.Where(x => previouslyCreatedIds.Contains(x.AuditEvent.Id)).ToListAsync();
363+
foreach (var chat in previouslyCreatedChats)
364+
{
365+
if (chat.Agent != null)
366+
{
367+
await _db.Entry(chat.Agent).ReloadAsync();
368+
Assert.AreEqual(updatedTargetName, chat.Agent.Name, "Existing event did not reflect updated agent name from TargetAgentName");
369+
}
370+
}
371+
}
372+
}
373+
312374
/// <summary>
313375
/// Tests that AccessedResources are correctly saved to lookup tables and junction table
314376
/// </summary>
@@ -1137,7 +1199,7 @@ public void CopilotAuditLogContent_FromJson_ExtractsAgentFromAppIdentity()
11371199
Assert.AreEqual(appIdentity, result.AgentId, "AgentId should be set to AppIdentity value");
11381200
Assert.AreEqual(appIdentity, result.AppIdentity, "AppIdentity should be preserved");
11391201
Assert.AreEqual(organizationId, result.OrganizationId, "OrganizationId should be preserved");
1140-
Assert.IsTrue(result.IsCustomAgent.HasValue && result.IsCustomAgent.Value, "IsCustomAgent should be true when extracted from AppIdentity");
1202+
Assert.IsNull(result.IsCustomAgent, "IsCustomAgent should always be null from FromJson");
11411203
}
11421204

11431205
/// <summary>
@@ -1171,7 +1233,221 @@ public void CopilotAuditLogContent_FromJson_PreservesExistingAgentValues()
11711233
Assert.IsNotNull(result, "Result should not be null");
11721234
Assert.AreEqual(existingAgentName, result.AgentName, "Existing AgentName should be preserved");
11731235
Assert.AreEqual(existingAgentId, result.AgentId, "Existing AgentId should be preserved");
1174-
Assert.IsNull(result.IsCustomAgent, "IsCustomAgent should remain null when not extracted from AppIdentity");
1236+
Assert.IsNull(result.IsCustomAgent, "IsCustomAgent should always be null from FromJson");
1237+
}
1238+
1239+
/// <summary>
1240+
/// Tests that declarative agents (AgentId starting with "CopilotStudio.Declarative.") are not marked as custom
1241+
/// </summary>
1242+
[TestMethod]
1243+
public void CopilotAuditLogContent_FromJson_DeclarativeAgentIsNotCustom()
1244+
{
1245+
// Arrange
1246+
var agentName = "DeclarativeAgent";
1247+
var agentId = "CopilotStudio.Declarative.T_a83f31f8-c2a2-3418-17dd-c7a5c8b01a45.7c8cc6e4-257e-455b-b557-c6ffa78eda90";
1248+
1249+
var json = $@"{{
1250+
""AgentName"": ""{agentName}"",
1251+
""AgentId"": ""{agentId}"",
1252+
""CopilotEventData"": {{
1253+
""AppHost"": ""Teams"",
1254+
""AccessedResources"": [],
1255+
""Contexts"": []
1256+
}}
1257+
}}";
1258+
1259+
// Act
1260+
var result = CopilotAuditLogContent.FromJson(json);
1261+
1262+
// Assert
1263+
Assert.IsNotNull(result, "Result should not be null");
1264+
Assert.AreEqual(agentName, result.AgentName, "AgentName should be preserved");
1265+
Assert.AreEqual(agentId, result.AgentId, "AgentId should be preserved");
1266+
Assert.IsNull(result.IsCustomAgent, "IsCustomAgent should always be null from FromJson");
1267+
}
1268+
1269+
/// <summary>
1270+
/// Tests that TargetAgentName is used as the agent name and marked as custom agent
1271+
/// </summary>
1272+
[TestMethod]
1273+
public void CopilotAuditLogContent_FromJson_UsesTargetAgentNameAsCustomAgent()
1274+
{
1275+
// Arrange
1276+
var targetAgentName = "MyCustomEngineAgent";
1277+
var agentId = "custom-engine-agent-id-123";
1278+
1279+
var json = $@"{{
1280+
""AgentId"": ""{agentId}"",
1281+
""CopilotEventData"": {{
1282+
""AppHost"": ""Teams"",
1283+
""AccessedResources"": [],
1284+
""Contexts"": [],
1285+
""TargetAgentName"": ""{targetAgentName}""
1286+
}}
1287+
}}";
1288+
1289+
// Act
1290+
var result = CopilotAuditLogContent.FromJson(json);
1291+
1292+
// Assert
1293+
Assert.IsNotNull(result, "Result should not be null");
1294+
Assert.AreEqual(targetAgentName, result.AgentName, "AgentName should be set from TargetAgentName");
1295+
Assert.AreEqual(agentId, result.AgentId, "AgentId should be preserved from JSON");
1296+
Assert.IsNull(result.IsCustomAgent, "IsCustomAgent should always be null from FromJson");
1297+
}
1298+
1299+
/// <summary>
1300+
/// Tests that TargetAgentName takes priority over AgentName when both are present
1301+
/// </summary>
1302+
[TestMethod]
1303+
public void CopilotAuditLogContent_FromJson_TargetAgentNameOverridesAgentName()
1304+
{
1305+
// Arrange
1306+
var targetAgentName = "CustomEngineAgent";
1307+
var declarativeAgentName = "DeclarativeAgent";
1308+
var agentId = "agent-id-456";
1309+
1310+
var json = $@"{{
1311+
""AgentName"": ""{declarativeAgentName}"",
1312+
""AgentId"": ""{agentId}"",
1313+
""CopilotEventData"": {{
1314+
""AppHost"": ""Teams"",
1315+
""AccessedResources"": [],
1316+
""Contexts"": [],
1317+
""TargetAgentName"": ""{targetAgentName}""
1318+
}}
1319+
}}";
1320+
1321+
// Act
1322+
var result = CopilotAuditLogContent.FromJson(json);
1323+
1324+
// Assert
1325+
Assert.AreEqual(targetAgentName, result.AgentName, "TargetAgentName should take priority over AgentName");
1326+
Assert.AreEqual(agentId, result.AgentId, "AgentId should be preserved");
1327+
Assert.IsNull(result.IsCustomAgent, "IsCustomAgent should always be null from FromJson");
1328+
}
1329+
1330+
/// <summary>
1331+
/// Tests that TargetAgentName takes priority over AppIdentity fallback
1332+
/// </summary>
1333+
[TestMethod]
1334+
public void CopilotAuditLogContent_FromJson_TargetAgentNameSkipsAppIdentityFallback()
1335+
{
1336+
// Arrange
1337+
var organizationId = "873ca9a3-4805-48f2-b419-fabf868641da";
1338+
var appIdentity = $"Copilot.Studio.Default-{organizationId}-appIdentityAgent";
1339+
var targetAgentName = "CustomEngineAgentFromTargetField";
1340+
1341+
var json = $@"{{
1342+
""OrganizationId"": ""{organizationId}"",
1343+
""AppIdentity"": ""{appIdentity}"",
1344+
""CopilotEventData"": {{
1345+
""AppHost"": ""Teams"",
1346+
""AccessedResources"": [],
1347+
""Contexts"": [],
1348+
""TargetAgentName"": ""{targetAgentName}""
1349+
}}
1350+
}}";
1351+
1352+
// Act
1353+
var result = CopilotAuditLogContent.FromJson(json);
1354+
1355+
// Assert
1356+
Assert.AreEqual(targetAgentName, result.AgentName, "TargetAgentName should be used instead of AppIdentity extraction");
1357+
Assert.AreEqual(appIdentity, result.AgentId, "AgentId should fall back to AppIdentity when not set in JSON");
1358+
Assert.IsNull(result.IsCustomAgent, "IsCustomAgent should always be null from FromJson");
1359+
}
1360+
1361+
/// <summary>
1362+
/// Tests that TargetAgentName uses AppIdentity as AgentId when AgentId is not set
1363+
/// </summary>
1364+
[TestMethod]
1365+
public void CopilotAuditLogContent_FromJson_TargetAgentNameUsesAppIdentityAsAgentIdFallback()
1366+
{
1367+
// Arrange
1368+
var targetAgentName = "CustomEngineAgent";
1369+
var appIdentity = "Copilot.Studio.Default-someorg-someagent";
1370+
1371+
var json = $@"{{
1372+
""AppIdentity"": ""{appIdentity}"",
1373+
""CopilotEventData"": {{
1374+
""AppHost"": ""Teams"",
1375+
""AccessedResources"": [],
1376+
""Contexts"": [],
1377+
""TargetAgentName"": ""{targetAgentName}""
1378+
}}
1379+
}}";
1380+
1381+
// Act
1382+
var result = CopilotAuditLogContent.FromJson(json);
1383+
1384+
// Assert
1385+
Assert.AreEqual(targetAgentName, result.AgentName, "AgentName should be set from TargetAgentName");
1386+
Assert.AreEqual(appIdentity, result.AgentId, "AgentId should fall back to AppIdentity when not in JSON");
1387+
Assert.IsNull(result.IsCustomAgent, "IsCustomAgent should always be null from FromJson");
1388+
}
1389+
1390+
/// <summary>
1391+
/// Tests that TargetAgentName preserves existing AgentId when it is set
1392+
/// </summary>
1393+
[TestMethod]
1394+
public void CopilotAuditLogContent_FromJson_TargetAgentNamePreservesExistingAgentId()
1395+
{
1396+
// Arrange
1397+
var targetAgentName = "CustomEngineAgent";
1398+
var agentId = "explicit-agent-id-789";
1399+
var appIdentity = "Copilot.Studio.Default-someorg-someagent";
1400+
1401+
var json = $@"{{
1402+
""AgentId"": ""{agentId}"",
1403+
""AppIdentity"": ""{appIdentity}"",
1404+
""CopilotEventData"": {{
1405+
""AppHost"": ""Teams"",
1406+
""AccessedResources"": [],
1407+
""Contexts"": [],
1408+
""TargetAgentName"": ""{targetAgentName}""
1409+
}}
1410+
}}";
1411+
1412+
// Act
1413+
var result = CopilotAuditLogContent.FromJson(json);
1414+
1415+
// Assert
1416+
Assert.AreEqual(targetAgentName, result.AgentName, "AgentName should be set from TargetAgentName");
1417+
Assert.AreEqual(agentId, result.AgentId, "AgentId from JSON should be preserved, not overwritten by AppIdentity");
1418+
Assert.IsNull(result.IsCustomAgent, "IsCustomAgent should always be null from FromJson");
1419+
}
1420+
1421+
/// <summary>
1422+
/// Tests that TargetAgentName triggers Copilot Credit calculation as a custom agent
1423+
/// </summary>
1424+
[TestMethod]
1425+
public void CopilotAuditLogContent_FromJson_TargetAgentNameCalculatesCost()
1426+
{
1427+
// Arrange - JSON with TargetAgentName inside CopilotEventData and event data that would incur costs
1428+
var json = @"{
1429+
""AgentId"": ""cost-test-agent-id"",
1430+
""CopilotEventData"": {
1431+
""AppHost"": ""Teams"",
1432+
""AccessedResources"": [
1433+
{ ""Type"": ""File"", ""SiteUrl"": ""https://contoso.sharepoint.com/sites/team"" }
1434+
],
1435+
""Contexts"": [],
1436+
""Messages"": [
1437+
{ ""Id"": ""msg1"", ""isPrompt"": true },
1438+
{ ""Id"": ""msg2"", ""isPrompt"": false }
1439+
],
1440+
""TargetAgentName"": ""CostTestAgent""
1441+
}
1442+
}";
1443+
1444+
// Act
1445+
var result = CopilotAuditLogContent.FromJson(json);
1446+
1447+
// Assert
1448+
Assert.IsNotNull(result.Cost, "Cost should be calculated for TargetAgentName (custom agent)");
1449+
Assert.IsNull(result.IsCustomAgent, "IsCustomAgent should always be null from FromJson");
1450+
Assert.AreEqual("CostTestAgent", result.AgentName, "AgentName should be set from TargetAgentName");
11751451
}
11761452

11771453
/// <summary>
@@ -1326,7 +1602,7 @@ public void CopilotAuditLogContent_FromJson_HandlesVariousAgentNameFormats()
13261602
Assert.IsNotNull(result, $"Result should not be null for agent name: {expectedAgentName}");
13271603
Assert.AreEqual(expectedAgentName, result.AgentName, $"AgentName should be correctly extracted for: {expectedAgentName}");
13281604
Assert.AreEqual(appIdentity, result.AgentId, $"AgentId should be set to AppIdentity for: {expectedAgentName}");
1329-
Assert.IsTrue(result.IsCustomAgent.HasValue && result.IsCustomAgent.Value, $"IsCustomAgent should be true for: {expectedAgentName}");
1605+
Assert.IsNull(result.IsCustomAgent, $"IsCustomAgent should always be null from FromJson for: {expectedAgentName}");
13301606
}
13311607
}
13321608

src/AnalyticsEngine/WebJob.Office365ActivityImporter.Engine/ActivityAPI/Copilot/SQL/common_upsert_copilot_agents.sql

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,28 @@ INSERT INTO copilot_agents([name], [agent_id], [is_custom_agent])
99
-- Update agent names to the first value in imports.agent_name for matching agent_id
1010
UPDATE copilot_agents
1111
SET [name] = (
12-
SELECT TOP 1 imports.agent_name
13-
FROM [${STAGING_TABLE_ACTIVITY}] imports
14-
WHERE copilot_agents.[agent_id] = imports.[agent_id]
15-
AND imports.agent_name IS NOT NULL
16-
AND imports.agent_name <> copilot_agents.[name]
17-
ORDER BY imports.agent_name
12+
SELECT TOP 1 imports.agent_name
13+
FROM [${STAGING_TABLE_ACTIVITY}] imports
14+
WHERE copilot_agents.[agent_id] = imports.[agent_id]
15+
AND imports.agent_name IS NOT NULL
16+
AND imports.agent_name <> copilot_agents.[name]
17+
ORDER BY imports.agent_name
1818
),
1919
[is_custom_agent] = (
20-
SELECT TOP 1 imports.is_custom_agent
21-
FROM [${STAGING_TABLE_ACTIVITY}] imports
22-
WHERE copilot_agents.[agent_id] = imports.[agent_id]
23-
AND imports.is_custom_agent IS NOT NULL
24-
ORDER BY imports.agent_name
20+
SELECT TOP 1 imports.is_custom_agent
21+
FROM [${STAGING_TABLE_ACTIVITY}] imports
22+
WHERE copilot_agents.[agent_id] = imports.[agent_id]
23+
AND imports.is_custom_agent IS NOT NULL
24+
ORDER BY imports.agent_name
2525
)
2626
WHERE EXISTS (
27-
SELECT 1
28-
FROM [${STAGING_TABLE_ACTIVITY}] imports
29-
WHERE copilot_agents.[agent_id] = imports.[agent_id]
30-
AND (
31-
(imports.agent_name IS NOT NULL AND imports.agent_name <> copilot_agents.[name])
32-
OR (imports.is_custom_agent IS NOT NULL AND (copilot_agents.[is_custom_agent] IS NULL OR imports.is_custom_agent <> copilot_agents.[is_custom_agent]))
33-
)
27+
SELECT 1
28+
FROM [${STAGING_TABLE_ACTIVITY}] imports
29+
WHERE copilot_agents.[agent_id] = imports.[agent_id]
30+
AND (
31+
(imports.agent_name IS NOT NULL AND imports.agent_name <> copilot_agents.[name])
32+
OR (imports.is_custom_agent IS NOT NULL AND (copilot_agents.[is_custom_agent] IS NULL OR imports.is_custom_agent <> copilot_agents.[is_custom_agent]))
33+
)
3434
);
3535

3636

0 commit comments

Comments
 (0)