diff --git a/MeetingAssistant.Tests/LiteLlmResponsesChatClientTests.cs b/MeetingAssistant.Tests/LiteLlmResponsesChatClientTests.cs index 2d0eee6..f54b051 100644 --- a/MeetingAssistant.Tests/LiteLlmResponsesChatClientTests.cs +++ b/MeetingAssistant.Tests/LiteLlmResponsesChatClientTests.cs @@ -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(); + 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? 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 diff --git a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs index 6b07ccd..48b40fb 100644 --- a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs +++ b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs @@ -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"); diff --git a/MeetingAssistant.Tests/TaskbarIconTests.cs b/MeetingAssistant.Tests/TaskbarIconTests.cs index 06df391..c7c6817 100644 --- a/MeetingAssistant.Tests/TaskbarIconTests.cs +++ b/MeetingAssistant.Tests/TaskbarIconTests.cs @@ -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" && diff --git a/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs b/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs index 2ecc774..6bc99da 100644 --- a/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs +++ b/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs @@ -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().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 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 LastConversation { get; private set; } = []; + + public void Reset(string nextResponse) + { + response = nextResponse; + Started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } public async Task SendAsync( IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken, - Action? statusChanged = null) + Action? 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 conversation, string userMessage, CancellationToken cancellationToken, - Action? statusChanged = null) + Action? activityChanged = null) { throw exception; } } - private sealed class StatusReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline + private sealed class ReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline { private readonly string response; + private readonly IReadOnlyList 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 conversation, string userMessage, CancellationToken cancellationToken, - Action? statusChanged = null) + Action? 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 SendAsync( + IReadOnlyList conversation, + string userMessage, + CancellationToken cancellationToken, + Action? 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); } } diff --git a/MeetingAssistant/Summary/LiteLlmResponsesChatClient.cs b/MeetingAssistant/Summary/LiteLlmResponsesChatClient.cs index 85d304f..9bd8843 100644 --- a/MeetingAssistant/Summary/LiteLlmResponsesChatClient.cs +++ b/MeetingAssistant/Summary/LiteLlmResponsesChatClient.cs @@ -22,6 +22,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient private readonly LiteLlmResponsesCompactionOptions? compactionOptions; private readonly ILogger? logger; private readonly Action? retrying; + private readonly Action? reasoningSummaryChanged; private int responsesRequestCount; public LiteLlmResponsesChatClient( @@ -35,7 +36,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient LiteLlmResponsesCompactionOptions? compactionOptions = null, ILogger? logger = null, bool firstRequestIsUser = true, - Action? retrying = null) + Action? retrying = null, + Action? reasoningSummaryChanged = null) : this( new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) }, apiKey, @@ -47,7 +49,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient compactionOptions, logger, firstRequestIsUser, - retrying) + retrying, + reasoningSummaryChanged) { } @@ -62,7 +65,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient LiteLlmResponsesCompactionOptions? compactionOptions = null, ILogger? logger = null, bool firstRequestIsUser = true, - Action? retrying = null) + Action? retrying = null, + Action? reasoningSummaryChanged = null) { this.httpClient = httpClient; this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); @@ -74,6 +78,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient this.compactionOptions = compactionOptions; this.logger = logger; this.retrying = retrying; + this.reasoningSummaryChanged = reasoningSummaryChanged; responsesRequestCount = firstRequestIsUser ? 0 : 1; } @@ -90,7 +95,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient var payload = await CreateCompactedPayloadAsync(messages.ToList(), options, cancellationToken).ConfigureAwait(false); var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false); - var response = ParseResponseJson(responseJson); + var response = ParseResponseJson(responseJson, out var reasoningSummaries); + ReportVisibleReasoningSummaries(reasoningSummaries); LogResponseDiagnostics(responseJson, response); ThrowIfResponseHasNoContent(responseJson, response); LogResponseUsage(response.Usage); @@ -122,10 +128,18 @@ public sealed class LiteLlmResponsesChatClient : IChatClient } public static ChatResponse ParseResponseJson(string responseJson) + { + return ParseResponseJson(responseJson, out _); + } + + private static ChatResponse ParseResponseJson( + string responseJson, + out IReadOnlyList reasoningSummaries) { using var document = JsonDocument.Parse(responseJson); var root = document.RootElement; var contents = new List(); + reasoningSummaries = ExtractVisibleReasoningSummaries(root); if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array) { @@ -162,6 +176,54 @@ public sealed class LiteLlmResponsesChatClient : IChatClient }; } + internal static IReadOnlyList ExtractVisibleReasoningSummaries(string responseJson) + { + using var document = JsonDocument.Parse(responseJson); + return ExtractVisibleReasoningSummaries(document.RootElement); + } + + private static IReadOnlyList ExtractVisibleReasoningSummaries(JsonElement root) + { + var summaries = new List(); + + if (!root.TryGetProperty("output", out var output) || output.ValueKind != JsonValueKind.Array) + { + return summaries; + } + + foreach (var item in output.EnumerateArray()) + { + if (GetString(item, "type") == "reasoning") + { + AddVisibleReasoningSummaryText(summaries, item); + } + } + + return summaries; + } + + private void ReportVisibleReasoningSummaries(IReadOnlyList summaries) + { + if (reasoningSummaryChanged is null) + { + return; + } + + foreach (var summary in summaries) + { + try + { + reasoningSummaryChanged(summary); + } + catch (Exception exception) + { + logger?.LogWarning( + exception, + "Reasoning summary callback failed; continuing with the LiteLLM response."); + } + } + } + private async Task CreateCompactedPayloadAsync( IReadOnlyList messages, ChatOptions? options, @@ -660,6 +722,66 @@ public sealed class LiteLlmResponsesChatClient : IChatClient } } + private static void AddVisibleReasoningSummaryText(List summaries, JsonElement item) + { + AddVisibleReasoningSummaryText(summaries, item, "summary"); + AddVisibleReasoningSummaryText(summaries, item, "content"); + } + + private static void AddVisibleReasoningSummaryText( + List summaries, + JsonElement item, + string propertyName) + { + if (!item.TryGetProperty(propertyName, out var property)) + { + return; + } + + if (property.ValueKind == JsonValueKind.String) + { + AddNonEmpty(summaries, property.GetString()); + return; + } + + if (property.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var summaryItem in property.EnumerateArray()) + { + if (summaryItem.ValueKind == JsonValueKind.String) + { + AddNonEmpty(summaries, summaryItem.GetString()); + } + else if (summaryItem.ValueKind == JsonValueKind.Object) + { + if (propertyName == "content" && !IsSummaryContent(summaryItem)) + { + continue; + } + + AddNonEmpty(summaries, GetString(summaryItem, "text")); + } + } + } + + private static bool IsSummaryContent(JsonElement item) + { + var type = GetString(item, "type"); + return !string.IsNullOrWhiteSpace(type) + && type.Contains("summary", StringComparison.OrdinalIgnoreCase); + } + + private static void AddNonEmpty(List values, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + values.Add(value.Trim()); + } + } + private static Dictionary ParseArguments(string? arguments) { if (string.IsNullOrWhiteSpace(arguments)) diff --git a/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs b/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs index 1c78d9d..ebc6267 100644 --- a/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs +++ b/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs @@ -38,7 +38,7 @@ public static class MeetingTaskbarMenuBuilder { var items = new List { - new("Settings and logs", MeetingTaskbarAction.EditRules) + new("Open agent", MeetingTaskbarAction.EditRules) }; if (microphones is { Count: > 0 }) diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs index 858b88e..4d77417 100644 --- a/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs @@ -10,9 +10,66 @@ public sealed record WorkflowRulesEditorChatMessage( WorkflowRulesEditorChatRole Role, string Content); -public sealed record WorkflowRulesEditorChatResult( - string Response, - IReadOnlyList Conversation); +public enum WorkflowRulesEditorConversationItemKind +{ + Message, + Activity +} + +public sealed record WorkflowRulesEditorConversationItem( + WorkflowRulesEditorConversationItemKind Kind, + WorkflowRulesEditorChatMessage? Message, + string Content, + IReadOnlyList? ActivityLines = null) +{ + public bool IsExpanded { get; set; } + + public static WorkflowRulesEditorConversationItem ChatMessage(WorkflowRulesEditorChatMessage message) + { + return new WorkflowRulesEditorConversationItem( + WorkflowRulesEditorConversationItemKind.Message, + message, + message.Content); + } + + public static WorkflowRulesEditorConversationItem Activity(string content, IReadOnlyList activityLines) + { + return new WorkflowRulesEditorConversationItem( + WorkflowRulesEditorConversationItemKind.Activity, + null, + content, + activityLines); + } +} + +public sealed record WorkflowRulesEditorChatResult(string Response); + +public enum WorkflowRulesEditorActivityKind +{ + Status, + ToolCall, + Thinking +} + +public sealed record WorkflowRulesEditorActivityUpdate( + WorkflowRulesEditorActivityKind Kind, + string Text) +{ + public static WorkflowRulesEditorActivityUpdate Status(string text) + { + return new WorkflowRulesEditorActivityUpdate(WorkflowRulesEditorActivityKind.Status, text); + } + + public static WorkflowRulesEditorActivityUpdate ToolCall(string toolName) + { + return new WorkflowRulesEditorActivityUpdate(WorkflowRulesEditorActivityKind.ToolCall, toolName); + } + + public static WorkflowRulesEditorActivityUpdate Thinking(string text) + { + return new WorkflowRulesEditorActivityUpdate(WorkflowRulesEditorActivityKind.Thinking, text); + } +} public interface IWorkflowRulesEditorChatPipeline { @@ -20,5 +77,5 @@ public interface IWorkflowRulesEditorChatPipeline IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken, - Action? statusChanged = null); + Action? activityChanged = null); } diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs index d090f33..7cacbfb 100644 --- a/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs @@ -59,11 +59,11 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken, - Action? statusChanged = null) + Action? activityChanged = null) { if (string.IsNullOrWhiteSpace(userMessage)) { - return new WorkflowRulesEditorChatResult("", conversation); + return new WorkflowRulesEditorChatResult(""); } var agentOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent); @@ -108,10 +108,18 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi compactionOptions, logger, firstRequestIsUser: true, - retrying: () => statusChanged?.Invoke("Reconnecting...")); + retrying: () => activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.Status("Reconnecting...")), + reasoningSummaryChanged: text => activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.Thinking(text))); var functionClient = chatClient .AsBuilder() - .UseFunctionInvocation(loggerFactory) + .UseFunctionInvocation(loggerFactory, client => + { + client.FunctionInvoker = async (context, token) => + { + activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.ToolCall(context.Function.Name)); + return await context.Function.InvokeAsync(context.Arguments, token); + }; + }) .Build(); var response = await functionClient.GetResponseAsync( @@ -121,11 +129,7 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi var responseText = string.IsNullOrWhiteSpace(response.Text) ? "(No response text returned.)" : response.Text.Trim(); - var nextConversation = conversation - .Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage.Trim())) - .Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, responseText)) - .ToList(); - return new WorkflowRulesEditorChatResult(responseText, nextConversation); + return new WorkflowRulesEditorChatResult(responseText); } private static ChatMessage ToChatMessage(WorkflowRulesEditorChatMessage message) diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs index bee604d..e9162b6 100644 --- a/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs @@ -1,9 +1,11 @@ using System.Collections.ObjectModel; +using System.Diagnostics; namespace MeetingAssistant.Workflow; public sealed class WorkflowRulesEditorChatViewModel { + private const string ThinkingPlaceholder = "Thinking..."; private readonly IWorkflowRulesEditorChatPipeline pipeline; public WorkflowRulesEditorChatViewModel(IWorkflowRulesEditorChatPipeline pipeline) @@ -11,13 +13,15 @@ public sealed class WorkflowRulesEditorChatViewModel this.pipeline = pipeline; } - public ObservableCollection Messages { get; } = []; + public ObservableCollection Messages { get; } = []; + + public ObservableCollection ActivityMessages { get; } = []; public string Draft { get; set; } = ""; public bool IsThinking { get; private set; } - public string ActivityMessage { get; private set; } = "Thinking..."; + public string ActivityMessage { get; private set; } = ThinkingPlaceholder; public event EventHandler? Changed; @@ -30,10 +34,19 @@ public sealed class WorkflowRulesEditorChatViewModel } Draft = ""; - var priorConversation = Messages.ToList(); - Messages.Add(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt)); + var startedAt = Stopwatch.GetTimestamp(); + var activityLines = new List(); + var hasVisibleThinking = false; + var activityContext = SynchronizationContext.Current; + var priorConversation = Messages + .Select(item => item.Message) + .OfType() + .ToList(); + Messages.Add(WorkflowRulesEditorConversationItem.ChatMessage( + new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt))); + ActivityMessages.Clear(); IsThinking = true; - ActivityMessage = "Thinking..."; + ActivityMessage = ThinkingPlaceholder; OnChanged(); try @@ -42,36 +55,121 @@ public sealed class WorkflowRulesEditorChatViewModel priorConversation, prompt, cancellationToken, - SetActivityMessage); - Messages.Clear(); - foreach (var message in result.Conversation) - { - Messages.Add(message); - } + update => hasVisibleThinking |= ApplyActivityUpdate(activityContext, update, activityLines)); + AddCompletedActivity(startedAt, activityLines, hasVisibleThinking); + Messages.Add(WorkflowRulesEditorConversationItem.ChatMessage( + new WorkflowRulesEditorChatMessage( + WorkflowRulesEditorChatRole.Agent, + string.IsNullOrWhiteSpace(result.Response) + ? "(No response text returned.)" + : result.Response.Trim()))); } catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { - Messages.Add(new WorkflowRulesEditorChatMessage( - WorkflowRulesEditorChatRole.Agent, - $"Settings and logs failed: {exception.Message}")); + AddCompletedActivity(startedAt, activityLines, hasVisibleThinking); + Messages.Add(WorkflowRulesEditorConversationItem.ChatMessage( + new WorkflowRulesEditorChatMessage( + WorkflowRulesEditorChatRole.Agent, + $"Meeting Summary Agent failed: {exception.Message}"))); } finally { IsThinking = false; - ActivityMessage = "Thinking..."; + ActivityMessages.Clear(); + ActivityMessage = ThinkingPlaceholder; OnChanged(); } } - private void SetActivityMessage(string message) + private bool ApplyActivityUpdate( + SynchronizationContext? context, + WorkflowRulesEditorActivityUpdate update, + List activityLines) { - if (!IsThinking || string.IsNullOrWhiteSpace(message)) + if (context is null || SynchronizationContext.Current == context) { - return; + return ApplyActivityUpdate(update, activityLines); + } + + var hasVisibleThinking = false; + Exception? exception = null; + context.Send( + _ => + { + try + { + hasVisibleThinking = ApplyActivityUpdate(update, activityLines); + } + catch (Exception caught) + { + exception = caught; + } + }, + null); + + if (exception is not null) + { + throw exception; + } + + return hasVisibleThinking; + } + + private bool ApplyActivityUpdate( + WorkflowRulesEditorActivityUpdate update, + List activityLines) + { + if (!IsThinking || string.IsNullOrWhiteSpace(update.Text)) + { + return false; + } + + var hasVisibleThinking = false; + if (update.Kind == WorkflowRulesEditorActivityKind.ToolCall) + { + var line = $"Called tool: {update.Text.Trim()}"; + ActivityMessages.Add(line); + activityLines.Add(line); + } + else if (update.Kind == WorkflowRulesEditorActivityKind.Thinking) + { + var line = update.Text.Trim(); + ActivityMessages.Add(line); + activityLines.Add(line); + hasVisibleThinking = true; + } + else + { + var line = update.Text.Trim(); + ActivityMessage = line; + activityLines.Add(line); } - ActivityMessage = message; OnChanged(); + return hasVisibleThinking; + } + + private void AddCompletedActivity( + long startedAt, + IReadOnlyList activityLines, + bool hasVisibleThinking) + { + var completedActivityLines = hasVisibleThinking + ? activityLines.ToArray() + : new[] { ThinkingPlaceholder }.Concat(activityLines).ToArray(); + Messages.Add(WorkflowRulesEditorConversationItem.Activity( + $"Worked for {FormatDuration(Stopwatch.GetElapsedTime(startedAt))}", + completedActivityLines)); + } + + private static string FormatDuration(TimeSpan duration) + { + if (duration.TotalMinutes >= 1) + { + return $"{(int)duration.TotalMinutes}m {duration.Seconds}s"; + } + + return $"{Math.Max(0, (int)Math.Round(duration.TotalSeconds))}s"; } private void OnChanged() diff --git a/MeetingAssistant/Workflow/WpfWorkflowRulesEditorWindowService.Windows.cs b/MeetingAssistant/Workflow/WpfWorkflowRulesEditorWindowService.Windows.cs index 9b58790..c659bfb 100644 --- a/MeetingAssistant/Workflow/WpfWorkflowRulesEditorWindowService.Windows.cs +++ b/MeetingAssistant/Workflow/WpfWorkflowRulesEditorWindowService.Windows.cs @@ -11,7 +11,7 @@ namespace MeetingAssistant.Workflow; internal sealed class WpfWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService { - internal const string WindowTitle = "Settings and logs"; + internal const string WindowTitle = "Meeting Summary Agent"; private readonly IServiceProvider services; private readonly WorkflowRulesEditorMarkdownLinkResolver linkResolver; @@ -288,19 +288,21 @@ internal sealed class WorkflowRulesEditorWpfWindow : Window conversationPanel.ActualHeight); conversationPanel.Children.Clear(); - foreach (var message in viewModel.Messages) + foreach (var item in viewModel.Messages) { - conversationPanel.Children.Add(CreateMessageCard(message)); + conversationPanel.Children.Add(item.Kind == WorkflowRulesEditorConversationItemKind.Activity + ? CreateActivityExpander(item) + : CreateMessageCard(item.Message!)); } if (viewModel.IsThinking) { - conversationPanel.Children.Add(new TextBlock + foreach (var activity in viewModel.ActivityMessages) { - Text = viewModel.ActivityMessage, - Foreground = MutedText, - Margin = new Thickness(4, 2, 4, 2) - }); + conversationPanel.Children.Add(CreateActivityLine(activity)); + } + + conversationPanel.Children.Add(CreateActivityLine(viewModel.ActivityMessage)); } sendButton.IsEnabled = !viewModel.IsThinking; @@ -338,6 +340,43 @@ internal sealed class WorkflowRulesEditorWpfWindow : Window return card; } + private static Expander CreateActivityExpander(WorkflowRulesEditorConversationItem item) + { + var details = new StackPanel + { + Orientation = Orientation.Vertical, + Margin = new Thickness(16, 2, 4, 6) + }; + + foreach (var line in item.ActivityLines ?? []) + { + details.Children.Add(CreateActivityLine(line)); + } + + var expander = new Expander + { + Header = item.Content, + Content = details, + IsExpanded = item.IsExpanded, + Foreground = MutedText, + Background = Brushes.Transparent, + Margin = new Thickness(4, 0, 4, 8) + }; + expander.Expanded += (_, _) => item.IsExpanded = true; + expander.Collapsed += (_, _) => item.IsExpanded = false; + return expander; + } + + private static TextBlock CreateActivityLine(string text) + { + return new TextBlock + { + Text = text, + Foreground = MutedText, + Margin = new Thickness(4, 2, 4, 2) + }; + } + private static Style CreateSendButtonStyle() { var style = new Style(typeof(Button)); diff --git a/README.md b/README.md index 862e210..334c7b7 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Important settings: - `CalendarRecordingPrompts`: enables Outlook Classic Teams-start prompts on Windows. - `Screenshots`: controls the capture hotkey, attachment folder, and optional OCR/vision model. - `Agent`: configures the OpenAI-compatible summary/project agent endpoint, model, retries, output limits, and compaction. -- `WorkflowRulesEditor`: optionally overrides the agent settings for the tray-launched settings/logs assistant. +- `WorkflowRulesEditor`: optionally overrides the agent settings for the tray-launched assistant. Required or commonly used secrets: @@ -125,14 +125,14 @@ See `docs/meeting-assistant-configuration.md` for the full configuration referen - **Azure AI Speech**: default checked-in ASR path, live diarized conversation transcription, and speaker identity matching. - **FunASR**: optional WebSocket streaming ASR. When managed backend startup is enabled, the app pulls and runs the configured Docker image as `meeting-assistant-funasr` on port `10095`. - **Whisper.NET plus pyannote**: optional local Whisper fallback and Docker-backed final diarization. -- **LiteLLM/OpenAI-compatible Responses endpoint**: summary generation, screenshot OCR fallback, project tools, settings/logs assistant, and retry flows. +- **LiteLLM/OpenAI-compatible Responses endpoint**: summary generation, screenshot OCR fallback, project tools, the tray-launched assistant, and retry flows. - **Docker Desktop or compatible Docker CLI**: required only for managed FunASR and pyannote paths. ## Workflow Rules And Agents Meeting-specific automation lives in a local YAML file, not in committed personal rules. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, and transcript-line writes. They can add/remove attendees, set supported properties, add context, add projects, and rewrite a transcript line before persistence. -The tray menu exposes the settings/logs assistant. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools. +The tray menu exposes `Open agent`, which opens the `Meeting Summary Agent` window. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools. Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`. diff --git a/docs/meeting-assistant-configuration.md b/docs/meeting-assistant-configuration.md index 37dcc2d..4eebb81 100644 --- a/docs/meeting-assistant-configuration.md +++ b/docs/meeting-assistant-configuration.md @@ -358,7 +358,7 @@ The summary agent can add and remove meeting-note attendees when transcript or O ## Workflow Rules Editor -`WorkflowRulesEditor` configures the tray-launched settings/logs assistant. Blank values inherit from `Agent`, so it uses the summarizer endpoint, key, model, reasoning, retry, output, and compaction settings unless explicitly overridden. +`WorkflowRulesEditor` configures the tray-launched `Meeting Summary Agent` window. Blank values inherit from `Agent`, so it uses the summarizer endpoint, key, model, reasoning, retry, output, and compaction settings unless explicitly overridden. The overridable fields are `Endpoint`, `Key`, `KeyEnv`, `Model`, `EnableThinking`, `ReasoningEffort`, `ReconnectionAttempts`, `ReconnectionDelay`, `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, `CompactionRemainingRatio`, `ResponsesCompactPath`, and `InitialPrompt`. @@ -378,7 +378,7 @@ Independently from stdout and stderr redirection, Meeting Assistant writes an ap %TEMP%\MeetingAssistant\Logs\meeting-assistant.log ``` -On startup it rotates the previous current file to `meeting-assistant.log.1` and keeps up to `.4`. The settings/logs assistant reads and searches these current and rotated files. If another app instance or test host still has the file open, rotation is skipped and logging appends to the current file so startup is not blocked. +On startup it rotates the previous current file to `meeting-assistant.log.1` and keeps up to `.4`. The tray-launched assistant reads and searches these current and rotated files. If another app instance or test host still has the file open, rotation is skipped and logging appends to the current file so startup is not blocked. ## API diff --git a/docs/meeting-workflow-engine.md b/docs/meeting-workflow-engine.md index ef4e05c..6a7804e 100644 --- a/docs/meeting-workflow-engine.md +++ b/docs/meeting-workflow-engine.md @@ -22,7 +22,7 @@ The rules file path is configured through `MeetingAssistant:Automation:RulesPath If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing. -The tray menu includes `Settings and logs`, which opens a small MewUI chat assistant for this configured rules file, the local speaker identity database, appsettings configuration, and application logs. The assistant uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`. +The tray menu includes `Open agent`, which opens the `Meeting Summary Agent` chat window for this configured rules file, the local speaker identity database, appsettings configuration, and application logs. The assistant uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`. ```json { @@ -45,6 +45,8 @@ The tray menu includes `Settings and logs`, which opens a small MewUI chat assis Blank editor values inherit from `MeetingAssistant:Agent`. This keeps the editor on the same LiteLLM Responses endpoint and model as the summarizer unless a value is explicitly configured. Each user-submitted editor turn sends its first model request with `X-Initiator: user`; follow-up model requests in the same turn, such as tool-call continuations, use `X-Initiator: agent`. +While the agent is working, the window shows visible reasoning summaries returned by the Responses endpoint and called tool names as lightweight activity lines above the bottom activity line. After the turn completes, those lines move into a collapsible `Worked for ` expander between the user and assistant messages. + The editor agent prompt includes this document and is restricted to these tools: - `read_rules`: read the configured rules file, optionally by inclusive 1-based line range. diff --git a/openspec/changes/resilient-transcript-workflow/design.md b/openspec/changes/archive/2026-07-01-resilient-transcript-workflow/design.md similarity index 100% rename from openspec/changes/resilient-transcript-workflow/design.md rename to openspec/changes/archive/2026-07-01-resilient-transcript-workflow/design.md diff --git a/openspec/changes/resilient-transcript-workflow/proposal.md b/openspec/changes/archive/2026-07-01-resilient-transcript-workflow/proposal.md similarity index 100% rename from openspec/changes/resilient-transcript-workflow/proposal.md rename to openspec/changes/archive/2026-07-01-resilient-transcript-workflow/proposal.md diff --git a/openspec/changes/resilient-transcript-workflow/specs/meeting-session/spec.md b/openspec/changes/archive/2026-07-01-resilient-transcript-workflow/specs/meeting-session/spec.md similarity index 100% rename from openspec/changes/resilient-transcript-workflow/specs/meeting-session/spec.md rename to openspec/changes/archive/2026-07-01-resilient-transcript-workflow/specs/meeting-session/spec.md diff --git a/openspec/changes/resilient-transcript-workflow/tasks.md b/openspec/changes/archive/2026-07-01-resilient-transcript-workflow/tasks.md similarity index 100% rename from openspec/changes/resilient-transcript-workflow/tasks.md rename to openspec/changes/archive/2026-07-01-resilient-transcript-workflow/tasks.md diff --git a/openspec/specs/meeting-session/spec.md b/openspec/specs/meeting-session/spec.md index f7fcb37..9320605 100644 --- a/openspec/specs/meeting-session/spec.md +++ b/openspec/specs/meeting-session/spec.md @@ -229,6 +229,10 @@ A `speaker_identified` trigger MAY filter by speaker name. A `transcript_line` trigger MAY filter by speaker name. +Workflow rule execution SHALL log each triggered rule with its rule name and event type. + +Workflow rule execution SHALL log rule failures with the rule name and event type. + #### Scenario: State transition rule matches from and to - **GIVEN** a configured rule that triggers on a state transition from `collecting metadata` to `transcribing` - **WHEN** Meeting Assistant transitions that meeting from `collecting metadata` to `transcribing` @@ -241,11 +245,19 @@ A `transcript_line` trigger MAY filter by speaker name. - **WHEN** Meeting Assistant identifies speaker `Ada` - **THEN** it applies the rule steps -#### Scenario: Transcript line rule rewrites masked profanity before persistence +#### Scenario: Transcript line rule rewrites masked profanity after durable append - **GIVEN** a configured rule that triggers on transcript line writes and sets `transcript.line` by replacing `*****` with `[redacted]` - **WHEN** Meeting Assistant writes a transcript line for speaker `Guest-1` containing `*****` -- **THEN** the written transcript line contains `[redacted]` -- **AND** the written transcript line does not contain `*****` +- **THEN** Meeting Assistant first appends the original formatted line to the transcript file +- **AND** rewrites that written line to contain `[redacted]` +- **AND** the final written transcript line does not contain `*****` + +#### Scenario: Transcript line workflow failure keeps transcript writing +- **GIVEN** a configured transcript line workflow rule fails while processing a transcript line +- **WHEN** Meeting Assistant receives that live transcript segment +- **THEN** Meeting Assistant keeps the original formatted line in the transcript file +- **AND** logs the workflow rule failure +- **AND** continues processing later transcript segments ### Requirement: Meeting automation rules support conditions and steps Meeting Assistant SHALL support rule conditions using an expression engine. @@ -254,6 +266,8 @@ Rules SHALL support nested `and`, `or`, and `not` condition groups. Step values SHALL support Razor syntax against the current meeting event model. +Rendered step values SHALL be treated as plain UTF-8 text for markdown artifacts and SHALL NOT persist Razor HTML entity encoding. + Step values SHALL treat `@` characters inside valid email address tokens as literal text rather than Razor transitions. Meeting Assistant SHALL expose the formatted transcript line and transcript speaker to `transcript_line` rule conditions and Razor step templates. @@ -290,6 +304,12 @@ The `set_property` step SHALL support setting `transcript.line` during `transcri - **WHEN** the rule runs - **THEN** Meeting Assistant renders the Razor expression and preserves the email address as literal text +#### Scenario: Razor-rendered transcript line preserves UTF-8 text +- **GIVEN** a configured `transcript_line` rule uses Razor to replace part of a transcript line containing `heißt`, `Schimpfwörter`, and `nächstes` +- **WHEN** the rule changes `transcript.line` +- **THEN** the resulting transcript line contains the original UTF-8 characters +- **AND** does not contain HTML entities such as `ß`, `ö`, or `ä` + #### Scenario: Rule can clean a meeting title - **GIVEN** a configured state-transition rule that matches a title containing a configured marker - **WHEN** the rule runs @@ -298,18 +318,19 @@ The `set_property` step SHALL support setting `transcript.line` during `transcri #### Scenario: Transcript line conditions can use the written line and speaker - **GIVEN** a configured `transcript_line` rule with conditions over `transcript.line` and `transcript.speaker` - **WHEN** Meeting Assistant writes a transcript line that matches both conditions -- **THEN** it applies the rule steps before the line is persisted +- **THEN** Meeting Assistant applies the rule steps after the original formatted line is durably appended +- **AND** rewrites the written line if a rule changes `transcript.line` ### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant -Meeting Assistant SHALL expose an `Edit rules and identities` item from the tray icon menu. +Meeting Assistant SHALL expose an `Open agent` item from the tray icon menu. The tray icon SHALL be implemented through the Uno notification icon stack. The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item. -When the user selects `Edit rules and identities`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities. +When the user selects `Open agent`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities. -The chat window SHALL be titled `Edit rules and identities`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, MAY replace that line with `Reconnecting...` while retrying a transient agent request, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button. +The chat window SHALL be titled `Meeting Summary Agent`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display lightweight visible reasoning summary lines and tool-call lines that include only the called tool name while the agent is working, SHALL display a plain `Thinking...` line as the bottom activity line while the agent is working, MAY replace that bottom line with `Reconnecting...` while retrying a transient agent request, SHALL replace per-turn activity after completion with a collapsible `Worked for ` expander between the user and assistant cards, SHALL show visible reasoning summary lines, tool-call lines, and status lines in order inside the expander when expanded, SHALL collapse those details again when the expander is closed, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button. When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append. @@ -335,8 +356,8 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru #### Scenario: Tray menu opens the editor - **WHEN** the user opens the tray icon menu -- **THEN** the menu includes `Edit rules and identities` -- **WHEN** the user selects `Edit rules and identities` +- **THEN** the menu includes `Open agent` +- **WHEN** the user selects `Open agent` - **THEN** Meeting Assistant opens the workflow rules and identities editor chat window #### Scenario: Tray menu shows configured profile hotkeys @@ -385,9 +406,16 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru - **GIVEN** the rules editor chat window is open - **WHEN** the user types a prompt and presses Enter - **THEN** the user message appears in the conversation -- **AND** a plain `Thinking...` line appears while the agent is running +- **AND** a plain `Thinking...` line appears at the bottom while the agent is running +- **WHEN** the agent emits visible reasoning summaries or calls tools +- **THEN** lightweight activity lines appear above the bottom activity line with the visible reasoning summary text or only the called tool names +- **WHEN** the agent turn completes +- **THEN** the activity is represented between the user and assistant cards as a collapsed `Worked for ` expander +- **AND** expanding it shows the visible reasoning summary, status, and tool-call lines in order +- **AND** collapsing it hides those details again +- **AND** if no visible reasoning summary was emitted, the expander includes the fallback `Thinking...` line - **AND** the first model request for that turn is marked as user-initiated -- **AND** the final assistant response replaces the thinking line +- **AND** the final assistant response replaces the live activity lines #### Scenario: Rules editor displays agent request failures - **GIVEN** the rules editor chat window is open