Skip to content

Commit d3a7f96

Browse files
authored
Merge pull request #1466 from yileicn/master
feat(agent-testing): let output assertions and llmJudge read rich content
2 parents 237d025 + 8fa8603 commit d3a7f96

10 files changed

Lines changed: 152 additions & 21 deletions

File tree

‎src/Plugins/BotSharp.Plugin.AgentTesting/Models/AgentTestCaseResult.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ public class TurnResult
9797
public int Index { get; set; }
9898
public string UserMessage { get; set; } = default!;
9999
public string? Output { get; set; }
100+
101+
/// <summary>The reply's rich message as JSON, null for a plain reply. What richContent assertions read.</summary>
102+
public string? RichContent { get; set; }
103+
100104
public List<AssertionResult> Assertions { get; set; } = [];
101105

102106
/// <summary>

‎src/Plugins/BotSharp.Plugin.AgentTesting/Services/AgentTestCaseRunner.cs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ async Task<T> AwaitOrHandOffAsync<T>(Task<T> driverTask)
213213
// canary, the mock lookups and the conversation reads, and on a fast case that
214214
// overhead is a big enough share to move a latency percentile.
215215
var turnTimer = Stopwatch.StartNew();
216-
var output = await AwaitOrHandOffAsync(
216+
var reply = await AwaitOrHandOffAsync(
217217
_driver.SendAsync(conversationId, entryAgentId, turn.UserMessage, timeout.Token));
218218
turnTimer.Stop();
219219
modelDurationMs += turnTimer.ElapsedMilliseconds;
@@ -226,7 +226,8 @@ async Task<T> AwaitOrHandOffAsync<T>(Task<T> driverTask)
226226
{
227227
Index = turn.Index,
228228
UserMessage = turn.UserMessage,
229-
Output = output,
229+
Output = reply.Text,
230+
RichContent = reply.RichContent,
230231
ModelDurationMs = turnTimer.ElapsedMilliseconds,
231232
// Names, not hops: a result is read by a person, and the ids are only needed
232233
// while an assertion is being evaluated.
@@ -239,7 +240,8 @@ async Task<T> AwaitOrHandOffAsync<T>(Task<T> driverTask)
239240
// assertion about routing that never happened.
240241
var turnContext = new AssertionContext
241242
{
242-
Output = output,
243+
Output = reply.Text,
244+
RichContent = reply.RichContent,
243245
ToolCalls = active.ObservedCalls.Where(c => c.TurnIndex == turn.Index).ToList(),
244246
States = await _driver.ReadStatesAsync(conversationId),
245247
AgentChain = turnChain
@@ -265,6 +267,7 @@ async Task<T> AwaitOrHandOffAsync<T>(Task<T> driverTask)
265267
var finalContext = new AssertionContext
266268
{
267269
Output = result.Turns.LastOrDefault()?.Output,
270+
RichContent = result.Turns.LastOrDefault()?.RichContent,
268271
ToolCalls = active.ObservedCalls,
269272
States = await _driver.ReadStatesAsync(conversationId),
270273
AgentChain = caseChain

‎src/Plugins/BotSharp.Plugin.AgentTesting/Services/AssertionContext.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ namespace BotSharp.Plugin.AgentTesting.Services;
77
public class AssertionContext
88
{
99
public string? Output { get; set; }
10+
11+
/// <summary>Read only by assertions targeting <see cref="OutputSources.RichContent"/>.</summary>
12+
public string? RichContent { get; set; }
13+
1014
public IReadOnlyList<ObservedToolCall> ToolCalls { get; set; } = [];
1115
public IReadOnlyDictionary<string, string?> States { get; set; } = new Dictionary<string, string?>();
1216

‎src/Plugins/BotSharp.Plugin.AgentTesting/Services/AssertionEvaluator.cs‎

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,23 @@ public static AssertionResult Evaluate(TestAssertion assertion, AssertionContext
4141
Expected = assertion.Expected
4242
};
4343

44+
string? output = null;
45+
if (assertion.Type is AssertionTypes.OutputContains or AssertionTypes.OutputNotContains or AssertionTypes.OutputRegex)
46+
{
47+
var source = OutputSources.Normalize(assertion.Target);
48+
if (source == null)
49+
{
50+
result.Passed = false;
51+
result.Message = OutputSources.InvalidTargetMessage(assertion);
52+
return result;
53+
}
54+
output = OutputSources.Read(source, context);
55+
}
56+
4457
switch (assertion.Type)
4558
{
4659
case AssertionTypes.OutputContains:
47-
result.Actual = context.Output;
60+
result.Actual = output;
4861
if (string.IsNullOrEmpty(assertion.Expected))
4962
{
5063
// Contains("") is vacuously true for any non-null string -- a blank/omitted
@@ -55,21 +68,21 @@ public static AssertionResult Evaluate(TestAssertion assertion, AssertionContext
5568
}
5669
else
5770
{
58-
result.Passed = context.Output?.Contains(assertion.Expected,
71+
result.Passed = output?.Contains(assertion.Expected,
5972
StringComparison.OrdinalIgnoreCase) == true;
6073
if (!result.Passed) result.Message = "output does not contain the expected text";
6174
}
6275
break;
6376

6477
case AssertionTypes.OutputNotContains:
65-
result.Actual = context.Output;
66-
result.Passed = context.Output?.Contains(assertion.Expected ?? string.Empty,
78+
result.Actual = output;
79+
result.Passed = output?.Contains(assertion.Expected ?? string.Empty,
6780
StringComparison.OrdinalIgnoreCase) != true;
6881
if (!result.Passed) result.Message = "output contains text that should not appear";
6982
break;
7083

7184
case AssertionTypes.OutputRegex:
72-
result.Actual = context.Output;
85+
result.Actual = output;
7386
if (string.IsNullOrEmpty(assertion.Expected))
7487
{
7588
// An empty pattern matches everything -- a blank/omitted Expected must not
@@ -80,7 +93,7 @@ public static AssertionResult Evaluate(TestAssertion assertion, AssertionContext
8093
}
8194
try
8295
{
83-
result.Passed = Regex.IsMatch(context.Output ?? string.Empty, assertion.Expected,
96+
result.Passed = Regex.IsMatch(output ?? string.Empty, assertion.Expected,
8497
RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
8598
if (!result.Passed) result.Message = "output does not match the pattern";
8699
}
@@ -338,6 +351,39 @@ public static class AgentChainModes
338351
: All.FirstOrDefault(m => string.Equals(m, value.Trim(), StringComparison.OrdinalIgnoreCase));
339352
}
340353

354+
/// <summary>
355+
/// Which text outputContains / outputNotContains / outputRegex and llmJudge read, selected by
356+
/// <see cref="TestAssertion.Target"/>.
357+
///
358+
/// Reply -- the text a user reads. The default.
359+
/// RichContent -- the reply's rich message as JSON, for content that exists only there, such as an
360+
/// upload card's photo and video lists. Opt-in, so button titles and other structure
361+
/// never leak into the reply text every existing assertion was written against.
362+
/// </summary>
363+
public static class OutputSources
364+
{
365+
public const string Reply = "reply";
366+
public const string RichContent = "richContent";
367+
368+
public static readonly string[] All = [Reply, RichContent];
369+
370+
/// <summary>
371+
/// Canonical source for any casing, with blank meaning <see cref="Reply"/>; null for an
372+
/// unrecognised value, so a typo fails loudly instead of silently reading the reply text.
373+
/// </summary>
374+
public static string? Normalize(string? value)
375+
=> string.IsNullOrWhiteSpace(value)
376+
? Reply
377+
: All.FirstOrDefault(s => string.Equals(s, value.Trim(), StringComparison.OrdinalIgnoreCase));
378+
379+
public static string? Read(string source, AssertionContext context)
380+
=> source == RichContent ? context.RichContent : context.Output;
381+
382+
public static string InvalidTargetMessage(TestAssertion assertion)
383+
=> $"{assertion.Type} 'target' must be one of {string.Join(", ", All)} "
384+
+ $"(or empty for {Reply}), not '{assertion.Target}'";
385+
}
386+
341387
/// <summary>
342388
/// Save-time counterpart to the four fixed <see cref="AssertionEvaluator"/> branches above
343389
/// (outputContains/outputRegex/toolNotCalled/routedToAgent): an assertion missing the one field

‎src/Plugins/BotSharp.Plugin.AgentTesting/Services/BotSharpAgentConversationDriver.cs‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
using System.Text.Encodings.Web;
2+
using System.Text.Json;
13
using BotSharp.Abstraction.Agents;
24
using BotSharp.Abstraction.Agents.Enums;
35
using BotSharp.Abstraction.Conversations.Enums;
6+
using BotSharp.Abstraction.Messaging;
47
using BotSharp.Abstraction.Models;
58
using BotSharp.Abstraction.Repositories;
69
using BotSharp.Abstraction.Routing;
@@ -126,7 +129,7 @@ public async Task<int> InjectHistoryAsync(
126129
return stored.Count(d => d.MetaData?.MessageId != null && written.Contains(d.MetaData.MessageId));
127130
}
128131

129-
public async Task<string?> SendAsync(string conversationId, string agentId, string userMessage, CancellationToken ct)
132+
public async Task<AgentReply> SendAsync(string conversationId, string agentId, string userMessage, CancellationToken ct)
130133
{
131134
// Fail fast only if the case had already timed out before we ever got here (e.g. a prior
132135
// turn ran long). Once the real call below starts, there is no cancellation hook left to
@@ -152,7 +155,34 @@ await _conversations.SendMessage(
152155
// label -- are what decide pass/fail.
153156
await EnsureConversationTaggedAsync(conversationId);
154157

155-
return last?.RichContent?.Message?.Text ?? last?.Content;
158+
var message = last?.RichContent?.Message;
159+
return new AgentReply(message?.Text ?? last?.Content, SerializeRichMessage(message));
160+
}
161+
162+
// Unescaped so apostrophes and dashes stay literal: assertions match against this text and a
163+
// judge reads it, and neither should have to see "it's".
164+
private static readonly JsonSerializerOptions RichMessageJsonOptions = new()
165+
{
166+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
167+
};
168+
169+
private string? SerializeRichMessage(IRichMessage? message)
170+
{
171+
if (message == null) return null;
172+
173+
try
174+
{
175+
// By runtime type: serializing as IRichMessage would drop every property the concrete
176+
// message adds, which is exactly the content a richContent assertion is after.
177+
return JsonSerializer.Serialize(message, message.GetType(), RichMessageJsonOptions);
178+
}
179+
catch (Exception ex) when (ex is NotSupportedException or JsonException)
180+
{
181+
// Only richContent assertions read this; a message that cannot be serialized must not
182+
// fail a case whose assertions never look at it.
183+
_logger.LogWarning(ex, "Could not serialize rich message {Type} for agent-test output.", message.GetType().Name);
184+
return null;
185+
}
156186
}
157187

158188
private async Task EnsureConversationTaggedAsync(string conversationId)

‎src/Plugins/BotSharp.Plugin.AgentTesting/Services/IAgentConversationDriver.cs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ public interface IAgentConversationDriver
2222
Task<int> InjectHistoryAsync(
2323
string conversationId, string agentId, IReadOnlyList<TestHistoryMessage> history);
2424

25-
/// <summary>Drives one turn and returns that turn's output text.</summary>
26-
Task<string?> SendAsync(string conversationId, string agentId, string userMessage, CancellationToken ct);
25+
/// <summary>Drives one turn and returns what that turn produced.</summary>
26+
Task<AgentReply> SendAsync(string conversationId, string agentId, string userMessage, CancellationToken ct);
2727

2828
/// <summary>Calls the canary function once; returns whether the mock seam took it over.</summary>
2929
Task<bool> RunCanaryAsync(string conversationId, string agentId, CancellationToken ct);
@@ -43,3 +43,10 @@ Task<int> InjectHistoryAsync(
4343
/// </summary>
4444
Task<IReadOnlyList<AgentChainHop>> ReadAssistantAgentSequenceAsync(string conversationId);
4545
}
46+
47+
/// <summary>
48+
/// One turn's reply. Text is what a user reads: the rich message's text, else the plain content.
49+
/// RichContent is the rich message serialized as JSON, null for a plain reply -- structured content
50+
/// such as an upload card's photo list exists only there.
51+
/// </summary>
52+
public record AgentReply(string? Text, string? RichContent);

‎src/Plugins/BotSharp.Plugin.AgentTesting/Services/LlmAgentTestJudge.cs‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,21 @@ public async Task<AssertionResult> JudgeAsync(
6262
+ "Set the suite's judgeProvider and judgeModel, or remove the llmJudge assertion.");
6363
}
6464

65-
if (string.IsNullOrWhiteSpace(context.Output))
65+
var source = OutputSources.Normalize(assertion.Target);
66+
if (source == null)
67+
{
68+
throw new AgentTestJudgeUnavailableException(OutputSources.InvalidTargetMessage(assertion));
69+
}
70+
71+
var output = OutputSources.Read(source, context);
72+
if (string.IsNullOrWhiteSpace(output))
6673
{
6774
// Not a failing verdict: the agent produced no text at all, so there is nothing for the
6875
// judge to grade. Whatever went wrong upstream is the real finding, and the other
6976
// assertions on this case will say so far more usefully than a fabricated score would.
70-
throw new AgentTestJudgeUnavailableException(
71-
"the agent produced no reply text, so there is nothing for llmJudge to score");
77+
throw new AgentTestJudgeUnavailableException(source == OutputSources.RichContent
78+
? "the agent produced no rich content, so there is nothing for llmJudge to score"
79+
: "the agent produced no reply text, so there is nothing for llmJudge to score");
7280
}
7381

7482
ct.ThrowIfCancellationRequested();
@@ -101,7 +109,7 @@ public async Task<AssertionResult> JudgeAsync(
101109
{
102110
var response = await completion.GetChatCompletions(
103111
promptAgent,
104-
[new RoleDialogModel(AgentRole.User, BuildPrompt(criterion, context.Output))]);
112+
[new RoleDialogModel(AgentRole.User, BuildPrompt(criterion, output))]);
105113

106114
raw = response?.Content ?? string.Empty;
107115
}

‎tests/BotSharp.Core.UnitTests/AgentTesting/AgentTestCaseRunnerTests.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ public Task PrepareAsync(string conversationId, string agentId, IReadOnlyList<Te
115115
public IAgentTestRunRegistry? Registry { get; set; }
116116
public List<string> BlockedOnFirstSend { get; } = [];
117117

118-
public async Task<string?> SendAsync(string conversationId, string agentId, string userMessage, CancellationToken ct)
118+
public async Task<AgentReply> SendAsync(string conversationId, string agentId, string userMessage, CancellationToken ct)
119119
{
120120
Sent.Add(userMessage);
121121
SentAgentIds.Add(agentId);
@@ -171,7 +171,7 @@ public Task PrepareAsync(string conversationId, string agentId, IReadOnlyList<Te
171171
throw ThrowAfterDelay;
172172
}
173173

174-
return Replies.Count > 0 ? Replies.Dequeue() : string.Empty;
174+
return new AgentReply(Replies.Count > 0 ? Replies.Dequeue() : string.Empty, null);
175175
}
176176

177177
public Task<int> InjectHistoryAsync(

‎tests/BotSharp.Core.UnitTests/AgentTesting/AgentTestJudgeTests.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,8 @@ private sealed class StubDriver : IAgentConversationDriver
212212
public Task PrepareAsync(string conversationId, string agentId, IReadOnlyList<TestState> initialStates)
213213
=> Task.CompletedTask;
214214

215-
public Task<string?> SendAsync(string conversationId, string agentId, string userMessage, CancellationToken ct)
216-
=> Task.FromResult<string?>(_reply);
215+
public Task<AgentReply> SendAsync(string conversationId, string agentId, string userMessage, CancellationToken ct)
216+
=> Task.FromResult(new AgentReply(_reply, null));
217217

218218
public Task<bool> RunCanaryAsync(string conversationId, string agentId, CancellationToken ct)
219219
=> Task.FromResult(true);

‎tests/BotSharp.Core.UnitTests/AgentTesting/AssertionEvaluatorTests.cs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,35 @@ public void Output_regex_reports_a_bad_pattern_as_a_failed_assertion_not_an_exce
7272
Assert.Contains("invalid regular expression", result.Message!);
7373
}
7474

75+
[Fact]
76+
public void Output_assertions_read_the_rich_content_only_when_targeted()
77+
{
78+
var context = Context(output: "One photo would help.");
79+
context.RichContent = """{"upload_guidance":{"photos":["Photograph the cracked pipe"]},"buttons":[{"title":"Skip upload"}]}""";
80+
81+
var targeted = AssertionEvaluator.Evaluate(
82+
new TestAssertion { Type = AssertionTypes.OutputContains, Target = "richcontent", Expected = "cracked pipe" },
83+
context);
84+
var untargeted = AssertionEvaluator.Evaluate(
85+
new TestAssertion { Type = AssertionTypes.OutputNotContains, Expected = "skip" },
86+
context);
87+
88+
Assert.True(targeted.Passed);
89+
Assert.Equal(context.RichContent, targeted.Actual);
90+
Assert.True(untargeted.Passed);
91+
}
92+
93+
[Fact]
94+
public void Output_assertions_fail_on_an_unknown_target_instead_of_reading_the_reply()
95+
{
96+
var result = AssertionEvaluator.Evaluate(
97+
new TestAssertion { Type = AssertionTypes.OutputContains, Target = "rich", Expected = "photo" },
98+
Context(output: "One photo would help."));
99+
100+
Assert.False(result.Passed);
101+
Assert.Contains("'target' must be one of", result.Message!);
102+
}
103+
75104
[Fact]
76105
public void Tool_called_matches_arguments_as_a_subset()
77106
{

0 commit comments

Comments
 (0)