Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -180,53 +180,60 @@ static async Task<ChatResponse> ToChatResponseAsync(
}
}

/// <summary>Coalesces sequential <see cref="TextContent"/> content elements.</summary>
/// <summary>Coalesces sequential <see cref="AIContent"/> content elements.</summary>
internal static void CoalesceTextContent(List<AIContent> contents)
{
StringBuilder? coalescedText = null;
Coalesce<TextContent>(contents, static text => new(text));
Coalesce<TextReasoningContent>(contents, static text => new(text));

// Iterate through all of the items in the list looking for contiguous items that can be coalesced.
int start = 0;
while (start < contents.Count - 1)
// This implementation relies on TContent's ToString returning its exact text.
static void Coalesce<TContent>(List<AIContent> contents, Func<string, TContent> fromText)
where TContent : AIContent
{
// We need at least two TextContents in a row to be able to coalesce.
if (contents[start] is not TextContent firstText)
{
start++;
continue;
}

if (contents[start + 1] is not TextContent secondText)
{
start += 2;
continue;
}
StringBuilder? coalescedText = null;

// Append the text from those nodes and continue appending subsequent TextContents until we run out.
// We null out nodes as their text is appended so that we can later remove them all in one O(N) operation.
coalescedText ??= new();
_ = coalescedText.Clear().Append(firstText.Text).Append(secondText.Text);
contents[start + 1] = null!;
int i = start + 2;
for (; i < contents.Count && contents[i] is TextContent next; i++)
// Iterate through all of the items in the list looking for contiguous items that can be coalesced.
int start = 0;
while (start < contents.Count - 1)
{
_ = coalescedText.Append(next.Text);
contents[i] = null!;
// We need at least two TextContents in a row to be able to coalesce.
if (contents[start] is not TContent firstText)
{
start++;
continue;
}

if (contents[start + 1] is not TContent secondText)
{
start += 2;
continue;
}

// Append the text from those nodes and continue appending subsequent TextContents until we run out.
// We null out nodes as their text is appended so that we can later remove them all in one O(N) operation.
coalescedText ??= new();
_ = coalescedText.Clear().Append(firstText).Append(secondText);
contents[start + 1] = null!;
int i = start + 2;
for (; i < contents.Count && contents[i] is TContent next; i++)
{
_ = coalescedText.Append(next);
contents[i] = null!;
}

// Store the replacement node. We inherit the properties of the first text node. We don't
// currently propagate additional properties from the subsequent nodes. If we ever need to,
// we can add that here.
var newContent = fromText(coalescedText.ToString());
contents[start] = newContent;
newContent.AdditionalProperties = firstText.AdditionalProperties?.Clone();

start = i;
}

// Store the replacement node.
contents[start] = new TextContent(coalescedText.ToString())
{
// We inherit the properties of the first text node. We don't currently propagate additional
// properties from the subsequent nodes. If we ever need to, we can add that here.
AdditionalProperties = firstText.AdditionalProperties?.Clone(),
};

start = i;
// Remove all of the null slots left over from the coalescing process.
_ = contents.RemoveAll(u => u is null);
}

// Remove all of the null slots left over from the coalescing process.
_ = contents.RemoveAll(u => u is null);
}

/// <summary>Finalizes the <paramref name="response"/> object.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace Microsoft.Extensions.AI;
[JsonDerivedType(typeof(FunctionCallContent), typeDiscriminator: "functionCall")]
[JsonDerivedType(typeof(FunctionResultContent), typeDiscriminator: "functionResult")]
[JsonDerivedType(typeof(TextContent), typeDiscriminator: "text")]
[JsonDerivedType(typeof(TextReasoningContent), typeDiscriminator: "reasoning")]
[JsonDerivedType(typeof(UriContent), typeDiscriminator: "uri")]
[JsonDerivedType(typeof(UsageContent), typeDiscriminator: "usage")]
public class AIContent
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;

namespace Microsoft.Extensions.AI;

