Update meeting summary agent UI

This commit is contained in:
2026-07-01 10:30:28 +02:00
parent 4787bf8cec
commit 92e359646b
18 changed files with 731 additions and 101 deletions
@@ -42,6 +42,99 @@ public sealed class LiteLlmResponsesChatClientTests
Assert.Equal("gpt-5.5-2026-04-23", response.ModelId);
}
[Fact]
public async Task ClientReportsVisibleReasoningSummariesSeparatelyFromResponseText()
{
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "reasoning",
"summary": [
{
"type": "summary_text",
"text": "Checked the configured rules file."
},
{
"type": "summary_text",
"text": "Prepared a targeted update."
}
]
},
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Done."
}
]
}
]
}
""")
});
var reasoningSummaries = new List<string>();
using var client = CreateClient(
handler,
reconnectionAttempts: 0,
reasoningSummaryChanged: reasoningSummaries.Add);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]);
Assert.Equal("Done.", response.Text);
Assert.Equal(
[
"Checked the configured rules file.",
"Prepared a targeted update."
],
reasoningSummaries);
}
[Fact]
public async Task ClientDoesNotFailResponseWhenReasoningSummaryCallbackFails()
{
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "reasoning",
"summary": [
{
"type": "summary_text",
"text": "Checked the configured rules file."
}
]
},
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Done."
}
]
}
]
}
""")
});
using var client = CreateClient(
handler,
reconnectionAttempts: 0,
reasoningSummaryChanged: _ => throw new InvalidOperationException("UI failed"));
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]);
Assert.Equal("Done.", response.Text);
}
[Fact]
public void ParserReadsFunctionCalls()
{
@@ -306,7 +399,8 @@ public sealed class LiteLlmResponsesChatClientTests
HttpMessageHandler handler,
int reconnectionAttempts,
LiteLlmResponsesCompactionOptions? compactionOptions = null,
Action? retrying = null)
Action? retrying = null,
Action<string>? reasoningSummaryChanged = null)
{
return new LiteLlmResponsesChatClient(
new HttpClient(handler)
@@ -320,7 +414,8 @@ public sealed class LiteLlmResponsesChatClientTests
reconnectionAttempts,
TimeSpan.Zero,
compactionOptions,
retrying: retrying);
retrying: retrying,
reasoningSummaryChanged: reasoningSummaryChanged);
}
private sealed class SequencedHttpMessageHandler : HttpMessageHandler
@@ -117,7 +117,7 @@ public sealed class RecordingCoordinatorTests
}
[Fact]
public async Task TranscriptLineWorkflowRuleTransformsLiveTranscriptBeforePersistingMarkdown()
public async Task TranscriptLineWorkflowRuleTransformsLiveTranscriptAfterDurableAppend()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var rulesPath = Path.Combine(root, "rules.yaml");
+1 -1
View File
@@ -18,7 +18,7 @@ public sealed class TaskbarIconTests
Assert.Equal(RecordingProcessState.Idle, menu.State);
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.EditRules &&
item.Text == "Settings and logs");
item.Text == "Open agent");
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.StartRecording &&
item.ProfileName == "default" &&
@@ -857,15 +857,13 @@ public sealed class WorkflowRulesEditorTests
Assert.Equal("", viewModel.Draft);
Assert.Equal(
[new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "Rename ABS Daily")],
viewModel.Messages.ToArray());
viewModel.Messages.Select(item => item.Message).OfType<WorkflowRulesEditorChatMessage>().ToArray());
pipeline.Release.SetResult();
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(viewModel.IsThinking);
Assert.Equal(2, viewModel.Messages.Count);
Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[1].Role);
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
AssertCompletedActivity(viewModel, ["Thinking..."], "Updated rules.");
}
[Fact]
@@ -881,20 +879,19 @@ public sealed class WorkflowRulesEditorTests
Assert.False(viewModel.IsThinking);
Assert.Equal("", viewModel.Draft);
Assert.Equal(
[
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "check logs"),
new WorkflowRulesEditorChatMessage(
WorkflowRulesEditorChatRole.Agent,
"Settings and logs failed: The request timed out.")
],
viewModel.Messages.ToArray());
Assert.Equal(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "check logs"), viewModel.Messages[0].Message);
AssertCompletedActivity(
viewModel,
["Thinking..."],
"Meeting Summary Agent failed: The request timed out.");
}
[Fact]
public async Task ViewModelShowsReconnectStatusReportedByPipeline()
{
var pipeline = new StatusReportingRulesEditorPipeline("Updated rules.");
var pipeline = new ReportingRulesEditorPipeline(
"Updated rules.",
WorkflowRulesEditorActivityUpdate.Status("Reconnecting..."));
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
{
Draft = "check logs"
@@ -911,7 +908,146 @@ public sealed class WorkflowRulesEditorTests
Assert.False(viewModel.IsThinking);
Assert.Equal("Thinking...", viewModel.ActivityMessage);
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
AssertCompletedActivity(viewModel, ["Thinking...", "Reconnecting..."], "Updated rules.");
}
[Fact]
public async Task ViewModelShowsToolCallsAbovePersistentThinkingLine()
{
var pipeline = new ReportingRulesEditorPipeline(
"Updated rules.",
WorkflowRulesEditorActivityUpdate.ToolCall("read_logs"));
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
{
Draft = "check logs"
};
var sendTask = viewModel.SendAsync();
await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(viewModel.IsThinking);
Assert.Equal("Thinking...", viewModel.ActivityMessage);
Assert.Equal(["Called tool: read_logs"], viewModel.ActivityMessages.ToArray());
pipeline.Release.SetResult();
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(viewModel.IsThinking);
Assert.Empty(viewModel.ActivityMessages);
AssertCompletedActivity(viewModel, ["Thinking...", "Called tool: read_logs"], "Updated rules.");
}
[Fact]
public async Task ViewModelShowsVisibleThinkingOutputInActivityExpander()
{
var pipeline = new ReportingRulesEditorPipeline(
"Updated rules.",
WorkflowRulesEditorActivityUpdate.Thinking("Checked the recent logs."),
WorkflowRulesEditorActivityUpdate.ToolCall("read_logs"),
WorkflowRulesEditorActivityUpdate.Thinking("Matched the rule to the user request."));
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
{
Draft = "check logs"
};
var sendTask = viewModel.SendAsync();
await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(viewModel.IsThinking);
Assert.Equal("Thinking...", viewModel.ActivityMessage);
Assert.Equal(
[
"Checked the recent logs.",
"Called tool: read_logs",
"Matched the rule to the user request."
],
viewModel.ActivityMessages.ToArray());
pipeline.Release.SetResult();
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(viewModel.IsThinking);
Assert.Empty(viewModel.ActivityMessages);
AssertCompletedActivity(
viewModel,
[
"Checked the recent logs.",
"Called tool: read_logs",
"Matched the rule to the user request."
],
"Updated rules.");
}
[Fact]
public async Task ViewModelMarshalsBackgroundActivityUpdatesToCapturedContext()
{
var previousContext = SynchronizationContext.Current;
var context = new RecordingSynchronizationContext();
var pipeline = new BackgroundReportingRulesEditorPipeline(
"Updated rules.",
WorkflowRulesEditorActivityUpdate.Thinking("Checked the recent logs."));
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
{
Draft = "check logs"
};
SynchronizationContext.SetSynchronizationContext(context);
var sendTask = viewModel.SendAsync();
SynchronizationContext.SetSynchronizationContext(previousContext);
await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(viewModel.IsThinking);
Assert.Equal(["Checked the recent logs."], viewModel.ActivityMessages.ToArray());
Assert.Equal(1, context.SendCount);
pipeline.Release.SetResult();
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
AssertCompletedActivity(viewModel, ["Checked the recent logs."], "Updated rules.");
}
[Fact]
public async Task ViewModelKeepsActivityItemsOutOfModelConversation()
{
var pipeline = new BlockingRulesEditorPipeline("Updated rules.");
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
{
Draft = "first"
};
var firstSend = viewModel.SendAsync();
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
pipeline.Release.SetResult();
await firstSend.WaitAsync(TimeSpan.FromSeconds(2));
pipeline.Reset("Second response.");
viewModel.Draft = "second";
var secondSend = viewModel.SendAsync();
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(
[
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "first"),
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, "Updated rules.")
],
pipeline.LastConversation);
pipeline.Release.SetResult();
await secondSend.WaitAsync(TimeSpan.FromSeconds(2));
}
private static void AssertCompletedActivity(
WorkflowRulesEditorChatViewModel viewModel,
IReadOnlyList<string> expectedActivityLines,
string expectedAgentMessage)
{
Assert.Equal(3, viewModel.Messages.Count);
Assert.Equal(WorkflowRulesEditorConversationItemKind.Activity, viewModel.Messages[1].Kind);
Assert.StartsWith("Worked for ", viewModel.Messages[1].Content);
Assert.Equal(expectedActivityLines, viewModel.Messages[1].ActivityLines);
Assert.Equal(WorkflowRulesEditorConversationItemKind.Message, viewModel.Messages[2].Kind);
Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[2].Message?.Role);
Assert.Equal(expectedAgentMessage, viewModel.Messages[2].Message?.Content);
}
[Theory]
@@ -1196,31 +1332,36 @@ public sealed class WorkflowRulesEditorTests
private sealed class BlockingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
{
private readonly string response;
private string response;
public BlockingRulesEditorPipeline(string response)
{
this.response = response;
}
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Started { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Release { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IReadOnlyList<WorkflowRulesEditorChatMessage> LastConversation { get; private set; } = [];
public void Reset(string nextResponse)
{
response = nextResponse;
Started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
public async Task<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken,
Action<string>? statusChanged = null)
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
{
LastConversation = conversation;
Started.SetResult();
await Release.Task.WaitAsync(cancellationToken);
return new WorkflowRulesEditorChatResult(
response,
conversation
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
.ToList());
return new WorkflowRulesEditorChatResult(response);
}
}
@@ -1237,19 +1378,23 @@ public sealed class WorkflowRulesEditorTests
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken,
Action<string>? statusChanged = null)
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
{
throw exception;
}
}
private sealed class StatusReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
private sealed class ReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
{
private readonly string response;
private readonly IReadOnlyList<WorkflowRulesEditorActivityUpdate> updates;
public StatusReportingRulesEditorPipeline(string response)
public ReportingRulesEditorPipeline(
string response,
params WorkflowRulesEditorActivityUpdate[] updates)
{
this.response = response;
this.updates = updates;
}
public TaskCompletionSource Reported { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -1260,17 +1405,57 @@ public sealed class WorkflowRulesEditorTests
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken,
Action<string>? statusChanged = null)
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
{
statusChanged?.Invoke("Reconnecting...");
foreach (var update in updates)
{
activityChanged?.Invoke(update);
}
Reported.SetResult();
await Release.Task.WaitAsync(cancellationToken);
return new WorkflowRulesEditorChatResult(
response,
conversation
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
.ToList());
return new WorkflowRulesEditorChatResult(response);
}
}
private sealed class BackgroundReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
{
private readonly string response;
private readonly WorkflowRulesEditorActivityUpdate update;
public BackgroundReportingRulesEditorPipeline(
string response,
WorkflowRulesEditorActivityUpdate update)
{
this.response = response;
this.update = update;
}
public TaskCompletionSource Reported { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public async Task<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken,
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
{
await Task.Run(() => activityChanged?.Invoke(update), cancellationToken);
Reported.SetResult();
await Release.Task.WaitAsync(cancellationToken);
return new WorkflowRulesEditorChatResult(response);
}
}
private sealed class RecordingSynchronizationContext : SynchronizationContext
{
public int SendCount { get; private set; }
public override void Send(SendOrPostCallback callback, object? state)
{
SendCount++;
callback(state);
}
}