From 4e7a46b3f1c56a411cb2582346d3e54c934b3ead Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 14:17:38 +0000 Subject: [PATCH] Reframe MessageMerger to pure emission order; drop CreatedAt sort Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/570d306e-62db-44a6-998a-915fc8fe8807 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../0026-message-merger-invariants.md | 140 +++++++++++------- .../MessageMerger.cs | 28 ++-- .../MessageMergerTests.cs | 131 ++++++++++++++-- 3 files changed, 213 insertions(+), 86 deletions(-) diff --git a/docs/decisions/0026-message-merger-invariants.md b/docs/decisions/0026-message-merger-invariants.md index 48f461412e..58b32c3e0a 100644 --- a/docs/decisions/0026-message-merger-invariants.md +++ b/docs/decisions/0026-message-merger-invariants.md @@ -22,57 +22,74 @@ Prior to this change the contract that hosting executors and the merger should jointly enforce was implicit. The implementation also carried a small amount of dead state (`createdTimes`) that was collected but never consumed, suggesting that an earlier, timestamp-driven ordering scheme had been -abandoned without documentation. There were no tests pinning down the +abandoned without documentation, and the live code still ran an unreliable +`CreatedAt`-based sort that could reorder messages across concurrent agents +inside a single workflow super-step. There were no tests pinning down the ordering or grouping behavior, so any future refactor risked silently regressing it. The problem this ADR addresses is therefore: **what merge guarantees does `MessageMerger` make to its callers, and how do we lock those guarantees in -without changing observable behavior in this PR?** +without changing observable behavior in non-pathological cases?** ## Decision Drivers -- **A. Predictable ordering**: developers consuming a merged `AgentResponse` - must be able to reason about the order in which messages appear, even when - some updates lack `CreatedAt`. -- **B. Coherent multi-agent transcripts**: when several agents stream - concurrently into one merger (handoff, orchestrator-as-agent), each - agent's contribution must read as a contiguous block. -- **C. Stable hosting-executor contract**: a turn must be addressable by a +- **A. Predictable ordering.** Developers consuming a merged `AgentResponse` + must be able to reason about the order in which messages appear without + having to know whether updates carried `CreatedAt`. +- **B. Coherent multi-agent transcripts.** When several agents stream into + one merger within a single workflow super-step, each agent's contribution + must read as a contiguous block; and a step's updates must precede the + next step's updates. +- **C. Stable hosting-executor contract.** A turn must be addressable by a single `ResponseId`; updates without one are an exceptional, "dangling" case rather than the norm. -- **D. Minimal behavioral change**: this work is intended to document and - test current behavior, not to alter what users see today. -- **E. Discoverability for future contributors**: known sharp edges (e.g. +- **D. Minimal behavioral change for non-pathological inputs.** This work + is intended to document and test current behavior, not to alter what + users see today in well-formed agent streams. +- **E. Discoverability for future contributors.** Known sharp edges (e.g. cross-`ResponseId` ordering, dangling-update placement) should be written down so they are not rediscovered as bugs. ## Considered Options -1. **Option 1 — Document invariants and pin them with tests; remove the - dead `createdTimes` collection.** No behavioral change. -2. **Option 2 — Rewrite `MessageMerger` to group strictly by `AgentId`, - and to use a stable, transitive comparer that mixes `CreatedAt` with - insertion index.** Behavioral change; would also require updating - hosting executors that currently assume `ResponseId`-based grouping. +1. **Option 1 — Document invariants, pin them with tests, and use pure + emission/insertion order for both responses and the messages inside + each response.** Removes the unreliable `CreatedAt`-based sort and the + dead `createdTimes` collection. Behavioral change only for inputs that + relied on `CreatedAt` to re-order updates after the fact — which the + prior comparer could not do correctly anyway (non-transitive). +2. **Option 2 — Rewrite `MessageMerger` to group strictly by `AgentId` + (rather than `ResponseId`), and to use a stable, transitive comparer + that mixes `CreatedAt` with insertion index.** Behavioral change; would + also require updating hosting executors that currently assume + `ResponseId`-based grouping. 3. **Option 3 — Leave the code and tests as-is; capture edge cases only in a working note.** No code or test change. ## Decision Outcome -Chosen option: **Option 1 — Document invariants and pin them with tests; -remove the dead `createdTimes` collection.** +Chosen option: **Option 1 — Document invariants, pin them with tests, +and use pure emission/insertion order; remove the dead `createdTimes` +collection.** -This option satisfies driver D (minimal behavioral change) and driver E -(discoverability) while making real progress on A, B, and C: by writing the -invariants down and covering them with regression tests, future refactors -must either preserve the invariants or explicitly supersede this ADR. The -dead `createdTimes` collection is removed because it actively misleads -readers about the ordering strategy. +This option satisfies driver A (predictable ordering: emission order is +the simplest reasoning model), driver B (per-`ResponseId` grouping plus +first-seen ordering across responses gives both per-agent blocks within a +step and step-ordering across steps), driver C (single ResponseId per +turn is unchanged), and driver E (invariants and edge cases are now +written down and covered by tests). + +Driver D (minimal behavioral change) is satisfied because well-formed +agent streams already emit updates in the order they want to appear; the +prior `CreatedAt`-based sort only mattered for pathological inputs (mixed +or out-of-order timestamps from concurrent agents), and on those inputs +the old comparer was non-transitive and therefore unreliable. Removing +the sort makes those inputs deterministic — they now follow emission +order — without disturbing the well-formed case. Option 2 was rejected for this iteration because it changes what callers -observe (e.g., would re-order outputs in any flow that relies on -`ResponseId`-based grouping today) and would require a coordinated change +observe in well-formed flows and would require a coordinated change across hosting executors. It is a candidate for a follow-up ADR if the known edge cases below are reported as bugs in practice. @@ -87,30 +104,45 @@ The following invariants are now part of the contract of `MessageMergerTests`: 1. **Single `ResponseId` per turn.** Every `AgentResponseUpdate` produced - by a hosting executor in a single turn shares one `ResponseId`. If the - underlying agent does not supply one, the executor assigns it. Updates - with `ResponseId == null` are treated as "dangling" and flattened into - loose messages at the end of the merged response. -2. **Output order preservation for untimestamped messages.** When updates - lack `CreatedAt`, their relative order in the merged output matches - their arrival order. `CompareByDateTimeOffset` falls back to insertion - index when timestamps are missing or equal. + by a hosting executor in a single agent turn shares one `ResponseId`. + If the underlying agent does not supply one, the executor assigns it. + Updates with `ResponseId == null` are treated as "dangling" and + flattened into loose messages at the end of the merged response. +2. **Pure emission-order preservation.** Within a `ResponseId` block, + messages appear in the order their updates first arrived at the + merger. Across `ResponseId` blocks, blocks appear in first-seen order. + `CreatedAt` is **not** consulted when ordering messages or blocks — + only when stamping the merged response and its child messages. 3. **Per-`ResponseId` grouping (no interleaving).** Messages produced under one `ResponseId` are emitted as a contiguous block in the merged - `AgentResponse`. In multi-agent scenarios where each agent uses its - own `ResponseId`, this also yields per-agent grouping. + `AgentResponse`. Combined with Invariant 1, this yields: + - **Within a workflow super-step with one agent**: all messages + appear together, in emission order. + - **Within a super-step with multiple agents**: each agent's messages + are a contiguous block, ordered by which agent emitted first. + - **Across super-steps**: a step's blocks all precede the next step's + blocks, because the next step cannot start emitting until the + current step's emissions have arrived at the merger. ### Consequences -- Good, because the merge contract is now explicit and regression-tested, - reducing the risk of silent behavioral drift. +- Good, because the merge contract is now explicit, regression-tested, + and trivially reasoned about (it is just emission order with + per-`ResponseId` grouping). +- Good, because removing the unreliable `CreatedAt`-based sort eliminates + a latent bug — the prior comparer was non-transitive on mixed-timestamp + inputs, so `List.Sort` could in principle return any of several + orderings or throw on some runtimes. - Good, because removing the unused `createdTimes` collection eliminates a misleading code smell. - Good, because hosting-executor authors have a written checklist of invariants to satisfy. -- Neutral, because no end-user behavior changes. -- Bad, because several known edge cases (see below) are documented but - not fixed; consumers may still encounter them. +- Neutral, because well-formed agent streams (those that emit updates in + the order they want to appear) see no change in output. +- Bad, because callers who relied on a server-supplied `CreatedAt` to + retro-correct out-of-order emissions will no longer see that + correction — they must ensure emission order matches desired output + order, or attach to `RawRepresentation` for original timestamps. ## Validation @@ -118,9 +150,11 @@ The following invariants are now part of the contract of `dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs` cover each invariant: - Insertion-order preservation with no timestamps. - - Insertion-order preservation with mixed timestamps. + - Insertion-order preservation with mixed timestamps (emission order + wins over `CreatedAt`). - Determinism across repeated runs with mixed timestamps. - - Per-`ResponseId` grouping for interleaved multi-agent streams. + - Per-`ResponseId` grouping for interleaved multi-agent streams within + a single step. - Per-`ResponseId` grouping with distinct response ids. - Existing tests for assembly, function-call/result ordering, and `FinishReason` propagation continue to pass. @@ -135,12 +169,12 @@ but they are present in the shipped behavior covered by tests above. | # | Edge case | Risk | Notes | |---|-----------|------|-------| -| 1 | `CompareByDateTimeOffset` is not transitive when some messages have `CreatedAt` and others do not (e.g. A=10, B=null, C=5 yields AC by timestamp). | Medium | `List.Sort` is not guaranteed to produce a unique ordering for non-transitive comparers, but the current tests verify that repeated runs over identical input produce identical output. Use `CreatedAt` consistently per response to avoid this. | -| 2 | Cross-`ResponseId` ordering follows first-seen-`ResponseId` order, not chronological order across responses. | Medium | Acceptable today because each turn has a single `ResponseId` (Invariant 1); only matters if a caller deliberately interleaves multiple response ids. | -| 3 | Updates with `ResponseId == null` are always emitted **after** all keyed responses, regardless of arrival time. | Medium | Documented as the "dangling" path; agents should always emit a `ResponseId`. | -| 4 | Within a response, updates with `MessageId == null` are always emitted **after** keyed messages. | Low | Same rationale as #3, scoped to messages within a response. | -| 5 | The merged response's `CreatedAt` is set to `DateTimeOffset.UtcNow`; per-response `CreatedAt` is propagated onto each contained `ChatMessage` instead of being preserved at the response level. | Low | Callers who need original per-update timestamps should read them from `RawRepresentation` or capture them before merging. | -| 6 | Metadata on dangling (`ResponseId == null`) updates — `FinishReason`, `Usage`, `AgentId`, `AdditionalProperties` — is **not** merged into the final `AgentResponse`; only their `Messages` are surfaced. | Medium | Hosting executors must attach metadata to a keyed update if they want it reflected in the merged response. | +| 1 | Cross-`ResponseId` ordering follows first-seen-`ResponseId` order, not chronological order across responses. | Medium | Acceptable today because each turn has a single `ResponseId` (Invariant 1); only matters if a caller deliberately interleaves multiple response ids inside one step. | +| 2 | Updates with `ResponseId == null` are always emitted **after** all keyed responses, regardless of arrival time. | Medium | Documented as the "dangling" path; agents should always emit a `ResponseId`. | +| 3 | Within a response, updates with `MessageId == null` are always emitted **after** keyed messages. | Low | Same rationale as #2, scoped to messages within a response. | +| 4 | The merged response's `CreatedAt` is set to `DateTimeOffset.UtcNow`; per-response `CreatedAt` is propagated onto each contained `ChatMessage` instead of being preserved at the response level. | Low | Callers who need original per-update timestamps should read them from `RawRepresentation` or capture them before merging. | +| 5 | Metadata on dangling (`ResponseId == null`) updates — `FinishReason`, `Usage`, `AgentId`, `AdditionalProperties` — is **not** merged into the final `AgentResponse`; only their `Messages` are surfaced. | Medium | Hosting executors must attach metadata to a keyed update if they want it reflected in the merged response. | +| 6 | Emission order is the **only** ordering signal — `CreatedAt` differences between updates are ignored when ordering. | Low | This is the intended behavior under Invariant 2; producers must emit in the desired output order. | If any of these become observable problems in production, the appropriate follow-up is a new ADR that supersedes this one (likely realizing @@ -149,8 +183,8 @@ follow-up is a new ADR that supersedes this one (likely realizing ### Code references - `dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs` — merger - implementation. The previously-unused `createdTimes` collection has - been removed in this change. + implementation. The previously-unused `createdTimes` collection and + the `CreatedAt`-based sort have both been removed in this change. - `dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs` — invariant tests added in this change. - `AgentInvocationContext.ResponseId` and `ToStreamingResponseAsync` — diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs index a7e9dab92c..32919d1c3b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs @@ -97,16 +97,6 @@ internal sealed class MessageMerger } } - private int CompareByDateTimeOffset(AgentResponse left, int leftIndex, AgentResponse right, int rightIndex) - { - if (!left.CreatedAt.HasValue || !right.CreatedAt.HasValue || left.CreatedAt == right.CreatedAt) - { - return leftIndex.CompareTo(rightIndex); - } - - return left.CreatedAt.Value.CompareTo(right.CreatedAt.Value); - } - public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgentId = null, string? primaryAgentName = null) { List messages = []; @@ -114,6 +104,15 @@ internal sealed class MessageMerger HashSet agentIds = []; HashSet finishReasons = []; + // Ordering contract (see docs/decisions/0026-message-merger-invariants.md): + // - Outer loop iterates ResponseIds in first-seen order, which preserves step + // ordering: each agent invocation owns its own ResponseId, and successive + // super-steps emit their first update only after the prior step's updates + // have all arrived. Iterating Dictionary<,>.Keys preserves insertion order. + // - Inner loop iterates MessageIds in first-seen order, then appends dangling + // updates last. This preserves emission order within an agent's block. + // We deliberately do NOT sort by CreatedAt: timestamps from concurrent agents + // or different clocks would interleave per-agent blocks and break Goal 1. foreach (string responseId in this._mergeStates.Keys) { ResponseMergeState mergeState = this._mergeStates[responseId]; @@ -124,14 +123,7 @@ internal sealed class MessageMerger responseList.Add(mergeState.ComputeDangling()); } - List<(AgentResponse Response, int Index)> orderedResponseList = responseList - .Select((response, index) => (Response: response, Index: index)) - .ToList(); - orderedResponseList.Sort((left, right) => this.CompareByDateTimeOffset(left.Response, left.Index, right.Response, right.Index)); - - responses[responseId] = orderedResponseList - .Select(item => item.Response) - .Aggregate(MergeResponses); + responses[responseId] = responseList.Aggregate(MergeResponses); messages.AddRange(GetMessagesWithCreatedAt(responses[responseId])); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs index 284e6094be..a0e2124cfc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs @@ -166,56 +166,70 @@ public class MessageMergerTests [Fact] public void Test_MessageMerger_PreservesInsertionOrder_WhenMixedTimestamps() { - // Arrange: Updates where some have CreatedAt and some don't + // Arrange: Updates with OUT-OF-ORDER timestamps relative to emission order. + // Emission order: A, B, C. + // Timestamp order: C (oldest), A, B (newest) - reverse of emission. + // Per Invariant 2, emission order wins; timestamps are ignored for ordering. string responseId = Guid.NewGuid().ToString("N"); string messageIdA = Guid.NewGuid().ToString("N"); string messageIdB = Guid.NewGuid().ToString("N"); string messageIdC = Guid.NewGuid().ToString("N"); - DateTimeOffset time1 = DateTimeOffset.UtcNow.AddMinutes(-2); - DateTimeOffset time3 = DateTimeOffset.UtcNow; + DateTimeOffset timeOldest = DateTimeOffset.UtcNow.AddMinutes(-10); + DateTimeOffset timeMiddle = DateTimeOffset.UtcNow.AddMinutes(-5); + DateTimeOffset timeNewest = DateTimeOffset.UtcNow; MessageMerger merger = new(); - // A has timestamp (time1), B has no timestamp, C has timestamp (time3) - // Insertion order: A, B, C - // B should maintain its relative position among untimestamped messages + // Emit A first but stamp it with timeMiddle. merger.AddUpdate(new AgentResponseUpdate { ResponseId = responseId, MessageId = messageIdA, Role = ChatRole.Assistant, - CreatedAt = time1, + CreatedAt = timeMiddle, Contents = [new TextContent("Message A")], }); + // Emit B second, without a timestamp. merger.AddUpdate(new AgentResponseUpdate { ResponseId = responseId, MessageId = messageIdB, Role = ChatRole.Assistant, - // No CreatedAt - should use insertion order as tiebreaker + // No CreatedAt Contents = [new TextContent("Message B")], }); + // Emit C third but stamp it with timeOldest - if we sorted by timestamp, + // C would come first; the new merger MUST keep emission order (A, B, C). merger.AddUpdate(new AgentResponseUpdate { ResponseId = responseId, MessageId = messageIdC, Role = ChatRole.Assistant, - CreatedAt = time3, + CreatedAt = timeOldest, Contents = [new TextContent("Message C")], }); + // Stamp a fourth message with the newest timestamp - it should still come last + // because it was emitted last, not because of its timestamp. + string messageIdD = Guid.NewGuid().ToString("N"); + merger.AddUpdate(new AgentResponseUpdate + { + ResponseId = responseId, + MessageId = messageIdD, + Role = ChatRole.Assistant, + CreatedAt = timeNewest, + Contents = [new TextContent("Message D")], + }); // Act AgentResponse response = merger.ComputeMerged(responseId); - // Assert: Untimestamped messages should maintain relative order via insertion index fallback - response.Messages.Should().HaveCount(3); - - // A (time1) should come first, B (no timestamp, uses index 1) should be in middle, - // C (time3) should come last since it has the latest timestamp + // Assert: Emission order wins; CreatedAt is ignored for ordering. + response.Messages.Should().HaveCount(4); response.Messages[0].Text.Should().Be("Message A"); response.Messages[1].Text.Should().Be("Message B"); response.Messages[2].Text.Should().Be("Message C"); + response.Messages[3].Text.Should().Be("Message D"); } [Fact] @@ -292,10 +306,15 @@ public class MessageMergerTests }); AgentResponse response2 = merger2.ComputeMerged(responseId); - // Assert: Result is reproducible and consistent across runs with same input order + // Assert: Result is reproducible and consistent across runs with same input order. + // Per Invariant 2, both runs must produce emission order (A, B, C) regardless + // of the messages' CreatedAt values. response1.Messages.Should().HaveCount(3); response2.Messages.Should().HaveCount(3); + response1.Messages.Select(m => m.Text).Should().ContainInOrder( + "Message A (T=10)", "Message B (no timestamp)", "Message C (T=5)"); + // Both runs should produce identical ordering for (int i = 0; i < 3; i++) { @@ -502,4 +521,86 @@ public class MessageMergerTests } #endregion + + #region Step Ordering Tests (workflow goals) + + [Fact] + public void Test_MessageMerger_OrdersStepsBeforeNextStep_AndGroupsAgentsWithinStep() + { + // Scenario mirroring the workflow ordering goals: + // Step 1: Agent1 alone emits 2 updates. + // Step 2: Agent1 and Agent2 emit updates interleaved (concurrent in the step). + // Step 3: Agent2 alone emits 2 updates. + // Each agent invocation has its own ResponseId, so per-ResponseId grouping + // yields per-agent grouping. Steps are naturally serialized in emission + // order (the next step cannot emit until the prior step's updates arrive), + // so the first-seen-ResponseId ordering preserves step boundaries. + const string step1Agent1 = "step1-agent1"; + const string step2Agent1 = "step2-agent1"; + const string step2Agent2 = "step2-agent2"; + const string step3Agent2 = "step3-agent2"; + + MessageMerger merger = new(); + + // Step 1: Agent1 emits 2 messages. + AddSimpleUpdate(merger, step1Agent1, TestAgentId1, "S1-A1-m1"); + AddSimpleUpdate(merger, step1Agent1, TestAgentId1, "S1-A1-m2"); + + // Step 2: Agent1 and Agent2 emit interleaved (A1, A2, A1, A2). + AddSimpleUpdate(merger, step2Agent1, TestAgentId1, "S2-A1-m1"); + AddSimpleUpdate(merger, step2Agent2, TestAgentId2, "S2-A2-m1"); + AddSimpleUpdate(merger, step2Agent1, TestAgentId1, "S2-A1-m2"); + AddSimpleUpdate(merger, step2Agent2, TestAgentId2, "S2-A2-m2"); + + // Step 3: Agent2 emits 2 messages. + AddSimpleUpdate(merger, step3Agent2, TestAgentId2, "S3-A2-m1"); + AddSimpleUpdate(merger, step3Agent2, TestAgentId2, "S3-A2-m2"); + + // Act + AgentResponse response = merger.ComputeMerged(step1Agent1); + + // Assert: expected output order + // Step 1 block (Agent1): S1-A1-m1, S1-A1-m2 + // Step 2 Agent1 block: S2-A1-m1, S2-A1-m2 + // Step 2 Agent2 block: S2-A2-m1, S2-A2-m2 + // Step 3 block (Agent2): S3-A2-m1, S3-A2-m2 + response.Messages.Should().HaveCount(8); + var texts = response.Messages.Select(m => m.Text).ToList(); + texts.Should().ContainInOrder( + "S1-A1-m1", "S1-A1-m2", + "S2-A1-m1", "S2-A1-m2", + "S2-A2-m1", "S2-A2-m2", + "S3-A2-m1", "S3-A2-m2"); + + // Step boundaries: no step-2 or step-3 message may appear before the last step-1 message. + int lastStep1Index = texts.FindLastIndex(t => t.StartsWith("S1-", StringComparison.Ordinal)); + int firstStep2Index = texts.FindIndex(t => t.StartsWith("S2-", StringComparison.Ordinal)); + int lastStep2Index = texts.FindLastIndex(t => t.StartsWith("S2-", StringComparison.Ordinal)); + int firstStep3Index = texts.FindIndex(t => t.StartsWith("S3-", StringComparison.Ordinal)); + lastStep1Index.Should().BeLessThan(firstStep2Index, "all step-1 messages precede step-2"); + lastStep2Index.Should().BeLessThan(firstStep3Index, "all step-2 messages precede step-3"); + + // Within step 2 (multi-agent), per-agent blocks must be contiguous. + int firstS2A1 = texts.FindIndex(t => t.StartsWith("S2-A1", StringComparison.Ordinal)); + int lastS2A1 = texts.FindLastIndex(t => t.StartsWith("S2-A1", StringComparison.Ordinal)); + int firstS2A2 = texts.FindIndex(t => t.StartsWith("S2-A2", StringComparison.Ordinal)); + int lastS2A2 = texts.FindLastIndex(t => t.StartsWith("S2-A2", StringComparison.Ordinal)); + (lastS2A1 - firstS2A1).Should().Be(1, "Agent1 step-2 messages should be contiguous"); + (lastS2A2 - firstS2A2).Should().Be(1, "Agent2 step-2 messages should be contiguous"); + (lastS2A1 < firstS2A2 || lastS2A2 < firstS2A1).Should().BeTrue("agent blocks within a step must not interleave"); + + static void AddSimpleUpdate(MessageMerger m, string responseId, string agentId, string text) + { + m.AddUpdate(new AgentResponseUpdate + { + ResponseId = responseId, + MessageId = Guid.NewGuid().ToString("N"), + AgentId = agentId, + Role = ChatRole.Assistant, + Contents = [new TextContent(text)], + }); + } + } + + #endregion }