From ecdd23bde706639155f088591980d92ee3200641 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Tue, 9 Jun 2026 17:10:06 +0200 Subject: [PATCH] Handle settings logs agent failures --- .../LiteLlmResponsesChatClientTests.cs | 10 +- .../WorkflowRulesEditorTests.cs | 99 ++++++++++++++++++- .../Summary/LiteLlmResponsesChatClient.cs | 12 ++- .../Workflow/WorkflowRulesEditorChatModels.cs | 3 +- .../WorkflowRulesEditorChatPipeline.cs | 6 +- .../WorkflowRulesEditorChatViewModel.cs | 22 ++++- ...orkflowRulesEditorWindowService.Windows.cs | 2 +- openspec/specs/meeting-session/spec.md | 8 +- 8 files changed, 147 insertions(+), 15 deletions(-) diff --git a/MeetingAssistant.Tests/LiteLlmResponsesChatClientTests.cs b/MeetingAssistant.Tests/LiteLlmResponsesChatClientTests.cs index e235422..2d0eee6 100644 --- a/MeetingAssistant.Tests/LiteLlmResponsesChatClientTests.cs +++ b/MeetingAssistant.Tests/LiteLlmResponsesChatClientTests.cs @@ -126,12 +126,14 @@ public sealed class LiteLlmResponsesChatClientTests } """) }); - using var client = CreateClient(handler, reconnectionAttempts: 1); + var retryCount = 0; + using var client = CreateClient(handler, reconnectionAttempts: 1, retrying: () => retryCount++); var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]); Assert.Equal("Done.", response.Text); Assert.Equal(2, handler.RequestCount); + Assert.Equal(1, retryCount); } [Fact] @@ -303,7 +305,8 @@ public sealed class LiteLlmResponsesChatClientTests private static LiteLlmResponsesChatClient CreateClient( HttpMessageHandler handler, int reconnectionAttempts, - LiteLlmResponsesCompactionOptions? compactionOptions = null) + LiteLlmResponsesCompactionOptions? compactionOptions = null, + Action? retrying = null) { return new LiteLlmResponsesChatClient( new HttpClient(handler) @@ -316,7 +319,8 @@ public sealed class LiteLlmResponsesChatClientTests reasoningEffort: "none", reconnectionAttempts, TimeSpan.Zero, - compactionOptions); + compactionOptions, + retrying: retrying); } private sealed class SequencedHttpMessageHandler : HttpMessageHandler diff --git a/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs b/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs index 0ac8722..ecb1a47 100644 --- a/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs +++ b/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs @@ -853,6 +853,52 @@ public sealed class WorkflowRulesEditorTests Assert.Equal("Updated rules.", viewModel.Messages[1].Content); } + [Fact] + public async Task ViewModelShowsAgentErrorWhenRequestTimesOut() + { + var viewModel = new WorkflowRulesEditorChatViewModel( + new ThrowingRulesEditorPipeline(new TaskCanceledException("The request timed out."))) + { + Draft = "check logs" + }; + + await viewModel.SendAsync(); + + 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()); + } + + [Fact] + public async Task ViewModelShowsReconnectStatusReportedByPipeline() + { + var pipeline = new StatusReportingRulesEditorPipeline("Updated rules."); + 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("Reconnecting...", viewModel.ActivityMessage); + + pipeline.Release.SetResult(); + await sendTask.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.False(viewModel.IsThinking); + Assert.Equal("Thinking...", viewModel.ActivityMessage); + Assert.Equal("Updated rules.", viewModel.Messages[1].Content); + } + [Theory] [InlineData(0, 500, 0, true)] [InlineData(0, 500, 400, true)] @@ -1149,7 +1195,8 @@ public sealed class WorkflowRulesEditorTests public async Task SendAsync( IReadOnlyList conversation, string userMessage, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Action? statusChanged = null) { Started.SetResult(); await Release.Task.WaitAsync(cancellationToken); @@ -1162,6 +1209,56 @@ public sealed class WorkflowRulesEditorTests } } + private sealed class ThrowingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline + { + private readonly Exception exception; + + public ThrowingRulesEditorPipeline(Exception exception) + { + this.exception = exception; + } + + public Task SendAsync( + IReadOnlyList conversation, + string userMessage, + CancellationToken cancellationToken, + Action? statusChanged = null) + { + throw exception; + } + } + + private sealed class StatusReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline + { + private readonly string response; + + public StatusReportingRulesEditorPipeline(string response) + { + this.response = response; + } + + 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? statusChanged = null) + { + statusChanged?.Invoke("Reconnecting..."); + 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()); + } + } + private sealed class CapturingLogger : ILogger { public List Messages { get; } = []; diff --git a/MeetingAssistant/Summary/LiteLlmResponsesChatClient.cs b/MeetingAssistant/Summary/LiteLlmResponsesChatClient.cs index 19d916e..85d304f 100644 --- a/MeetingAssistant/Summary/LiteLlmResponsesChatClient.cs +++ b/MeetingAssistant/Summary/LiteLlmResponsesChatClient.cs @@ -21,6 +21,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient private readonly TimeSpan reconnectionDelay; private readonly LiteLlmResponsesCompactionOptions? compactionOptions; private readonly ILogger? logger; + private readonly Action? retrying; private int responsesRequestCount; public LiteLlmResponsesChatClient( @@ -33,7 +34,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient TimeSpan reconnectionDelay, LiteLlmResponsesCompactionOptions? compactionOptions = null, ILogger? logger = null, - bool firstRequestIsUser = true) + bool firstRequestIsUser = true, + Action? retrying = null) : this( new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) }, apiKey, @@ -44,7 +46,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient reconnectionDelay, compactionOptions, logger, - firstRequestIsUser) + firstRequestIsUser, + retrying) { } @@ -58,7 +61,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient TimeSpan reconnectionDelay, LiteLlmResponsesCompactionOptions? compactionOptions = null, ILogger? logger = null, - bool firstRequestIsUser = true) + bool firstRequestIsUser = true, + Action? retrying = null) { this.httpClient = httpClient; this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); @@ -69,6 +73,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient this.reconnectionDelay = reconnectionDelay > TimeSpan.Zero ? reconnectionDelay : TimeSpan.Zero; this.compactionOptions = compactionOptions; this.logger = logger; + this.retrying = retrying; responsesRequestCount = firstRequestIsUser ? 0 : 1; } @@ -377,6 +382,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient lastException = exception; } + retrying?.Invoke(); await DelayBeforeRetryAsync(cancellationToken).ConfigureAwait(false); } diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs index 4d57eed..858b88e 100644 --- a/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs @@ -19,5 +19,6 @@ public interface IWorkflowRulesEditorChatPipeline Task SendAsync( IReadOnlyList conversation, string userMessage, - CancellationToken cancellationToken); + CancellationToken cancellationToken, + Action? statusChanged = null); } diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs index 6a34c8a..d090f33 100644 --- a/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs @@ -58,7 +58,8 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi public async Task SendAsync( IReadOnlyList conversation, string userMessage, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Action? statusChanged = null) { if (string.IsNullOrWhiteSpace(userMessage)) { @@ -106,7 +107,8 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi agentOptions.ReconnectionDelay, compactionOptions, logger, - firstRequestIsUser: true); + firstRequestIsUser: true, + retrying: () => statusChanged?.Invoke("Reconnecting...")); var functionClient = chatClient .AsBuilder() .UseFunctionInvocation(loggerFactory) diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs index 9882c43..bee604d 100644 --- a/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs @@ -17,6 +17,8 @@ public sealed class WorkflowRulesEditorChatViewModel public bool IsThinking { get; private set; } + public string ActivityMessage { get; private set; } = "Thinking..."; + public event EventHandler? Changed; public async Task SendAsync(CancellationToken cancellationToken = default) @@ -31,6 +33,7 @@ public sealed class WorkflowRulesEditorChatViewModel var priorConversation = Messages.ToList(); Messages.Add(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt)); IsThinking = true; + ActivityMessage = "Thinking..."; OnChanged(); try @@ -38,26 +41,39 @@ public sealed class WorkflowRulesEditorChatViewModel var result = await pipeline.SendAsync( priorConversation, prompt, - cancellationToken); + cancellationToken, + SetActivityMessage); Messages.Clear(); foreach (var message in result.Conversation) { Messages.Add(message); } } - catch (Exception exception) when (exception is not OperationCanceledException) + catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { Messages.Add(new WorkflowRulesEditorChatMessage( WorkflowRulesEditorChatRole.Agent, - $"Rules editor failed: {exception.Message}")); + $"Settings and logs failed: {exception.Message}")); } finally { IsThinking = false; + ActivityMessage = "Thinking..."; OnChanged(); } } + private void SetActivityMessage(string message) + { + if (!IsThinking || string.IsNullOrWhiteSpace(message)) + { + return; + } + + ActivityMessage = message; + OnChanged(); + } + private void OnChanged() { Changed?.Invoke(this, EventArgs.Empty); diff --git a/MeetingAssistant/Workflow/WpfWorkflowRulesEditorWindowService.Windows.cs b/MeetingAssistant/Workflow/WpfWorkflowRulesEditorWindowService.Windows.cs index 8d54064..9b58790 100644 --- a/MeetingAssistant/Workflow/WpfWorkflowRulesEditorWindowService.Windows.cs +++ b/MeetingAssistant/Workflow/WpfWorkflowRulesEditorWindowService.Windows.cs @@ -297,7 +297,7 @@ internal sealed class WorkflowRulesEditorWpfWindow : Window { conversationPanel.Children.Add(new TextBlock { - Text = "Thinking...", + Text = viewModel.ActivityMessage, Foreground = MutedText, Margin = new Thickness(4, 2, 4, 2) }); diff --git a/openspec/specs/meeting-session/spec.md b/openspec/specs/meeting-session/spec.md index 8259b80..50515a1 100644 --- a/openspec/specs/meeting-session/spec.md +++ b/openspec/specs/meeting-session/spec.md @@ -270,7 +270,7 @@ The tray icon menu SHALL show every configured launch profile when recording can 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. -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, 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 `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. 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. @@ -350,6 +350,12 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru - **AND** the first model request for that turn is marked as user-initiated - **AND** the final assistant response replaces the thinking line +#### Scenario: Rules editor displays agent request failures +- **GIVEN** the rules editor chat window is open +- **WHEN** the agent request fails or times out without caller cancellation +- **THEN** the user message remains in the conversation +- **AND** the thinking line is replaced by a visible assistant error message + #### Scenario: Chat auto-scrolls after appended content - **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom - **WHEN** a new user or assistant message is appended