Skip to content

RE1-T115 Adding TTS service to handle multi-lingual support for voice.#339

Merged
ucswift merged 2 commits intomasterfrom
develop
Apr 23, 2026
Merged

RE1-T115 Adding TTS service to handle multi-lingual support for voice.#339
ucswift merged 2 commits intomasterfrom
develop

Conversation

@ucswift
Copy link
Copy Markdown
Member

@ucswift ucswift commented Apr 23, 2026

Summary by CodeRabbit

  • New Features

    • Full TTS microservice added (generation, playback URLs, admin regeneration, warmup)
    • Voice call flows and verification now use generated speech; per-department TTS language selectable in settings
    • Audio caching and playback endpoints for improved performance; scoped rate-limiting for TTS requests
  • Infrastructure

    • Docker, Kubernetes manifests and solution/project updates for TTS service
    • Multi-target build support added (net8.0 and net9.0)
  • Tests

    • Extensive unit/integration tests for TTS, playback, storage, and voice response behaviors

@request-info
Copy link
Copy Markdown

request-info Bot commented Apr 23, 2026

Thanks for opening this, but we'd appreciate a little more information. Could you update it with more details?

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

Adds a new TTS microservice (Resgrid.Web.Tts) with audio generation, caching, S3 storage, rate limiting, health checks, and admin endpoints; integrates TTS-driven Twilio prompts and per-department TTS language; updates DI, solution, CI workflow to build the TTS Docker image; and adds comprehensive tests and worker scheduling.

Changes