/// <summary>
/// Represents text reasoning content in a chat.
/// </summary>
/// <remarks>
/// <see cref="TextReasoningContent"/> is distinct from <see cref="TextContent"/>. <see cref="TextReasoningContent"/>
/// represents "thinking" or "reasoning" performed by the model and is distinct from the actual output text from
/// the model, which is represented by <see cref="TextContent"/>. Neither types derives from the other.
/// </remarks>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class TextReasoningContent : AIContent
{
private string? _text;

/// <summary>
/// Initializes a new instance of the <see cref="TextReasoningContent"/> class.
/// </summary>
/// <param name="text">The text reasoning content.</param>
public TextReasoningContent(string? text)
{
_text = text;
}

/// <summary>
/// Gets or sets the text reasoning content.
/// </summary>
[AllowNull]
public string Text
{
get => _text ?? string.Empty;
set => _text = value;
}

/// <inheritdoc/>
public override string ToString() => Text;

[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay => $"Reasoning = \"{Text}\"";
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,44 @@ void AddGap()
}
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ToChatResponse_CoalescesTextContentAndTextReasoningContentSeparately(bool useAsync)
{
ChatResponseUpdate[] updates =
{
new(null, "A"),
new(null, "B"),
new(null, "C"),
new() { Contents = [new TextReasoningContent("D")] },
new() { Contents = [new TextReasoningContent("E")] },
new() { Contents = [new TextReasoningContent("F")] },
new(null, "G"),
new(null, "H"),
new() { Contents = [new TextReasoningContent("I")] },
new() { Contents = [new TextReasoningContent("J")] },
new(null, "K"),
new() { Contents = [new TextReasoningContent("L")] },
new(null, "M"),
new(null, "N"),
new() { Contents = [new TextReasoningContent("O")] },
new() { Contents = [new TextReasoningContent("P")] },
};

ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse();
ChatMessage message = Assert.Single(response.Messages);
Assert.Equal(8, message.Contents.Count);
Assert.Equal("ABC", Assert.IsType<TextContent>(message.Contents[0]).Text);
Assert.Equal("DEF", Assert.IsType<TextReasoningContent>(message.Contents[1]).Text);
Assert.Equal("GH", Assert.IsType<TextContent>(message.Contents[2]).Text);
Assert.Equal("IJ", Assert.IsType<TextReasoningContent>(message.Contents[3]).Text);
Assert.Equal("K", Assert.IsType<TextContent>(message.Contents[4]).Text);
Assert.Equal("L", Assert.IsType<TextReasoningContent>(message.Contents[5]).Text);
Assert.Equal("MN", Assert.IsType<TextContent>(message.Contents[6]).Text);
Assert.Equal("OP", Assert.IsType<TextReasoningContent>(message.Contents[7]).Text);
}

[Fact]
public async Task ToChatResponse_UsageContentExtractedFromContents()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Xunit;

namespace Microsoft.Extensions.AI;

public class TextReasoningContentTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("text")]
public void Constructor_String_PropsDefault(string? text)
{
TextReasoningContent c = new(text);
Assert.Null(c.RawRepresentation);
Assert.Null(c.AdditionalProperties);
Assert.Equal(text ?? string.Empty, c.Text);
}

[Fact]
public void Constructor_PropsRoundtrip()
{
TextReasoningContent c = new(null);

Assert.Null(c.RawRepresentation);
object raw = new();
c.RawRepresentation = raw;
Assert.Same(raw, c.RawRepresentation);

Assert.Null(c.AdditionalProperties);
AdditionalPropertiesDictionary props = new() { { "key", "value" } };
c.AdditionalProperties = props;
Assert.Same(props, c.AdditionalProperties);

Assert.Equal(string.Empty, c.Text);
c.Text = "text";
Assert.Equal("text", c.Text);
Assert.Equal("text", c.ToString());

c.Text = null;
Assert.Equal(string.Empty, c.Text);
Assert.Equal(string.Empty, c.ToString());

c.Text = string.Empty;
Assert.Equal(string.Empty, c.Text);
Assert.Equal(string.Empty, c.ToString());
}
}
Loading