Cohort / File(s) Summary
Solution & CI
Resgrid.sln, .github/workflows/dotnet.yml
Adds Resgrid.Web.Tts to the solution and expands the GitHub Actions docker build-and-push matrix to include the new TTS image.
Config Project
Core/Resgrid.Config/TtsConfig.cs, Core/Resgrid.Config/Resgrid.Config.csproj
Adds new static TtsConfig settings and multi-targets the config project to net8.0 and net9.0.
Model & Catalog
Core/Resgrid.Model/DepartmentSettingTypes.cs, Core/Resgrid.Model/EspeakVoiceCatalog.cs, Core/Resgrid.Model/TwilioVoicePromptCatalog.cs
Adds TtsLanguage enum member, eSpeak voice catalog and TTS prompt catalog (constants + formatting helpers).
Core Services & Interfaces
Core/Resgrid.Model/Services/*, Core/Resgrid.Services/*, Core/Resgrid.Services/ServicesModule.cs
New/extended interfaces and implementations: ITtsAudioService, communication test lookup, department settings getter, TtsAudioService REST client, and Autofac registrations for TTS RestClient and service.
TTS Web Project scaffold
Web/Resgrid.Web.Tts/*.csproj, Program.cs, Properties/launchSettings.json, Dockerfile, k8s/deployment.yaml, appsettings*.json
New ASP.NET Core project targeting .NET 8 with bootstrapping, DI, health checks, rate limiting, S3 wiring, Dockerfile and k8s manifests.
TTS Configuration & DI extensions
Web/Resgrid.Web.Tts/Configuration/*.cs
Adds S3/TTS/RateLimit options, validation, and ServiceCollection extension to map static TtsConfig into validated IOptions.
TTS Core Interfaces
Web/Resgrid.Web.Tts/Services/* (IAudioProcessingService, ICacheService, IStorageService, ITtsPlaybackUrlService, ITtsService, TtsCacheKey, TtsAudioContent)
Defines service contracts and simple DTOs/keys for TTS processing, caching, storage, playback URL creation, and high-level TTS operations.
TTS Implementations
Web/Resgrid.Web.Tts/Services/*.cs (AudioProcessingService, CacheService, S3StorageService, TtsPlaybackUrlService, TtsService, PromptWarmupHostedService)
Implements processing pipeline (eSpeak-ng + ffmpeg), deterministic caching with Redis+S3, S3 operations with retries, playback URL builder, TtsService orchestration with concurrency limits and warmup hosted service.
API Controllers
Web/Resgrid.Web.Tts/Controllers/TtsController.cs, Controllers/TtsAdminController.cs, Health/TtsDependencyHealthCheck.cs, models
Adds TTS API endpoints (generate single/batch, serve audio by hash, admin regenerate static prompts) and a dependency health check.
Twilio integration
Web/Resgrid.Web.Services/Twilio/*, Web/Resgrid.Web.Services/Controllers/*
New ITwilioVoiceResponseService and TwilioVoiceResponseService that chunk prompts, dedupe generation, and append Play verbs; controllers refactored to use the service and per-department TTS language; communication test webhook updated to use TTS.
UI Changes
Web/Resgrid.Web/Areas/User/*, Views/Department/Settings.cshtml
Adds TTS language selection to department settings, normalization/validation helpers, and view model properties.
Workers
Workers/Resgrid.Workers.Console/*
Adds scheduled TTS static prompt refresh command/task and schedules job when configured.
Tests
Tests/Resgrid.Tests/**/*
Extensive new and updated NUnit tests covering configuration mapping, playback URL creation, cache behavior, S3 retry, TtsService concurrency/deduplication, controller behaviors, Twilio response generation, worker task behavior, and more.
JS tweaks
Web/Resgrid.Web/wwwroot/js/*, ng/react-elements.js
Fixes DataTable “check all” delegation scope and updates React bundle reference.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant API as TtsController
    participant Service as TtsService
    participant Cache as CacheService
    participant Audio as AudioProcessingService
    participant Storage as S3StorageService

    Client->>API: POST /tts (text, voice, speed)
    API->>Service: GenerateAsync(request)
    Service->>Cache: CreateCacheKey(text,voice,speed)
    Service->>Cache: TryGetCachedUrlAsync(cacheKey)
    alt cache hit
        Cache->>Storage: GetObjectUrlAsync(objectKey)
        Storage-->>Service: Uri
        Service-->>API: TtsResponse {url, cached=true}
    else cache miss
        Service->>Audio: GenerateNormalizedWavAsync(text,voice,speed)
        Audio-->>Service: byte[] wav
        Service->>Cache: StoreAsync(cacheKey, wav)
        Cache->>Storage: UploadAsync(objectKey, stream)
        Storage-->>Service: Uri
        Service-->>API: TtsResponse {url, cached=false}
    end
    API-->>Client: 200 OK {url}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • github-actions
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically describes the main objective of the changeset: adding a TTS (text-to-speech) service to enable multi-lingual voice support, which aligns with the extensive addition of TTS infrastructure across the codebase.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

[ProducesResponseType(typeof(StaticPromptRegenerationResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<StaticPromptRegenerationResponse>> RegenerateStaticPromptsAsync(
[HttpPost]
[ProducesResponseType(typeof(TtsResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<TtsResponse>> GenerateAsync([FromBody] TtsRequest request, CancellationToken cancellationToken)
[HttpPost("batch")]
[ProducesResponseType(typeof(IReadOnlyCollection<TtsResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<IReadOnlyCollection<TtsResponse>>> GenerateBatchAsync([FromBody] List<TtsRequest> requests, CancellationToken cancellationToken)
_logger.LogWarning(
exception,
"Transient S3 failure during {OperationName} on attempt {Attempt}. Retrying in {DelayMs} ms.",
operationName,
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
Core/Resgrid.Services/DepartmentSettingsService.cs (1)

46-54: ⚠️ Potential issue | 🟠 Major

Invalidate the TTS language cache on create and delete too.

Line 69 only clears DSetTtsLanguage_{departmentId} in the update path. If a department previously cached the fallback language, creating the first TtsLanguage setting keeps serving the stale fallback for up to 14 days; deletes have the inverse stale-override problem.

🐛 Proposed cache-invalidation fix
 		public async Task<DepartmentSetting> SaveOrUpdateSettingAsync(int departmentId, string setting, DepartmentSettingTypes type, CancellationToken cancellationToken = default(CancellationToken))
 		{
 			var savedSetting = await GetSettingByDepartmentIdType(departmentId, type);
+			await ClearSettingCacheAsync(departmentId, type);
 
 			if (savedSetting == null)
 			{
 				DepartmentSetting newSetting = new DepartmentSetting();
 				newSetting.DepartmentId = departmentId;
@@
 			else
 			{
-				// Clear out Cache
-				switch (type)
-				{
-					case DepartmentSettingTypes.BigBoardMapCenterGpsCoordinates:
-						await _cacheProvider.RemoveAsync(string.Format(BigBoardCenterGps, departmentId));
-						break;
-					case DepartmentSettingTypes.DisabledAutoAvailable:
-						await _cacheProvider.RemoveAsync(string.Format(DisableAutoAvailableCacheKey, departmentId));
-						break;
-					case DepartmentSettingTypes.StaffingSuppressStaffingLevels:
-						await _cacheProvider.RemoveAsync(string.Format(StaffingSupressInfo, departmentId));
-						break;
-					case DepartmentSettingTypes.TtsLanguage:
-						await _cacheProvider.RemoveAsync(string.Format(TtsLanguageCacheKey, departmentId));
-						break;
-					case DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus:
-						await _cacheProvider.RemoveAsync(string.Format(PersonnelOnUnitSetUnitStatusCacheKey, departmentId));
-						break;
-				}
-
 				savedSetting.Setting = setting;
 				return await _departmentSettingsRepository.SaveOrUpdateAsync(savedSetting, cancellationToken);
 			}
@@
 		public async Task<bool> DeleteSettingAsync(int departmentId, DepartmentSettingTypes type, CancellationToken cancellationToken = default(CancellationToken))
 		{
 			var savedSetting = await GetSettingByDepartmentIdType(departmentId, type);
 
 			if (savedSetting != null)
+			{
+				await ClearSettingCacheAsync(departmentId, type);
 				return await _departmentSettingsRepository.DeleteAsync(savedSetting, cancellationToken);
+			}
 
 			return false;
 		}
+
+		private async Task ClearSettingCacheAsync(int departmentId, DepartmentSettingTypes type)
+		{
+			switch (type)
+			{
+				case DepartmentSettingTypes.BigBoardMapCenterGpsCoordinates:
+					await _cacheProvider.RemoveAsync(string.Format(BigBoardCenterGps, departmentId));
+					break;
+				case DepartmentSettingTypes.DisabledAutoAvailable:
+					await _cacheProvider.RemoveAsync(string.Format(DisableAutoAvailableCacheKey, departmentId));
+					break;
+				case DepartmentSettingTypes.StaffingSuppressStaffingLevels:
+					await _cacheProvider.RemoveAsync(string.Format(StaffingSupressInfo, departmentId));
+					break;
+				case DepartmentSettingTypes.TtsLanguage:
+					await _cacheProvider.RemoveAsync(string.Format(TtsLanguageCacheKey, departmentId));
+					break;
+				case DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus:
+					await _cacheProvider.RemoveAsync(string.Format(PersonnelOnUnitSetUnitStatusCacheKey, departmentId));
+					break;
+			}
+		}

Also applies to: 69-71, 84-91

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Core/Resgrid.Services/DepartmentSettingsService.cs` around lines 46 - 54,
When creating or deleting a TtsLanguage department setting you must also
invalidate the TTS language cache key so stale fallback values aren't served; in
DepartmentSettingsService update the create branch that builds newSetting (where
savedSetting == null) and the delete path (around the block that handles removal
at lines 84-91) to remove the cache key "DSetTtsLanguage_{departmentId}" in the
same way the update path does; locate the code that currently clears the cache
in the update path (references to savedSetting and the cache removal call) and
add equivalent cache invalidation calls in the newSetting creation flow and in
the delete flow so all create/update/delete paths clear the
DSetTtsLanguage_{departmentId} cache.
Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs (3)

504-509: ⚠️ Potential issue | 🟡 Minor

Input validation missing on twilioRequest.Digits.

int.Parse(twilioRequest.Digits) at line 523 will throw FormatException if Twilio posts an empty/non-numeric digits value (e.g., timeout with no input, or unexpected "#"/"*"). Consider int.TryParse and a graceful fallback prompt + hangup. Low likelihood given numDigits: 1 but Twilio can still send empty on gather timeout when no digits are pressed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs` around
lines 504 - 509, In VoiceCallAction, validate twilioRequest.Digits before
parsing: replace the direct int.Parse(twilioRequest.Digits) usage with
int.TryParse on twilioRequest.Digits and handle the false case by returning a
graceful Twilio response (e.g., play a retry prompt or Redirect back to the
gather and then Hangup) so empty or non-numeric input doesn't throw; update
logic around the int result where callId handling occurs to use the parsed value
only when TryParse succeeds.

456-481: ⚠️ Potential issue | 🟠 Major

Bug: prompt uses call.Address instead of the resolved address variable.

Lines 457–473 compute a local address that may be populated from a geo-lookup when call.Address is empty, but the ternary on line 477–479 checks address yet interpolates call.Address into the prompt. When the address came from the geo provider, call.Address is null/empty, so the generated prompt reads "... Priority X Address Nature Y" — an empty address slot. Use the local address variable.

🐛 Proposed fix
 			var prompts = new List<string>
 			{
 				!String.IsNullOrWhiteSpace(address)
-					? $"{call.Name}, Priority {call.GetPriorityText()} Address {call.Address} Nature {call.NatureOfCall}"
+					? $"{call.Name}, Priority {call.GetPriorityText()} Address {address} Nature {call.NatureOfCall}"
 					: $"{call.Name}, Priority {call.GetPriorityText()} Nature {call.NatureOfCall}",
 				TwilioVoicePromptCatalog.RepeatAndRespondToScene
 			};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs` around
lines 456 - 481, The prompt construction in TwilioProviderController uses
call.Address instead of the resolved local variable address, so when address was
filled from GeoLocationData the prompt shows an empty address; update the prompt
list creation to interpolate the local address variable (address) wherever the
current code inserts call.Address, ensuring the ternary still checks
String.IsNullOrWhiteSpace(address); check the block around
GetAproxAddressFromLatLong, GeoLocationData and the prompts List<string> to
replace call.Address with address so the generated prompt uses the resolved
value.

519-539: ⚠️ Potential issue | 🟠 Major

Missing stations.Count bound check — IndexOutOfRangeException possible.

index < 8 only caps the menu width but doesn't guard against stations having fewer entries. If a caller sends e.g. digit 9 while the department has 3 stations, stations[index] throws. Also, the null check if (station != null) is effectively dead code since stations[index] for a value-bearing list will return a reference (and indexing out of range throws, not returns null).

🐛 Proposed fix
-				if (index >= 0 && index < 8)
+				if (index >= 0 && index < Math.Min(8, stations.Count))
 				{
 					var station = stations[index];
-
-					if (station != null)
-					{
-						await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToStation, null,
-							station.DepartmentGroupId);
-
-						await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToStation(station.Name), call.DepartmentId);
-						response.Hangup();
-					}
-
+					await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToStation, null,
+						station.DepartmentGroupId);
+
+					await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToStation(station.Name), call.DepartmentId);
+					response.Hangup();
 				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs` around
lines 519 - 539, The code in TwilioProviderController computes index from
twilioRequest.Digits then accesses stations[index] without checking
stations.Count, risking IndexOutOfRangeException; update the logic after calling
_departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId)
to validate that index >= 0 && index < stations.Count (instead of index < 8),
only then fetch station and proceed to call
_actionLogsService.SetUserActionAsync and AppendVoicePromptAsync; remove the
ineffective null-indexing assumption and add an else branch to handle invalid
selections (e.g., play a retry/invalid selection prompt or hang up) so
out-of-range digits are handled safely.
Web/Resgrid.Web.Services/Controllers/TwilioController.cs (1)

682-707: ⚠️ Potential issue | 🟠 Major

Move the “consumed” save after TTS prompt generation succeeds.

The code marks the verification voice code consumed before the fallible AppendVoicePromptAsync calls. If TTS URL generation fails, the caller never hears the code but cannot retry with the same code.

🐛 Proposed fix
-	switch ((ContactVerificationType)contactType)
-	{
-		case ContactVerificationType.MobileNumber:
-			profile.MobileVerificationVoiceCodeConsumed = true;
-			break;
-		case ContactVerificationType.HomeNumber:
-			profile.HomeVerificationVoiceCodeConsumed = true;
-			break;
-	}
-
-	await _userProfileService.SaveProfileAsync(department.DepartmentId, profile);
-
 	var response = new VoiceResponse();
 	var spokenCode = string.Join(", ", code.ToCharArray());
 
 	await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.VerificationGreeting, department.DepartmentId);
 	for (int i = 0; i < 3; i++)
 	{
 		response.Pause(length: 1);
 		await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.VerificationCode(spokenCode), department.DepartmentId);
 	}
 	response.Pause(length: 1);
 	await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.VerificationClosing, department.DepartmentId);
 	response.Hangup();
+
+	switch ((ContactVerificationType)contactType)
+	{
+		case ContactVerificationType.MobileNumber:
+			profile.MobileVerificationVoiceCodeConsumed = true;
+			break;
+		case ContactVerificationType.HomeNumber:
+			profile.HomeVerificationVoiceCodeConsumed = true;
+			break;
+	}
+
+	await _userProfileService.SaveProfileAsync(department.DepartmentId, profile);
 
 	return CreateVoiceContentResult(response);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Services/Controllers/TwilioController.cs` around lines 682 -
707, The code sets profile.MobileVerificationVoiceCodeConsumed /
profile.HomeVerificationVoiceCodeConsumed and calls
_userProfileService.SaveProfileAsync before the fallible AppendVoicePromptAsync
work; move the flag mutation and the SaveProfileAsync call to after the TTS
prompt generation completes successfully (i.e., after the loop of
AppendVoicePromptAsync calls and before returning
CreateVoiceContentResult(response)) so the code is only marked consumed if
AppendVoicePromptAsync (and any TTS URL generation) succeeded; locate these
changes around the ContactVerificationType switch,
_userProfileService.SaveProfileAsync, the AppendVoicePromptAsync calls,
VoiceResponse construction, and CreateVoiceContentResult to implement the
reorder.
🟡 Minor comments (7)
Web/Resgrid.Web.Tts/appsettings.Development.json-9-9 (1)

9-9: ⚠️ Potential issue | 🟡 Minor

Windows-only default path for AppOptions.ConfigPath.

C:\\Resgrid\\Config\\ResgridConfig.json will not resolve on Linux/macOS developers or in a Linux container running with ASPNETCORE_ENVIRONMENT=Development. Other services in this repo typically accept an env-var override (e.g. APPOPTIONS__CONFIGPATH / AppOptions__ConfigPath) — please confirm the TTS Program.cs honors that so non-Windows contributors don’t have to edit this file. Consider using a relative/Linux-friendly default or documenting the override in the project README.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/appsettings.Development.json` at line 9,
appsettings.Development.json contains a Windows-only default for
AppOptions.ConfigPath; update Program.cs so the configuration system honors
environment-variable overrides and non-Windows defaults by ensuring the
configuration builder includes environment variables and the AppOptions section
is bound via IConfiguration (e.g., use
builder.Configuration.GetSection("AppOptions") /
services.Configure<AppOptions>(...) or equivalent) so APPOPTIONS__CONFIGPATH or
AppOptions__ConfigPath will override the file; also change the development
default in appsettings.Development.json to a relative or Linux-friendly value
(or remove the absolute path) so non-Windows developers and Linux containers
don't need to edit the file.
Web/Resgrid.Web.Tts/Services/TtsAudioContent.cs-3-3 (1)

3-3: ⚠️ Potential issue | 🟡 Minor

Heads-up: record value-equality on byte[] uses reference equality.

TtsAudioContent is a record but one of its members is byte[] AudioBytes, whose generated Equals/GetHashCode compares by reference, not by content. If this record is ever compared, deduplicated, or used as a dictionary key, two payloads with identical bytes will not be considered equal. Based on the summary this type is only used as a cache value (not a key), so this is likely fine — but worth being explicit (e.g. add an XML comment) or overriding equality so future callers do not get tripped up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Services/TtsAudioContent.cs` at line 3, TtsAudioContent's
generated record equality uses reference-equality for the byte[] AudioBytes, so
implement content-based equality or document the reference semantics: change the
positional record TtsAudioContent(byte[] AudioBytes, ...) into a record with a
body and override Equals(TtsAudioContent? other) to compare AudioBytes by
content (e.g., SequenceEqual) and override GetHashCode() to compute a hash from
the byte contents (or use a stable hashing method like HashCode.Combine with a
span-based/rolling hash); alternatively, replace AudioBytes with a value-type
representation (ReadOnlyMemory<byte> or ImmutableArray<byte>) or add an XML
comment on TtsAudioContent clarifying that AudioBytes equality is by reference
if you choose not to override.
Resgrid.sln-101-101 (1)

101-101: ⚠️ Potential issue | 🟡 Minor

Wrong project type GUID for Resgrid.Web.Tts.

The project is registered with type GUID {FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}, which is the legacy C++ project type. Every other SDK‑style C# project in this solution uses {9A19103F-16F7-4668-BE54-9A1E7A4F7556} (e.g. Resgrid.Web.Mcp on line 83, Resgrid.Web.Eventing on line 81). Visual Studio/dotnet may still open/build it, but tooling that respects the type GUID (code-style, some analyzers, project templates, solution filters) will misclassify it. The pre-existing Resgrid.Providers.Weather entry on line 99 has the same typo — worth fixing both while you’re here.

🔧 Proposed fix
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Web.Tts", "Web\Resgrid.Web.Tts\Resgrid.Web.Tts.csproj", "{684FA75D-6712-41AE-A396-B6E0918899C0}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Resgrid.Web.Tts", "Web\Resgrid.Web.Tts\Resgrid.Web.Tts.csproj", "{684FA75D-6712-41AE-A396-B6E0918899C0}"
 EndProject
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Resgrid.sln` at line 101, The solution file registers Resgrid.Web.Tts and
Resgrid.Providers.Weather with the legacy C++ project type GUID
({FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}); update their Project(...) entries to
use the SDK-style C# project type GUID ({9A19103F-16F7-4668-BE54-9A1E7A4F7556}).
Locate the lines starting with Project("...") = "Resgrid.Web.Tts",
"Resgrid.Web.Tts.csproj", "{684FA75D-6712-41AE-A396-B6E0918899C0}" and
Project("...") = "Resgrid.Providers.Weather", ... and replace the first GUID in
each Project(...) header with {9A19103F-16F7-4668-BE54-9A1E7A4F7556} so the
projects are correctly classified as C# SDK projects.
Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs-688-715 (1)

688-715: ⚠️ Potential issue | 🟡 Minor

Don’t treat an unsupported configured default as a supported language.

If TtsConfig.DefaultVoice is non-empty but not recognized by EspeakVoiceCatalog, IsSupportedTtsLanguage accepts that raw value. That can persist a language the TTS backend cannot synthesize, despite the validation message saying it must be a supported eSpeak-NG language.

Proposed fix
 private static bool IsSupportedTtsLanguage(string ttsLanguage)
 {
-	if (EspeakVoiceCatalog.TryNormalizeIdentifier(ttsLanguage, out _))
-		return true;
-
-	return string.Equals(NormalizeTtsLanguage(ttsLanguage), GetConfiguredDefaultTtsLanguage(), StringComparison.OrdinalIgnoreCase);
+	return EspeakVoiceCatalog.TryNormalizeIdentifier(ttsLanguage, out _);
 }
 
 private static string GetConfiguredDefaultTtsLanguage()
 {
 	if (EspeakVoiceCatalog.TryNormalizeIdentifier(TtsConfig.DefaultVoice, out var normalizedLanguage))
 		return normalizedLanguage;
 
-	if (!string.IsNullOrWhiteSpace(TtsConfig.DefaultVoice))
-		return TtsConfig.DefaultVoice.Trim();
-
 	return EspeakVoiceCatalog.DefaultIdentifier;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs` around lines
688 - 715, IsSupportedTtsLanguage currently treats a raw, unrecognized
TtsConfig.DefaultVoice as supported because GetConfiguredDefaultTtsLanguage
returns the raw value; change GetConfiguredDefaultTtsLanguage so it only returns
a normalized identifier or falls back to EspeakVoiceCatalog.DefaultIdentifier
(i.e., call EspeakVoiceCatalog.TryNormalizeIdentifier and if that fails do NOT
return the raw TtsConfig.DefaultVoice.Trim(), instead return
EspeakVoiceCatalog.DefaultIdentifier); this ensures
NormalizeTtsLanguage/IsSupportedTtsLanguage (and callers) never accept an
unrecognized configured default.
Core/Resgrid.Services/DepartmentSettingsService.cs-870-879 (1)

870-879: ⚠️ Potential issue | 🟡 Minor

Don’t return an invalid configured default voice.

Line 876 returns any non-empty TtsConfig.DefaultVoice even when EspeakVoiceCatalog.TryNormalizeIdentifier rejects it, which can push an unusable voice into TTS generation instead of falling back safely.

🐛 Proposed fallback fix
 		private static string GetDefaultTtsLanguage()
 		{
 			if (EspeakVoiceCatalog.TryNormalizeIdentifier(TtsConfig.DefaultVoice, out var normalizedVoice))
 				return normalizedVoice;
 
-			if (!string.IsNullOrWhiteSpace(TtsConfig.DefaultVoice))
-				return TtsConfig.DefaultVoice.Trim();
-
 			return EspeakVoiceCatalog.DefaultIdentifier;
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Core/Resgrid.Services/DepartmentSettingsService.cs` around lines 870 - 879,
GetDefaultTtsLanguage currently returns TtsConfig.DefaultVoice when
TryNormalizeIdentifier fails but the configured value is non-empty, which may be
invalid; change the logic in GetDefaultTtsLanguage so that if
EspeakVoiceCatalog.TryNormalizeIdentifier(TtsConfig.DefaultVoice, out var
normalizedVoice) returns true you return normalizedVoice, otherwise ignore any
non-normalized TtsConfig.DefaultVoice and return
EspeakVoiceCatalog.DefaultIdentifier (or trim/validate first if you intend to
accept only normalized values). Ensure you only accept TtsConfig.DefaultVoice
when it can be normalized by TryNormalizeIdentifier and otherwise fall back to
EspeakVoiceCatalog.DefaultIdentifier.
Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs-68-83 (1)

68-83: ⚠️ Potential issue | 🟡 Minor

Preserve default prompts when config is unset.

Line 68 replaces the default TtsOptions.PreGeneratedPrompts with an empty list whenever TtsConfig.PreGeneratedPrompts is blank. That can silently disable static prompt generation for deployments that do not yet provide the new setting.

🐛 Proposed default-preserving mapping
-			options.PreGeneratedPrompts = ParsePrompts(TtsConfig.PreGeneratedPrompts);
+			var configuredPrompts = ParsePrompts(TtsConfig.PreGeneratedPrompts);
+			if (configuredPrompts.Count > 0)
+				options.PreGeneratedPrompts = configuredPrompts;
 		}
 
 		private static List<string> ParsePrompts(string rawPrompts)
 		{
 			if (string.IsNullOrWhiteSpace(rawPrompts))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs` around
lines 68 - 83, The mapping currently overwrites TtsOptions.PreGeneratedPrompts
with an empty list when TtsConfig.PreGeneratedPrompts is blank; change
ParsePrompts to return null when rawPrompts is null/whitespace and update the
caller in ServiceCollectionExtensions to only assign options.PreGeneratedPrompts
when ParsePrompts(...) returns a non-null value (i.e., leave the default prompts
intact if the config is unset). Reference: ParsePrompts and the code that sets
options.PreGeneratedPrompts in ServiceCollectionExtensions.cs.
Web/Resgrid.Web.Tts/Services/CacheService.cs-129-138 (1)

129-138: ⚠️ Potential issue | 🟡 Minor

Broaden corruption handling when deserializing the cached payload.

Only InvalidDataException is caught on Line 133, but DeserializeAudioContent can throw other exceptions for a corrupted/truncated entry: EndOfStreamException from BinaryReader.ReadByte/ReadString/ReadInt32/ReadInt64, ArgumentOutOfRangeException from new DateTimeOffset(ticks, TimeSpan.Zero) on out-of-range ticks, and IOException/ArgumentException from malformed string length prefixes in ReadString. Any of these will bubble up into request-serving code paths and — because the poisoned key is not evicted — keep failing on subsequent reads.

Either normalize all deserialization failures to InvalidDataException inside DeserializeAudioContent, or widen the catch:

🛡️ Proposed fix
-            try
-            {
-                return DeserializeAudioContent(payload);
-            }
-            catch (InvalidDataException)
-            {
-                await _distributedCache.RemoveAsync(GetAudioCacheEntryKey(hash), cancellationToken);
-                return null;
-            }
+            try
+            {
+                return DeserializeAudioContent(payload);
+            }
+            catch (Exception ex) when (ex is InvalidDataException
+                                        or EndOfStreamException
+                                        or ArgumentException
+                                        or IOException)
+            {
+                await _distributedCache.RemoveAsync(GetAudioCacheEntryKey(hash), cancellationToken);
+                return null;
+            }

Also applies to: 178-206

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Services/CacheService.cs` around lines 129 - 138, The
try/catch around DeserializeAudioContent only handles InvalidDataException so
other deserialization errors (EndOfStreamException, ArgumentOutOfRangeException,
IOException, etc.) leave a poisoned cache key; update the catch to handle all
deserialization failures by either normalizing exceptions inside
DeserializeAudioContent to InvalidDataException or widening the catch here to
catch Exception (or the specific deserialization exceptions) and then call
_distributedCache.RemoveAsync(GetAudioCacheEntryKey(hash), cancellationToken)
before returning null; make the same change for the other deserialization block
that mirrors this logic (the block covering lines 178-206) so any corruption
always evicts the cached entry.
🧹 Nitpick comments (14)
Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js (1)

421-423: Keep the exported helper consistent with the new row-only behavior.

checkAllUnits still toggles every checkbox under the grid, while the new handlers intentionally target only tbody row checkboxes. If this helper is still used from markup, scope it the same way to avoid affecting header or future non-row controls.

♻️ Proposed alignment
 function checkAllUnits(gridName, item) {
-    $('#' + gridName).find(':checkbox').prop('checked', item.checked);
+    $('#' + gridName).find('tbody :checkbox').prop('checked', item.checked);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js`
around lines 421 - 423, The helper checkAllUnits currently toggles every
checkbox under the grid; change its selector to only target row checkboxes
(match the new handlers) by scoping to the table body—e.g., use $('#' +
gridName).find('tbody :checkbox') (or $('#' + gridName + '
tbody').find(':checkbox')) instead of $('#' + gridName).find(':checkbox');
update any markup usage that expects the old behavior if needed and keep
parameters (gridName, item) unchanged.
Core/Resgrid.Model/Services/ITtsAudioService.cs (1)

10-10: Consider nullable annotation on voice parameter.

string voice = null will emit a CS8625 warning if/when nullable reference types are enabled for Resgrid.Model. Prefer string? voice = null for clarity of intent, matching the optional semantic already expressed by int? speed.

Proposed change
-		Task<Uri> GenerateSpeechUrlAsync(string text, string voice = null, int? speed = null, CancellationToken cancellationToken = default);
+		Task<Uri> GenerateSpeechUrlAsync(string text, string? voice = null, int? speed = null, CancellationToken cancellationToken = default);

As per coding guidelines: "Use modern C# features appropriately."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Core/Resgrid.Model/Services/ITtsAudioService.cs` at line 10, Update the
GenerateSpeechUrlAsync signature to mark the voice parameter as nullable: change
the parameter type in the ITtsAudioService.GenerateSpeechUrlAsync declaration
from string voice = null to string? voice = null so it matches the optional int?
speed and avoids CS8625 when nullable reference types are enabled; ensure any
implementations of GenerateSpeechUrlAsync (methods named the same in
implementing classes) are updated to accept string? as well.
Web/Resgrid.Web.Tts/Models/TtsRequest.cs (1)

11-15: Consider tightening Voice validation.

Voice accepts up to 64 chars with no pattern validation. Since voice names are ultimately forwarded to an external TTS engine / used in object keys (per the S3 caching flow described in the PR), consider constraining to an allowlist or regex (e.g., [A-Za-z0-9_-]+) to prevent unexpected characters from propagating into cache keys or storage paths.

Also, [Range(80, 450)] on Speed — confirm these bounds match the underlying TTS engine's supported WPM/percentage range; out-of-band values silently clamped vs rejected is a common footgun.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Models/TtsRequest.cs` around lines 11 - 15, Tighten
validation on the TtsRequest model: add a RegularExpression attribute to the
Voice property (e.g., allow only alphanumerics, underscore and hyphen) and
reduce the max length if appropriate so voice names cannot contain path-breaking
characters; update the Voice property definition in the TtsRequest class to
include this regex-based validation in addition to StringLength. For Speed (the
Speed property on TtsRequest) verify the [Range(80, 450)] bounds against the
target TTS engine and either adjust the range to match the engine’s supported
min/max or change validation to explicitly reject out-of-range values (rather
than implicitly clamping) so callers get immediate errors. Ensure you reference
and update TtsRequest.Voice and TtsRequest.Speed in tests and any
deserialization paths that rely on these constraints.
Core/Resgrid.Model/EspeakVoiceCatalog.cs (1)

9-145: Use an array with Array.AsReadOnly() to prevent unsafe casting of the voice catalog.

Voices exposes VoicesInternal as IReadOnlyList<TtsVoiceOption>, but at runtime it references a mutable List<TtsVoiceOption>. Callers could theoretically cast it back to List<TtsVoiceOption> and mutate the global catalog. Use a TtsVoiceOption[] wrapped with Array.AsReadOnly() instead to prevent unsafe downcasting. This aligns with the coding guideline to prefer functional patterns and immutable data in C#.

♻️ Proposed fix
-		private static readonly IReadOnlyList<TtsVoiceOption> VoicesInternal = new List<TtsVoiceOption>
+		private static readonly TtsVoiceOption[] VoicesInternal =
 		{
 			new TtsVoiceOption("af", "Afrikaans"),
 			new TtsVoiceOption("sq", "Albanian"),
 		public const string DefaultIdentifier = "en-us";
 
-		public static IReadOnlyList<TtsVoiceOption> Voices => VoicesInternal;
+		public static IReadOnlyList<TtsVoiceOption> Voices { get; } = Array.AsReadOnly(VoicesInternal);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Core/Resgrid.Model/EspeakVoiceCatalog.cs` around lines 9 - 145,
VoicesInternal is currently a mutable List<TtsVoiceOption> typed as
IReadOnlyList, which allows unsafe downcast and mutation; change the backing
collection to a private static readonly TtsVoiceOption[] VoicesInternal = new
TtsVoiceOption[] { ... } (populate with the same entries), keep VoiceLookup
built from VoicesInternal (VoicesInternal.ToDictionary(...)), and change the
public accessor to return an immutable wrapper like public static
IReadOnlyList<TtsVoiceOption> Voices => Array.AsReadOnly(VoicesInternal); ensure
DefaultIdentifier and VoiceLookup remain unchanged.
Web/Resgrid.Web.Tts/Dockerfile (1)

11-25: Consider adding a HEALTHCHECK and pinning package versions.

Optional hardening suggestions:

  • Add a HEALTHCHECK instruction (e.g., hitting a /health endpoint) so orchestrators can detect unhealthy TTS pods beyond liveness based on process exit.
  • Consider pinning espeak-ng / ffmpeg apt package versions (or at least documenting that the image is tied to Debian stable minor upgrades) for reproducible TTS output across rebuilds — audio codecs in particular can change subtle behaviors between ffmpeg versions.

Also confirm whether the published app needs write access anywhere under /app at runtime; if temp files are written during TTS generation, ensure they go to /tmp or an explicitly-writable volume, since /app is root-owned while the process runs as appuser.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Dockerfile` around lines 11 - 25, Add a HEALTHCHECK to
the Dockerfile that probes the application's health endpoint (e.g., GET /health
on ASPNETCORE_URLS) so orchestrators can detect non-crash failures, and pin or
document apt package versions for espeak-ng and ffmpeg (or note Debian stable
minor-upgrade coupling) to ensure reproducible TTS output; also verify
Resgrid.Web.Tts runtime file writes: if the TTS code (e.g., temp file creation
in the app) writes under /app, change it to use /tmp or an explicit writable
volume and/or update the Dockerfile to create and chown a writable directory
before switching to USER appuser so the ENTRYPOINT dotnet process can write
where needed.
Core/Resgrid.Model/Services/IDepartmentSettingsService.cs (1)

172-173: Add XML doc comment for consistency.

Every other member in this interface is documented with a <summary> block. Please add equivalent XML docs for GetTtsLanguageForDepartmentAsync describing the return format (normalized language tag / eSpeak voice identifier) and the default/fallback behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Core/Resgrid.Model/Services/IDepartmentSettingsService.cs` around lines 172 -
173, Add an XML <summary> doc comment to the interface method
GetTtsLanguageForDepartmentAsync(int departmentId) describing what the method
returns (a normalized language tag or an eSpeak voice identifier), explain the
format expected (e.g., "en-US" or "en+f3" style), and state the default/fallback
behavior when no department-specific setting exists (which language/voice is
returned). Place the comment directly above the Task<string>
GetTtsLanguageForDepartmentAsync(int departmentId); declaration and include a
brief <param name="departmentId"> description and a <returns> line that
documents the exact return value semantics.
Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj (1)

12-16: Consider updating NuGet versions for currency.

The pinned versions are all valid and have no known security advisories, but are notably outdated for a greenfield service:

  • AWSSDK.S3 3.7.414.5 → latest 4.0.22
  • Microsoft.Extensions.Caching.StackExchangeRedis 8.0.0 → latest 10.0.7
  • Swashbuckle.AspNetCore 6.6.2 → latest 10.1.7

Updating to the latest 8.x/10.x-compatible versions will reduce version debt and align with current ecosystem standards.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj` around lines 12 - 16, Update the
three pinned NuGet PackageReference entries in Resgrid.Web.Tts.csproj to current
compatible releases: change AWSSDK.S3 from 3.7.414.5 to 4.0.22,
Microsoft.Extensions.Caching.StackExchangeRedis from 8.0.0 to 10.0.7, and
Swashbuckle.AspNetCore from 6.6.2 to 10.1.7; verify the project builds and run
tests after updating the PackageReference elements to ensure no breaking API
changes affect code that uses AWSSDK.S3,
Microsoft.Extensions.Caching.StackExchangeRedis, or Swashbuckle.AspNetCore.
Web/Resgrid.Web.Tts/Controllers/TtsAdminController.cs (1)

106-118: Key-length comparison leaks timing information.

The configuredBytes.Length == suppliedBytes.Length short-circuit is not constant-time and leaks the configured key length through timing of early return. For a shared admin secret this is typically acceptable, but if you want strict constant-time behavior (including identical runtime across any supplied-key length), hash both sides to a fixed size first and then FixedTimeEquals, e.g. using SHA256.HashData.

🛡️ Optional hardening
-			var configuredBytes = Encoding.UTF8.GetBytes(_options.StaticPromptAdminKey);
-			var suppliedBytes = Encoding.UTF8.GetBytes(suppliedKey);
-
-			return configuredBytes.Length == suppliedBytes.Length
-				   && CryptographicOperations.FixedTimeEquals(configuredBytes, suppliedBytes);
+			Span<byte> configuredHash = stackalloc byte[32];
+			Span<byte> suppliedHash = stackalloc byte[32];
+			SHA256.HashData(Encoding.UTF8.GetBytes(_options.StaticPromptAdminKey), configuredHash);
+			SHA256.HashData(Encoding.UTF8.GetBytes(suppliedKey), suppliedHash);
+			return CryptographicOperations.FixedTimeEquals(configuredHash, suppliedHash);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Controllers/TtsAdminController.cs` around lines 106 -
118, IsAuthorized currently leaks the configured key length via the Length
equality short-circuit; instead normalize both _options.StaticPromptAdminKey and
suppliedKey to a fixed-size digest (e.g. SHA256.HashData) and then call
CryptographicOperations.FixedTimeEquals on those digests so comparison time is
independent of input lengths while keeping the existing null/whitespace checks;
update IsAuthorized to compute hashA =
SHA256.HashData(Encoding.UTF8.GetBytes(_options.StaticPromptAdminKey)) and hashB
= SHA256.HashData(Encoding.UTF8.GetBytes(suppliedKey)) and return
FixedTimeEquals(hashA, hashB).
Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs (1)

91-125: Harden process orchestration against cancellation races.

A few small issues in RunProcessAsync:

  • process.StandardInput.FlushAsync() doesn't propagate cancellationToken (overload accepting one exists in modern .NET).
  • If cancellationToken triggers the TryKillProcess callback while stdin is still being written/flushed, the WriteAsync/FlushAsync/Close can throw (e.g., IOException/ObjectDisposedException) and be surfaced ahead of the more informative process-exit exception. Consider wrapping the stdin write block in a try/catch that swallows the expected disposal exceptions when the token is cancelled, then let WaitForExitAsync throw OperationCanceledException.
  • On failure, the captured standardOutput is only used as a fallback; if both are non-empty, stdout (which for ffmpeg -loglevel error is normally empty) is discarded. That's fine, just noting for log completeness — you could concatenate both when diagnosing.
♻️ Suggested tweak
-			if (standardInput is not null)
-			{
-				await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken);
-				await process.StandardInput.FlushAsync();
-				process.StandardInput.Close();
-			}
+			if (standardInput is not null)
+			{
+				try
+				{
+					await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken);
+					await process.StandardInput.FlushAsync(cancellationToken);
+				}
+				catch (IOException) when (cancellationToken.IsCancellationRequested) { }
+				catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) { }
+				finally
+				{
+					process.StandardInput.Close();
+				}
+			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs` around lines 91 -
125, RunProcessAsync currently can surface write/flush/close exceptions if
cancellation fires and disposes the process; fix by wrapping the stdin block
(the WriteAsync/FlushAsync/Close calls in RunProcessAsync) in a try/catch that
catches IOException and ObjectDisposedException (and OperationCanceledException)
and only suppresses them when cancellationToken.IsCancellationRequested so the
cancellation path lets WaitForExitAsync surface OperationCanceledException via
the cancellationRegistration/TryKillProcess; also call FlushAsync with the
cancellationToken overload. Finally, when throwing the non-zero exit exception
from RunProcessAsync include both standardError and standardOutput (e.g., concat
or prefer non-empty then append the other) so logs include both streams for
diagnosis.
Web/Resgrid.Web.Services/Twilio/ITwilioVoiceResponseService.cs (1)

9-18: Parameter ordering and fully-qualified Task are non-idiomatic.

CancellationToken conventionally sits at the end of the parameter list per C#/.NET guidelines, but here it precedes an optional voice parameter with a default. Additionally, using System.Threading.Tasks; is already imported, so System.Threading.Tasks.Task can be shortened to Task.

♻️ Proposed signatures
-		System.Threading.Tasks.Task AppendPromptAsync(VoiceResponse response, string text, CancellationToken cancellationToken = default, string voice = null);
-
-		System.Threading.Tasks.Task AppendPromptAsync(Gather gather, string text, CancellationToken cancellationToken = default, string voice = null);
-
-		System.Threading.Tasks.Task AppendPromptsAsync(VoiceResponse response, IEnumerable<string> prompts, CancellationToken cancellationToken = default, string voice = null);
-
-		System.Threading.Tasks.Task AppendPromptsAsync(Gather gather, IEnumerable<string> prompts, CancellationToken cancellationToken = default, string voice = null);
+		Task AppendPromptAsync(VoiceResponse response, string text, string voice = null, CancellationToken cancellationToken = default);
+
+		Task AppendPromptAsync(Gather gather, string text, string voice = null, CancellationToken cancellationToken = default);
+
+		Task AppendPromptsAsync(VoiceResponse response, IEnumerable<string> prompts, string voice = null, CancellationToken cancellationToken = default);
+
+		Task AppendPromptsAsync(Gather gather, IEnumerable<string> prompts, string voice = null, CancellationToken cancellationToken = default);

Note: this is a breaking change to the interface; call sites (including TwilioProviderController) will need to be updated. As per coding guidelines, "Use modern C# features appropriately" and "avoid unclear abbreviations".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Services/Twilio/ITwilioVoiceResponseService.cs` around lines
9 - 18, The interface ITwilioVoiceResponseService uses fully-qualified
System.Threading.Tasks.Task and places CancellationToken before an optional
voice parameter, which is non-idiomatic; update all four method signatures
(AppendPromptAsync(VoiceResponse ...), AppendPromptAsync(Gather ...),
AppendPromptsAsync(VoiceResponse ...), AppendPromptsAsync(Gather ...)) to return
Task (use the imported Task type) and move the CancellationToken parameter to
the end of each parameter list (e.g., ... string voice = null, CancellationToken
cancellationToken = default), then update all callers (including
TwilioProviderController) to match the new ordering.
Web/Resgrid.Web.Tts/Controllers/TtsController.cs (1)

50-69: Add a configurable maximum batch size limit.

GenerateBatchAsync accepts an unbounded batch of requests and processes them all concurrently via Task.WhenAll without size constraints. Combined with expensive eSpeak+ffmpeg processing per item, a large batch can exhaust worker threads, disk, and CPU even with rate limiting in front (the rate limiter counts requests, not items). Add a configurable MaxBatchSize setting to TtsOptions and validate the batch size in GenerateBatchAsync, returning 400 Bad Request when exceeded.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Controllers/TtsController.cs` around lines 50 - 69, Add a
MaxBatchSize int property to TtsOptions and wire TtsOptions into TtsController
(e.g., via IOptions<TtsOptions> in the constructor), then validate the incoming
requests list length at the start of GenerateBatchAsync: if requests is null or
requests.Count == 0 handle as before, but if requests.Count >
options.Value.MaxBatchSize return BadRequest(CreateProblemDetails($"Batch size
exceeds MaxBatchSize of {options.Value.MaxBatchSize}")); otherwise call
_ttsService.GenerateBatchAsync as before. Ensure you reference the
TtsOptions.MaxBatchSize property and the GenerateBatchAsync method and keep the
existing error handling behavior for ArgumentException.
Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs (1)

40-43: Inject ITtsAudioService instead of resolving it from the root container.

Bootstrapper.GetKernel().Resolve<ITtsAudioService>() hides a required dependency and couples task execution to the Autofac root container. Constructor injection keeps the handler testable and avoids service-locator behavior. As per coding guidelines, “Design for testability; avoid hidden dependencies inside methods and prefer explicit, pure functions”.

♻️ Proposed refactor
 	private readonly ILogger _logger;
+	private readonly ITtsAudioService _ttsAudioService;
 
-	public TtsStaticPromptRefreshTask(ILogger logger)
+	public TtsStaticPromptRefreshTask(ILogger logger, ITtsAudioService ttsAudioService)
 	{
 		_logger = logger;
+		_ttsAudioService = ttsAudioService;
 	}
 
-	var ttsAudioService = Bootstrapper.GetKernel().Resolve<ITtsAudioService>();
-
 	_logger.LogInformation("TtsStaticPromptRefresh::Refreshing static prompts");
-	await ttsAudioService.RegenerateStaticPromptsAsync(TwilioVoicePromptCatalog.GetStaticPrompts(), cancellationToken);
+	await _ttsAudioService.RegenerateStaticPromptsAsync(TwilioVoicePromptCatalog.GetStaticPrompts(), cancellationToken);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs` around
lines 40 - 43, Replace the service-locator call to
Bootstrapper.GetKernel().Resolve<ITtsAudioService>() by injecting
ITtsAudioService into the TtsStaticPromptRefreshTask via its constructor: add an
ITtsAudioService parameter, assign it to a private readonly field (e.g.,
_ttsAudioService) and use that field in ExecuteAsync when calling
RegenerateStaticPromptsAsync(TwilioVoicePromptCatalog.GetStaticPrompts(),
cancellationToken); remove the Resolve call and update any instantiation/DI
registration of TtsStaticPromptRefreshTask so the container supplies
ITtsAudioService, keeping _logger usage unchanged.
Core/Resgrid.Config/TtsConfig.cs (1)

1-44: LGTM — consistent with existing Resgrid.Config pattern.

Mutable public static fields are required by the reflection-based ConfigProcessor, so the deviation from the "immutable data where appropriate" guideline is acceptable here. One minor optional thought: the PreGeneratedPrompts literal on Line 38 is ~1.5 KB of English-only copy embedded in code — since this service is explicitly about multi-lingual support, consider extracting the default set to a resource/JSON file so non-English defaults can be shipped without a code change. Not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Core/Resgrid.Config/TtsConfig.cs` around lines 1 - 44, PreGeneratedPrompts is
a large English-only string embedded in TtsConfig (PreGeneratedPrompts) —
extract this default prompt set into an external resource (e.g., JSON or .resx)
and load it at startup so multilingual/default variants can be shipped without
re-compiling; update the TtsConfig class to read the external file into the
existing public static string PreGeneratedPrompts (or add a loader method) and
ensure ConfigProcessor still sets other fields as before, preserving the class
name TtsConfig and the PreGeneratedPrompts symbol for backward compatibility.
Web/Resgrid.Web.Tts/Services/CacheService.cs (1)

36-49: Avoid loading the full audio payload from Redis just to resolve a URL.

TryGetCachedAudioAsync on Line 38 performs a full IDistributedCache.GetAsync and DeserializeAudioContent — which pulls the entire WAV byte array over the wire from Redis and allocates it — only to discard the content and return a URL. For larger prompts under any non-trivial call volume this is wasted bandwidth/allocations on the hot path. Prefer a lightweight presence check (e.g., a separate small "exists" marker key, or relying solely on _storageService.ExistsAsync with a short-lived existence cache) so that URL resolution doesn't deserialize audio bytes.

♻️ Sketch
 public async Task<Uri?> TryGetCachedUrlAsync(TtsCacheKey cacheKey, CancellationToken cancellationToken)
 {
-    if (await TryGetCachedAudioAsync(cacheKey.Hash, cancellationToken) is not null)
-    {
-        return await _storageService.GetObjectUrlAsync(cacheKey.ObjectKey, cancellationToken);
-    }
-
-    if (!await _storageService.ExistsAsync(cacheKey.ObjectKey, cancellationToken))
+    // Cheap existence probe first (small marker key or storage head)
+    if (!await AudioExistsAsync(cacheKey, cancellationToken))
     {
         return null;
     }
 
     return await _storageService.GetObjectUrlAsync(cacheKey.ObjectKey, cancellationToken);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Services/CacheService.cs` around lines 36 - 49, Currently
TryGetCachedUrlAsync calls TryGetCachedAudioAsync (which pulls and deserializes
full WAV bytes) just to decide whether to return
_storageService.GetObjectUrlAsync; change TryGetCachedUrlAsync to avoid that
heavy call by performing a lightweight existence check instead — either call
_storageService.ExistsAsync(cacheKey.ObjectKey, cancellationToken) first or
check a small marker/exists key in the distributed cache (written when audio is
cached) instead of calling TryGetCachedAudioAsync(cacheKey.Hash, ...); keep the
final call to _storageService.GetObjectUrlAsync(cacheKey.ObjectKey,
cancellationToken) when the existence check succeeds and do not deserialize
audio bytes in this path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c07a0580-fec0-49cf-a251-11cba124ad09

📥 Commits

Reviewing files that changed from the base of the PR and between 8999854 and e2bc262.

⛔ Files ignored due to path filters (2)
  • Core/Resgrid.Localization/Areas/User/Department/Department.en.resx is excluded by !**/*.resx
  • Core/Resgrid.Localization/Areas/User/Department/Department.resx is excluded by !**/*.resx
📒 Files selected for processing (71)
  • .github/workflows/dotnet.yml
  • Core/Resgrid.Config/Resgrid.Config.csproj
  • Core/Resgrid.Config/TtsConfig.cs
  • Core/Resgrid.Model/DepartmentSettingTypes.cs
  • Core/Resgrid.Model/EspeakVoiceCatalog.cs
  • Core/Resgrid.Model/Services/ICommunicationTestService.cs
  • Core/Resgrid.Model/Services/IDepartmentSettingsService.cs
  • Core/Resgrid.Model/Services/ITtsAudioService.cs
  • Core/Resgrid.Model/TwilioVoicePromptCatalog.cs
  • Core/Resgrid.Services/CommunicationTestService.cs
  • Core/Resgrid.Services/DepartmentSettingsService.cs
  • Core/Resgrid.Services/ServicesModule.cs
  • Core/Resgrid.Services/TtsAudioService.cs
  • Resgrid.sln
  • Tests/Resgrid.Tests/Resgrid.Tests.csproj
  • Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs
  • Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs
  • Tests/Resgrid.Tests/Web/Services/TwilioVoiceResponseServiceTests.cs
  • Tests/Resgrid.Tests/Web/Tts/CacheServiceTests.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsControllerTests.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs
  • Web/Resgrid.Web.Services/Controllers/TwilioController.cs
  • Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs
  • Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs
  • Web/Resgrid.Web.Services/Startup.cs
  • Web/Resgrid.Web.Services/Twilio/ITwilioVoiceResponseService.cs
  • Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs
  • Web/Resgrid.Web.Tts/Configuration/RateLimitOptions.cs
  • Web/Resgrid.Web.Tts/Configuration/S3StorageOptions.cs
  • Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs
  • Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs
  • Web/Resgrid.Web.Tts/Controllers/TtsAdminController.cs
  • Web/Resgrid.Web.Tts/Controllers/TtsController.cs
  • Web/Resgrid.Web.Tts/Dockerfile
  • Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs
  • Web/Resgrid.Web.Tts/Models/StaticPromptRegenerationRequest.cs
  • Web/Resgrid.Web.Tts/Models/StaticPromptRegenerationResponse.cs
  • Web/Resgrid.Web.Tts/Models/TtsRequest.cs
  • Web/Resgrid.Web.Tts/Models/TtsResponse.cs
  • Web/Resgrid.Web.Tts/Program.cs
  • Web/Resgrid.Web.Tts/Properties/launchSettings.json
  • Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj
  • Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs
  • Web/Resgrid.Web.Tts/Services/CacheService.cs
  • Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs
  • Web/Resgrid.Web.Tts/Services/ICacheService.cs
  • Web/Resgrid.Web.Tts/Services/IStorageService.cs
  • Web/Resgrid.Web.Tts/Services/ITtsPlaybackUrlService.cs
  • Web/Resgrid.Web.Tts/Services/ITtsService.cs
  • Web/Resgrid.Web.Tts/Services/PromptWarmupHostedService.cs
  • Web/Resgrid.Web.Tts/Services/S3StorageService.cs
  • Web/Resgrid.Web.Tts/Services/TtsAudioContent.cs
  • Web/Resgrid.Web.Tts/Services/TtsCacheKey.cs
  • Web/Resgrid.Web.Tts/Services/TtsPlaybackUrlService.cs
  • Web/Resgrid.Web.Tts/Services/TtsService.cs
  • Web/Resgrid.Web.Tts/appsettings.Development.json
  • Web/Resgrid.Web.Tts/appsettings.json
  • Web/Resgrid.Web.Tts/k8s/deployment.yaml
  • Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs
  • Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs
  • Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml
  • Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js
  • Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js
  • Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js
  • Web/Resgrid.Web/wwwroot/js/ng/react-elements.css
  • Web/Resgrid.Web/wwwroot/js/ng/react-elements.js
  • Workers/Resgrid.Workers.Console/Commands/TtsStaticPromptRefreshCommand.cs
  • Workers/Resgrid.Workers.Console/Program.cs
  • Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs

Comment thread Core/Resgrid.Services/TtsAudioService.cs Outdated
Comment thread Core/Resgrid.Services/TtsAudioService.cs
Comment thread Web/Resgrid.Web.Services/Controllers/TwilioController.cs Outdated
Comment thread Web/Resgrid.Web.Tts/Services/PromptWarmupHostedService.cs
Comment thread Web/Resgrid.Web.Tts/Services/S3StorageService.cs
Comment thread Web/Resgrid.Web.Tts/Services/TtsPlaybackUrlService.cs Outdated
Comment thread Web/Resgrid.Web.Tts/Services/TtsService.cs
Comment thread Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (8)
Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs (1)

79-79: Minor: Length shouldn't be readable on a non-seekable stream.

Conventionally, Stream.Length throws NotSupportedException when CanSeek is false (see FileStream, NetworkStream, etc.). Exposing _inner.Length here makes the test double a weaker simulation of a non-seekable stream — if production code ever gated on Length, this test would silently pass despite the real-world contract differing. Not exercised by the current UploadAsync path (which only calls CopyToAsync), so this is purely a fidelity nit.

♻️ Optional tightening
-			public override long Length => _inner.Length;
+			public override long Length => throw new NotSupportedException();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs` at line 79, The test
stream currently exposes its Length via "public override long Length =>
_inner.Length;", which violates the non-seekable stream contract; change the
overridden Length getter on the test double to throw NotSupportedException when
CanSeek is false (keep CanSeek returning false) instead of returning
_inner.Length so the fake better matches real non-seekable streams and prevents
tests from masking production behavior (refer to the overridden Length property
and the CanSeek implementation in the test stream).
Tests/Resgrid.Tests/Web/Tts/TtsPlaybackUrlServiceTests.cs (1)

11-57: Consider adding coverage for the missing-base-URL branch.

The tests exercise normalization, delimiter rejection, and request-derived base URL resolution, but don't cover the InvalidOperationException path when both PlaybackBaseUrl is empty and request is null (or Host has no value). A small additional test would close the remaining branch in CreatePlaybackUrl.

Proposed additional test
[Test]
public void create_playback_url_should_throw_when_base_url_cannot_be_resolved()
{
    var service = new TtsPlaybackUrlService(Options.Create(new TtsOptions()));

    FluentActions
        .Invoking(() => service.CreatePlaybackUrl(null, "abc123"))
        .Should()
        .Throw<InvalidOperationException>();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/Resgrid.Tests/Web/Tts/TtsPlaybackUrlServiceTests.cs` around lines 11 -
57, Tests are missing coverage for the branch in
TtsPlaybackUrlService.CreatePlaybackUrl that throws InvalidOperationException
when no PlaybackBaseUrl is configured and no request/Host is available; add a
unit test that constructs TtsPlaybackUrlService(Options.Create(new
TtsOptions())) and asserts that invoking CreatePlaybackUrl(null, "abc123")
throws InvalidOperationException (use
FluentActions.Invoking...Should().Throw<InvalidOperationException>()), which
will close the untested branch.
Tests/Resgrid.Tests/Web/Tts/PromptWarmupHostedServiceTests.cs (1)

46-50: Consider invoking via StartAsync instead of reflection.

PromptWarmupHostedService derives from BackgroundService, which exposes a public StartAsync(CancellationToken) that internally calls ExecuteAsync. Using it avoids brittle reflection (which would silently break if the base type or method signature changes) and exercises the same public entry point the host uses at runtime.

♻️ Proposed refactor
-			await InvokeExecuteAsync(service, CancellationToken.None);
+			await service.StartAsync(CancellationToken.None);
+			await service.StopAsync(CancellationToken.None);
@@
-		private static Task InvokeExecuteAsync(PromptWarmupHostedService service, CancellationToken cancellationToken)
-		{
-			var method = typeof(PromptWarmupHostedService).GetMethod("ExecuteAsync", BindingFlags.Instance | BindingFlags.NonPublic);
-			return (Task)method!.Invoke(service, new object[] { cancellationToken })!;
-		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/Resgrid.Tests/Web/Tts/PromptWarmupHostedServiceTests.cs` around lines
46 - 50, The test helper InvokeExecuteAsync uses reflection to call the
non-public ExecuteAsync on PromptWarmupHostedService; replace this brittle
reflection call by invoking the public StartAsync(CancellationToken) on the
PromptWarmupHostedService instance so the test exercises the same host entry
point. Update the test to call service.StartAsync(cancellationToken) (and await
StopAsync when needed) instead of using
typeof(PromptWarmupHostedService).GetMethod("ExecuteAsync") and method.Invoke,
keeping the same CancellationToken handling and ensuring any cleanup uses
StopAsync if the original test relied on stopping the background task.
Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs (1)

71-86: Cache the Regex instances.

Regex.Replace and Regex.Split at Lines 76 and 85 are called on every prompt on the voice hot path. Hoisting them to static readonly Regex (or [GeneratedRegex] with a source-generated matcher) avoids pattern recompilation and cuts allocations.

🛠️ Proposed fix
+		private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
+		private static readonly Regex SentenceSplitRegex = new(@"(?<=[\.\!\?])\s+", RegexOptions.Compiled);
@@
-			var normalized = Regex.Replace(text, @"\s+", " ").Trim();
+			var normalized = WhitespaceRegex.Replace(text, " ").Trim();
@@
-			var sentences = Regex.Split(normalized, @"(?<=[\.\!\?])\s+")
+			var sentences = SentenceSplitRegex.Split(normalized)
 				.Where(sentence => !string.IsNullOrWhiteSpace(sentence));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs` around lines
71 - 86, ChunkText currently calls Regex.Replace and Regex.Split on every
invocation which recompiles patterns and allocates; hoist those patterns into
reusable regexes and use them instead. Add static readonly Regex fields (e.g.,
NormalizedWhitespaceRegex = new Regex(@"\s+", RegexOptions.Compiled |
RegexOptions.CultureInvariant) and SentenceSplitRegex = new
Regex(@"(?<=[\.\!\?])\s+", RegexOptions.Compiled |
RegexOptions.CultureInvariant)) or replace with [GeneratedRegex]
source-generated matchers, then change ChunkText to call
NormalizedWhitespaceRegex.Replace(...) and SentenceSplitRegex.Split(...) (keep
existing trimming and filtering logic).
Tests/Resgrid.Tests/Web/Services/CommunicationTestResponseControllerTests.cs (1)

69-80: Avoid reflecting on a private method; assert behavior via the public webhook.

Invoking GetDepartmentTtsLanguageAsync via reflection couples the test to a private symbol name and signature. The same blank-token behavior can be verified by calling VoiceWebhook("", "1") (or a whitespace token) and asserting GetDepartmentIdByResponseTokenAsync/GetTtsLanguageForDepartmentAsync are never invoked, which already appears to be the intended contract of the first test.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/Resgrid.Tests/Web/Services/CommunicationTestResponseControllerTests.cs`
around lines 69 - 80, The test currently reflects on the private method
GetDepartmentTtsLanguageAsync which couples the spec to an internal symbol;
instead call the public endpoint VoiceWebhook with a blank or whitespace token
(e.g. VoiceWebhook(" ", "1")) to exercise the same behavior, await the public
call's result, and assert that the returned value matches the expected
null/empty response while verifying
_communicationTestServiceMock.GetDepartmentIdByResponseTokenAsync and
_departmentSettingsServiceMock.GetTtsLanguageForDepartmentAsync were never
invoked; remove the reflection usage and reference the public VoiceWebhook call
and the two mock verifications (GetDepartmentIdByResponseTokenAsync,
GetTtsLanguageForDepartmentAsync) in the updated test.
Web/Resgrid.Web.Services/Controllers/TwilioController.cs (1)

917-934: Minor: template Gather is awkward.

The gatherResponse built at Lines 890/911/919 is only used to carry Action/Method into the two repeat iterations, which then construct fresh Gathers. Extracting the action URI and method into local variables would avoid the "dummy" Gather and make intent clearer.

🛠️ Proposed sketch
-			Gather gatherResponse = null;
+			Uri gatherAction = null;
+			const string gatherMethod = "GET";
@@
-			else if (twilioRequest.Digits == "6") // Set current status
+			else if (twilioRequest.Digits == "6") // Set current status
 			{
 				...
-				gatherResponse = new Gather(numDigits: 1, action: new Uri($"{...}/InboundVoiceActionStatus?userId={userId}"), method: "GET")
-				{
-					BargeIn = true
-				};
+				gatherAction = new Uri($"{...}/InboundVoiceActionStatus?userId={userId}");
 			}
 			...
-			if (gatherResponse == null)
-			{
-				gatherResponse = new Gather(numDigits: 1, action: new Uri($"{...}/InboundVoiceAction?userId={userId}"), method: "GET")
-				{
-					BargeIn = true
-				};
-			}
+			gatherAction ??= new Uri($"{...}/InboundVoiceAction?userId={userId}");

 			for (int repeat = 0; repeat < 2; repeat++)
 			{
-				var gather = new Gather(numDigits: 1, action: gatherResponse.Action, method: gatherResponse.Method)
+				var gather = new Gather(numDigits: 1, action: gatherAction, method: gatherMethod)
 				{
 					BargeIn = true
 				};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Services/Controllers/TwilioController.cs` around lines 917 -
934, The current code creates a dummy Gather (gatherResponse) only to pass its
Action/Method into the two loop-created Gathers; replace that by extracting the
action URI and method into locals (e.g., actionUri and httpMethod) computed the
same way as gatherResponse.Action/Method using
Config.SystemBehaviorConfig.ResgridApiBaseUrl and userId, then construct each
new Gather inside the loop with those locals (preserving BargeIn = true) and
keep calls to AppendVoicePromptsAsync, AppendVoicePromptAsync, and
response.Append unchanged (look for TwilioController, gatherResponse,
AppendVoicePromptsAsync, AppendVoicePromptAsync).
Core/Resgrid.Services/TtsAudioService.cs (1)

92-107: Consider records for the private DTOs.

GenerateSpeechRequest, GenerateSpeechResponse, and RegenerateStaticPromptsRequest are pure data carriers. Converting them to property-init records (e.g., record GenerateSpeechRequest { string Text { get; init; } }) would align with the guideline to use records for state while maintaining immutability. Property-init records serialize correctly with Newtonsoft.Json since serialization is property-based.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Core/Resgrid.Services/TtsAudioService.cs` around lines 92 - 107, Replace the
three private DTO classes GenerateSpeechRequest, GenerateSpeechResponse, and
RegenerateStaticPromptsRequest with property-init records to make them immutable
data carriers (e.g., use record types with init-only properties or positional
records) so serialization with Newtonsoft.Json still works; update
GenerateSpeechRequest to expose Text, Voice, Speed as init properties and
RegenerateStaticPromptsRequest to expose Prompts as an init
List<GenerateSpeechRequest>, and remove the sealed class declarations
accordingly.
Web/Resgrid.Web.Tts/Services/TtsService.cs (1)

190-202: Use ArgumentNullException for the null‑request case.

NormalizeRequest throws ArgumentException when request is null; the idiomatic choice is ArgumentNullException.ThrowIfNull(request) (mirroring GenerateBatchAsync on line 40). It also lets callers distinguish null from other invalid payloads (e.g., blank text).

♻️ Proposed tweak
 		private NormalizedTtsRequest NormalizeRequest(TtsRequest? request)
 		{
-			if (request is null)
-			{
-				throw new ArgumentException("A TTS request payload is required.", nameof(request));
-			}
+			ArgumentNullException.ThrowIfNull(request);
 
 			var text = request.Text?.Trim();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web.Tts/Services/TtsService.cs` around lines 190 - 202, Replace
the manual null check in NormalizeRequest with the standard null-argument
pattern: call ArgumentNullException.ThrowIfNull(request) at the top of
NormalizeRequest instead of throwing new ArgumentException("A TTS request
payload is required.", nameof(request)); keep the subsequent text trimming and
whitespace validation as-is so blank text still throws the ArgumentException;
this mirrors the usage in GenerateBatchAsync and makes the null case distinct
via ArgumentNullException.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Core/Resgrid.Services/TtsAudioService.cs`:
- Around line 38-41: The code currently constructs a Uri with new
Uri(response.Data.Url, UriKind.Absolute) which can throw UriFormatException on
malformed or non-absolute URLs; update the guard in the method that returns the
Uri (around the response handling in TtsAudioService) to use
Uri.TryCreate(response.Data.Url, UriKind.Absolute, out var uri) and, if it
returns false, throw the same InvalidOperationException via
CreateRequestFailure("generate speech audio", response) so all failure modes
surface the consistent exception type; keep the existing checks for
response.IsSuccessful and null/whitespace before attempting TryCreate.

In `@Tests/Resgrid.Tests/Resgrid.Tests.csproj`:
- Line 59: The test project targets net9.0 while the referenced Resgrid.Web.Tts
project targets net8.0, causing framework mismatch; fix by either changing the
Tests/Resgrid.Tests.csproj TargetFramework to net8.0 so tests run against the
same runtime as Resgrid.Web.Tts, or update Resgrid.Web.Tts.csproj to
multi-target (TargetFrameworks) to include net9.0 (e.g., net8.0;net9.0) so both
frameworks are available for the ProjectReference; after making the change,
restore and rebuild to ensure the TTS tests in Tests/Resgrid.Tests/Web/Tts/ run
against the intended framework.

In `@Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs`:
- Around line 56-58: The test is asserting the wrong IPv6 prefix length after
TtsRequestIdentity.AddKnownNetwork was changed to use IPNetwork(MapToIPv6(),
prefixLength + 96); update the expectation in TtsRequestIdentityTests so the
assertion that matches the IPv6-mapped network
(IPAddress.Parse("::ffff:10.42.0.0")) checks for a PrefixLength of 112 (i.e. 16
+ 96) rather than 16; locate the assertion lines in TtsRequestIdentityTests.cs
that reference IPAddress.Parse("::ffff:10.42.0.0") and change the expected
PrefixLength accordingly.

In
`@Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs`:
- Around line 19-48: The test fixture TtsStaticPromptRefreshTaskTests mutates
shared static state (Resgrid.Workers.Framework.Bootstrapper._container and
TtsConfig.ServiceBaseUrl/StaticPromptAdminKey) which can cause flaky failures if
NUnit runs tests in parallel; mark the fixture as non-parallelizable by adding
the NUnit [NonParallelizable] attribute to the TtsStaticPromptRefreshTaskTests
class (or alternatively apply [assembly: NonParallelizable] at the test
assembly) so tests that touch Bootstrapper._container and TtsConfig.* run
sequentially and avoid cross-test interference.

---

Nitpick comments:
In `@Core/Resgrid.Services/TtsAudioService.cs`:
- Around line 92-107: Replace the three private DTO classes
GenerateSpeechRequest, GenerateSpeechResponse, and
RegenerateStaticPromptsRequest with property-init records to make them immutable
data carriers (e.g., use record types with init-only properties or positional
records) so serialization with Newtonsoft.Json still works; update
GenerateSpeechRequest to expose Text, Voice, Speed as init properties and
RegenerateStaticPromptsRequest to expose Prompts as an init
List<GenerateSpeechRequest>, and remove the sealed class declarations
accordingly.

In
`@Tests/Resgrid.Tests/Web/Services/CommunicationTestResponseControllerTests.cs`:
- Around line 69-80: The test currently reflects on the private method
GetDepartmentTtsLanguageAsync which couples the spec to an internal symbol;
instead call the public endpoint VoiceWebhook with a blank or whitespace token
(e.g. VoiceWebhook(" ", "1")) to exercise the same behavior, await the public
call's result, and assert that the returned value matches the expected
null/empty response while verifying
_communicationTestServiceMock.GetDepartmentIdByResponseTokenAsync and
_departmentSettingsServiceMock.GetTtsLanguageForDepartmentAsync were never
invoked; remove the reflection usage and reference the public VoiceWebhook call
and the two mock verifications (GetDepartmentIdByResponseTokenAsync,
GetTtsLanguageForDepartmentAsync) in the updated test.

In `@Tests/Resgrid.Tests/Web/Tts/PromptWarmupHostedServiceTests.cs`:
- Around line 46-50: The test helper InvokeExecuteAsync uses reflection to call
the non-public ExecuteAsync on PromptWarmupHostedService; replace this brittle
reflection call by invoking the public StartAsync(CancellationToken) on the
PromptWarmupHostedService instance so the test exercises the same host entry
point. Update the test to call service.StartAsync(cancellationToken) (and await
StopAsync when needed) instead of using
typeof(PromptWarmupHostedService).GetMethod("ExecuteAsync") and method.Invoke,
keeping the same CancellationToken handling and ensuring any cleanup uses
StopAsync if the original test relied on stopping the background task.

In `@Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs`:
- Line 79: The test stream currently exposes its Length via "public override
long Length => _inner.Length;", which violates the non-seekable stream contract;
change the overridden Length getter on the test double to throw
NotSupportedException when CanSeek is false (keep CanSeek returning false)
instead of returning _inner.Length so the fake better matches real non-seekable
streams and prevents tests from masking production behavior (refer to the
overridden Length property and the CanSeek implementation in the test stream).

In `@Tests/Resgrid.Tests/Web/Tts/TtsPlaybackUrlServiceTests.cs`:
- Around line 11-57: Tests are missing coverage for the branch in
TtsPlaybackUrlService.CreatePlaybackUrl that throws InvalidOperationException
when no PlaybackBaseUrl is configured and no request/Host is available; add a
unit test that constructs TtsPlaybackUrlService(Options.Create(new
TtsOptions())) and asserts that invoking CreatePlaybackUrl(null, "abc123")
throws InvalidOperationException (use
FluentActions.Invoking...Should().Throw<InvalidOperationException>()), which
will close the untested branch.

In `@Web/Resgrid.Web.Services/Controllers/TwilioController.cs`:
- Around line 917-934: The current code creates a dummy Gather (gatherResponse)
only to pass its Action/Method into the two loop-created Gathers; replace that
by extracting the action URI and method into locals (e.g., actionUri and
httpMethod) computed the same way as gatherResponse.Action/Method using
Config.SystemBehaviorConfig.ResgridApiBaseUrl and userId, then construct each
new Gather inside the loop with those locals (preserving BargeIn = true) and
keep calls to AppendVoicePromptsAsync, AppendVoicePromptAsync, and
response.Append unchanged (look for TwilioController, gatherResponse,
AppendVoicePromptsAsync, AppendVoicePromptAsync).

In `@Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs`:
- Around line 71-86: ChunkText currently calls Regex.Replace and Regex.Split on
every invocation which recompiles patterns and allocates; hoist those patterns
into reusable regexes and use them instead. Add static readonly Regex fields
(e.g., NormalizedWhitespaceRegex = new Regex(@"\s+", RegexOptions.Compiled |
RegexOptions.CultureInvariant) and SentenceSplitRegex = new
Regex(@"(?<=[\.\!\?])\s+", RegexOptions.Compiled |
RegexOptions.CultureInvariant)) or replace with [GeneratedRegex]
source-generated matchers, then change ChunkText to call
NormalizedWhitespaceRegex.Replace(...) and SentenceSplitRegex.Split(...) (keep
existing trimming and filtering logic).

In `@Web/Resgrid.Web.Tts/Services/TtsService.cs`:
- Around line 190-202: Replace the manual null check in NormalizeRequest with
the standard null-argument pattern: call
ArgumentNullException.ThrowIfNull(request) at the top of NormalizeRequest
instead of throwing new ArgumentException("A TTS request payload is required.",
nameof(request)); keep the subsequent text trimming and whitespace validation
as-is so blank text still throws the ArgumentException; this mirrors the usage
in GenerateBatchAsync and makes the null case distinct via
ArgumentNullException.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f97f92ae-73dc-46c5-93af-f4523887da40

📥 Commits

Reviewing files that changed from the base of the PR and between e2bc262 and 1d7e049.

📒 Files selected for processing (25)
  • Core/Resgrid.Services/ServicesModule.cs
  • Core/Resgrid.Services/TtsAudioService.cs
  • Tests/Resgrid.Tests/Resgrid.Tests.csproj
  • Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs
  • Tests/Resgrid.Tests/Web/Services/CommunicationTestResponseControllerTests.cs
  • Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs
  • Tests/Resgrid.Tests/Web/Services/TwilioVoiceResponseServiceTests.cs
  • Tests/Resgrid.Tests/Web/Tts/PromptWarmupHostedServiceTests.cs
  • Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsPlaybackUrlServiceTests.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs
  • Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs
  • Web/Resgrid.Web.Services/Controllers/TwilioController.cs
  • Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs
  • Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs
  • Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs
  • Web/Resgrid.Web.Tts/Configuration/TtsRequestIdentity.cs
  • Web/Resgrid.Web.Tts/Program.cs
  • Web/Resgrid.Web.Tts/Services/PromptWarmupHostedService.cs
  • Web/Resgrid.Web.Tts/Services/S3StorageService.cs
  • Web/Resgrid.Web.Tts/Services/TtsPlaybackUrlService.cs
  • Web/Resgrid.Web.Tts/Services/TtsService.cs
  • Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs
✅ Files skipped from review due to trivial changes (1)
  • Web/Resgrid.Web.Tts/Services/S3StorageService.cs
🚧 Files skipped from review as they are similar to previous changes (6)
  • Web/Resgrid.Web.Tts/Services/PromptWarmupHostedService.cs
  • Core/Resgrid.Services/ServicesModule.cs
  • Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs
  • Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs
  • Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs

Comment on lines +38 to +41
if (!response.IsSuccessful || response.Data == null || string.IsNullOrWhiteSpace(response.Data.Url))
throw CreateRequestFailure("generate speech audio", response);

return new Uri(response.Data.Url, UriKind.Absolute);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent exception type on malformed URL.

The guard at Line 38 only checks for null/whitespace. If the TTS service returns a non-absolute or otherwise malformed URL, new Uri(response.Data.Url, UriKind.Absolute) throws UriFormatException instead of the InvalidOperationException callers see for other failure modes. Use Uri.TryCreate and surface the standard failure exception.

🛠️ Proposed fix
-			if (!response.IsSuccessful || response.Data == null || string.IsNullOrWhiteSpace(response.Data.Url))
-				throw CreateRequestFailure("generate speech audio", response);
-
-			return new Uri(response.Data.Url, UriKind.Absolute);
+			if (!response.IsSuccessful || response.Data == null || string.IsNullOrWhiteSpace(response.Data.Url))
+				throw CreateRequestFailure("generate speech audio", response);
+
+			if (!Uri.TryCreate(response.Data.Url, UriKind.Absolute, out var audioUrl))
+				throw CreateRequestFailure("generate speech audio", response);
+
+			return audioUrl;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!response.IsSuccessful || response.Data == null || string.IsNullOrWhiteSpace(response.Data.Url))
throw CreateRequestFailure("generate speech audio", response);
return new Uri(response.Data.Url, UriKind.Absolute);
if (!response.IsSuccessful || response.Data == null || string.IsNullOrWhiteSpace(response.Data.Url))
throw CreateRequestFailure("generate speech audio", response);
if (!Uri.TryCreate(response.Data.Url, UriKind.Absolute, out var audioUrl))
throw CreateRequestFailure("generate speech audio", response);
return audioUrl;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Core/Resgrid.Services/TtsAudioService.cs` around lines 38 - 41, The code
currently constructs a Uri with new Uri(response.Data.Url, UriKind.Absolute)
which can throw UriFormatException on malformed or non-absolute URLs; update the
guard in the method that returns the Uri (around the response handling in
TtsAudioService) to use Uri.TryCreate(response.Data.Url, UriKind.Absolute, out
var uri) and, if it returns false, throw the same InvalidOperationException via
CreateRequestFailure("generate speech audio", response) so all failure modes
surface the consistent exception type; keep the existing checks for
response.IsSuccessful and null/whitespace before attempting TryCreate.

<ProjectReference Include="..\..\Repositories\Resgrid.Repositories.NoSqlRepository\Resgrid.Repositories.NoSqlRepository.csproj" />
<ProjectReference Include="..\..\Web\Resgrid.Web.Services\Resgrid.Web.Services.csproj" />
<ProjectReference Include="..\..\Web\Resgrid.Web.Mcp\Resgrid.Web.Mcp.csproj" />
<ProjectReference Include="..\..\Web\Resgrid.Web.Tts\Resgrid.Web.Tts.csproj" />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm TFMs of both projects and scan for any other references to Resgrid.Web.Tts
fd -t f 'Resgrid.Web.Tts.csproj' --exec cat {}
echo "---"
fd -t f 'Resgrid.Workers.Console.csproj' --exec cat {}
echo "---"
rg -n 'Resgrid\.Web\.Tts' -g '*.csproj' -g '*.sln'

Repository: Resgrid/Core

Length of output: 5959


🏁 Script executed:

# Check the test project's target framework
head -20 Tests/Resgrid.Tests/Resgrid.Tests.csproj

Repository: Resgrid/Core

Length of output: 918


🏁 Script executed:

# Search for usages of Resgrid.Web.Tts in the test project
rg -l 'Resgrid\.Web\.Tts' Tests/Resgrid.Tests/ --type cs | head -20

Repository: Resgrid/Core

Length of output: 549


Target framework mismatch with Resgrid.Web.Tts — tests should run against the same framework as production.

This test project targets net9.0, while Resgrid.Web.Tts targets net8.0. The nine TTS-specific tests in Tests/Resgrid.Tests/Web/Tts/ exercise the TTS service directly, meaning they run against net9.0 assemblies even though the service is designed for net8.0. If the TTS service is deployed as net8.0 in production, the tests may not catch net8.0-specific issues or behavior differences. Consider multi-targeting Resgrid.Web.Tts to include net9.0, or aligning the test project to net8.0, to ensure tests validate the actual production framework.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/Resgrid.Tests/Resgrid.Tests.csproj` at line 59, The test project
targets net9.0 while the referenced Resgrid.Web.Tts project targets net8.0,
causing framework mismatch; fix by either changing the
Tests/Resgrid.Tests.csproj TargetFramework to net8.0 so tests run against the
same runtime as Resgrid.Web.Tts, or update Resgrid.Web.Tts.csproj to
multi-target (TargetFrameworks) to include net9.0 (e.g., net8.0;net9.0) so both
frameworks are available for the ProjectReference; after making the change,
restore and rebuild to ensure the TTS tests in Tests/Resgrid.Tests/Web/Tts/ run
against the intended framework.

Comment on lines +56 to +58
knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("10.42.0.0")) && x.PrefixLength == 16);
knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("::ffff:10.42.0.0")) && x.PrefixLength == 16);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Test locks in the incorrect IPv6 prefix length.

After the IPNetwork(MapToIPv6(), prefixLength + 96) fix in TtsRequestIdentity.AddKnownNetwork, update this assertion to the IPv6‑correct value:

💚 Proposed fix
-			knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("::ffff:10.42.0.0")) && x.PrefixLength == 16);
+			knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("::ffff:10.42.0.0")) && x.PrefixLength == 112);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("10.42.0.0")) && x.PrefixLength == 16);
knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("::ffff:10.42.0.0")) && x.PrefixLength == 16);
}
knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("10.42.0.0")) && x.PrefixLength == 16);
knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("::ffff:10.42.0.0")) && x.PrefixLength == 112);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs` around lines 56 - 58,
The test is asserting the wrong IPv6 prefix length after
TtsRequestIdentity.AddKnownNetwork was changed to use IPNetwork(MapToIPv6(),
prefixLength + 96); update the expectation in TtsRequestIdentityTests so the
assertion that matches the IPv6-mapped network
(IPAddress.Parse("::ffff:10.42.0.0")) checks for a PrefixLength of 112 (i.e. 16
+ 96) rather than 16; locate the assertion lines in TtsRequestIdentityTests.cs
that reference IPAddress.Parse("::ffff:10.42.0.0") and change the expected
PrefixLength accordingly.

Comment on lines +19 to +48
[TestFixture]
public class TtsStaticPromptRefreshTaskTests
{
private static readonly FieldInfo WorkerBootstrapperContainerField = typeof(Resgrid.Workers.Framework.Bootstrapper)
.GetField("_container", BindingFlags.Static | BindingFlags.NonPublic)!;

private IContainer _originalWorkerContainer;
private IContainer _testWorkerContainer;
private string _originalServiceBaseUrl;
private string _originalStaticPromptAdminKey;

[SetUp]
public void SetUp()
{
_originalWorkerContainer = WorkerBootstrapperContainerField.GetValue(null) as IContainer;
_originalServiceBaseUrl = TtsConfig.ServiceBaseUrl;
_originalStaticPromptAdminKey = TtsConfig.StaticPromptAdminKey;

TtsConfig.ServiceBaseUrl = "https://tts.example.com";
TtsConfig.StaticPromptAdminKey = "prompt-admin-key";
}

[TearDown]
public void TearDown()
{
WorkerBootstrapperContainerField.SetValue(null, _originalWorkerContainer);
_testWorkerContainer?.Dispose();
TtsConfig.ServiceBaseUrl = _originalServiceBaseUrl;
TtsConfig.StaticPromptAdminKey = _originalStaticPromptAdminKey;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against cross-test interference on static state.

This fixture mutates static state on Resgrid.Workers.Framework.Bootstrapper._container and TtsConfig.ServiceBaseUrl/StaticPromptAdminKey. If NUnit parallelization is enabled anywhere (assembly/namespace/class level), another fixture touching those statics concurrently will cause flaky failures. Mark the fixture (or the assembly) as non-parallelizable to make that constraint explicit.

🛠️ Proposed fix
 	[TestFixture]
+	[NonParallelizable]
 	public class TtsStaticPromptRefreshTaskTests
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[TestFixture]
public class TtsStaticPromptRefreshTaskTests
{
private static readonly FieldInfo WorkerBootstrapperContainerField = typeof(Resgrid.Workers.Framework.Bootstrapper)
.GetField("_container", BindingFlags.Static | BindingFlags.NonPublic)!;
private IContainer _originalWorkerContainer;
private IContainer _testWorkerContainer;
private string _originalServiceBaseUrl;
private string _originalStaticPromptAdminKey;
[SetUp]
public void SetUp()
{
_originalWorkerContainer = WorkerBootstrapperContainerField.GetValue(null) as IContainer;
_originalServiceBaseUrl = TtsConfig.ServiceBaseUrl;
_originalStaticPromptAdminKey = TtsConfig.StaticPromptAdminKey;
TtsConfig.ServiceBaseUrl = "https://tts.example.com";
TtsConfig.StaticPromptAdminKey = "prompt-admin-key";
}
[TearDown]
public void TearDown()
{
WorkerBootstrapperContainerField.SetValue(null, _originalWorkerContainer);
_testWorkerContainer?.Dispose();
TtsConfig.ServiceBaseUrl = _originalServiceBaseUrl;
TtsConfig.StaticPromptAdminKey = _originalStaticPromptAdminKey;
}
[TestFixture]
[NonParallelizable]
public class TtsStaticPromptRefreshTaskTests
{
private static readonly FieldInfo WorkerBootstrapperContainerField = typeof(Resgrid.Workers.Framework.Bootstrapper)
.GetField("_container", BindingFlags.Static | BindingFlags.NonPublic)!;
private IContainer _originalWorkerContainer;
private IContainer _testWorkerContainer;
private string _originalServiceBaseUrl;
private string _originalStaticPromptAdminKey;
[SetUp]
public void SetUp()
{
_originalWorkerContainer = WorkerBootstrapperContainerField.GetValue(null) as IContainer;
_originalServiceBaseUrl = TtsConfig.ServiceBaseUrl;
_originalStaticPromptAdminKey = TtsConfig.StaticPromptAdminKey;
TtsConfig.ServiceBaseUrl = "https://tts.example.com";
TtsConfig.StaticPromptAdminKey = "prompt-admin-key";
}
[TearDown]
public void TearDown()
{
WorkerBootstrapperContainerField.SetValue(null, _originalWorkerContainer);
_testWorkerContainer?.Dispose();
TtsConfig.ServiceBaseUrl = _originalServiceBaseUrl;
TtsConfig.StaticPromptAdminKey = _originalStaticPromptAdminKey;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs`
around lines 19 - 48, The test fixture TtsStaticPromptRefreshTaskTests mutates
shared static state (Resgrid.Workers.Framework.Bootstrapper._container and
TtsConfig.ServiceBaseUrl/StaticPromptAdminKey) which can cause flaky failures if
NUnit runs tests in parallel; mark the fixture as non-parallelizable by adding
the NUnit [NonParallelizable] attribute to the TtsStaticPromptRefreshTaskTests
class (or alternatively apply [assembly: NonParallelizable] at the test
assembly) so tests that touch Bootstrapper._container and TtsConfig.* run
sequentially and avoid cross-test interference.

@ucswift
Copy link
Copy Markdown
Member Author

ucswift commented Apr 23, 2026

Approve

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is approved.

@ucswift ucswift merged commit ccd1d6f into master Apr 23, 2026
16 of 19 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants