Compare commits

..

2 Commits

Author SHA1 Message Date
Jacob Dubin
2b16a5020c Jibo planning 2026-05-04 21:52:31 -05:00
Jacob Dubin
573911de0f added more logging around $YESNO so we can get more consistent yes and no replies processed.... they are spotty currently 2026-05-03 22:42:41 -05:00
9 changed files with 204 additions and 41 deletions

View File

@@ -33,6 +33,7 @@ Release `1.0.18` is now in feature-hardening. Its main bug-fix theme is alarm an
- Test 30 showed `cloud version` speaking cleanly with no interruption. The backup warning later appeared after opening gallery from the menu: gallery asked the empty-gallery photo question, then stock BE opened `@be/surprises`, selected `@be/surprises-ota`, and spoke the local backup announcement. The captured HTTP traffic still did not show hosted `Backup_*` calls. - Test 30 showed `cloud version` speaking cleanly with no interruption. The backup warning later appeared after opening gallery from the menu: gallery asked the empty-gallery photo question, then stock BE opened `@be/surprises`, selected `@be/surprises-ota`, and spoke the local backup announcement. The captured HTTP traffic still did not show hosted `Backup_*` calls.
- Test 31 sharpened the remaining alarm/back-up picture: the startup capture includes a legacy `Backup_20170222.List` request before any voice turn, the alarm set path still collapsed `7:11 AM` into `7:00 PM` / `setting alarm for seven`, and the later clock `No` replied `that's fine` before the robot opened `@be/surprises` and eventually got stuck in a blue-ring listen loop until reset. - Test 31 sharpened the remaining alarm/back-up picture: the startup capture includes a legacy `Backup_20170222.List` request before any voice turn, the alarm set path still collapsed `7:11 AM` into `7:00 PM` / `setting alarm for seven`, and the later clock `No` replied `that's fine` before the robot opened `@be/surprises` and eventually got stuck in a blue-ring listen loop until reset.
- Test 32 shows the alarm set path is better, but two cleanup gaps remain in the newer-code window: the alarm flow can still leave a listen open at the end, and the proactive Word of the Day yes/no branch can miss a short `Yes` and bounce into a mock/echo response. The delete-alarm retry case also still asks whether to set an alarm again, then mishandles the follow-up yes/no reply. - Test 32 shows the alarm set path is better, but two cleanup gaps remain in the newer-code window: the alarm flow can still leave a listen open at the end, and the proactive Word of the Day yes/no branch can miss a short `Yes` and bounce into a mock/echo response. The delete-alarm retry case also still asks whether to set an alarm again, then mishandles the follow-up yes/no reply.
- The websocket turn telemetry now emits compact snapshots for `binary_audio_received`, `binary_audio_ignored`, `yes_no_turn_received`, `yes_no_turn_resolved`, and `yes_no_no_input`, so the next live pass can prove whether the yes/no rule survived buffering and finalization.
- Test 30 showed the alarm value reply `638` arrived at 6:38:13 AM local. Stock clock parsed that as `6:38 PM`, and our cloud response then added a delayed `@be/clock` relaunch on top of the active local clock value flow, causing the duplicate existing-alarm replacement prompt. Current source now suppresses the extra clock relaunch for local clock follow-up rules. - Test 30 showed the alarm value reply `638` arrived at 6:38:13 AM local. Stock clock parsed that as `6:38 PM`, and our cloud response then added a delayed `@be/clock` relaunch on top of the active local clock value flow, causing the duplicate existing-alarm replacement prompt. Current source now suppresses the extra clock relaunch for local clock follow-up rules.
- Backup-in-progress still appears robot-local in the user-facing voice flow. Tests 27, 28, 29, and 30 had no matching `Backup_*` HTTP calls during the voice prompt itself. Keep investigating robot-local scheduler/status, startup reconnect state, CPU/load, and log/upload work if backup status itself remains sluggish after surprise suppression. - Backup-in-progress still appears robot-local in the user-facing voice flow. Tests 27, 28, 29, and 30 had no matching `Backup_*` HTTP calls during the voice prompt itself. Keep investigating robot-local scheduler/status, startup reconnect state, CPU/load, and log/upload work if backup status itself remains sluggish after surprise suppression.
- Test 26 remains the broader regression evidence for gallery success, alarm replacement/delete risk, stop/volume live proof, and short-answer STT weakness. Alarm replacement/menu agreement is still a live release risk, but Test 30 identified and patched one duplicate-handoff cause. - Test 26 remains the broader regression evidence for gallery success, alarm replacement/delete risk, stop/volume live proof, and short-answer STT weakness. Alarm replacement/menu agreement is still a live release risk, but Test 30 identified and patched one duplicate-handoff cause.

View File

@@ -610,10 +610,22 @@ Use [regression-test-plan.md](regression-test-plan.md) as the detailed checklist
For `1.0.19`: For `1.0.19`:
1. Harden stop or volume if the `1.0.18` live pass exposes stock-OS quirks; otherwise pick robot age/persona or another lightweight slice 1. Harden stop or volume if the `1.0.18` live pass exposes stock-OS quirks / harden $YESNO interaction
2. Update, backup, and restore proof 2. Make a pizza. How old are you? When's your birthday? Do you have a personality? (This is a fun one that can be implemented quickly and adds a lot of character, so it should be early in the queue to start showing off the new content capabilities.)
3. STT upgrade and noise screening 3. Update, backup, and restore proof
4. Hosted capture/storage plan 4. STT upgrade and noise screening
5. Binary-safe media storage 5. Hosted capture/storage plan / indexing for group testing
6. Provider-backed news or weather 6. Binary-safe media storage / sync to cloud drive: OneDrive, Google Drive, Box, etc.
7. Proactivity, memory/history, Lasso, identity, and onboarding as larger discovery-driven tracks 7. Provider-backed news and weather
8. Proactivity, dialog parsing/NLP, memory/history, Lasso, identity, and onboarding as larger discovery-driven tracks
For `1.0.20` and beyond:
1. Setup scripts to convert Jibo to Open Jibo by adding a mode for `open-jibo` pointing at our openjibo.com and `open-jibo-ai` pointing at openjibo.ai as a foundation for new cloud features and a clean separation from any remaining stock OS dependencies while preserving his original config
2. Setup scripts to put Jibo in `open-jibo` mode by default for new users, but allow existing users to keep the stock OS experience if they prefer by injecting a new skill that runs on startup to ask them if they want to convert to Open Jibo and switch modes, with a fallback timeout to switch modes automatically after a few weeks of inactivity (ensure new skill is accessible from menu so it can be opted into later on demand / likewise, if they have opted into Open Jibo, the skill will allow them to revert Jibo back to stock)
3. Setup openjibo.com and openjibo.ai domains with landing pages, support docs, and account management for future features that require hosted services or user accounts
4. Test Open Jibo with the new setup scripts and domains, and iterate on any issues that arise during the conversion process
5. Loop advancement (family and friends) / multiple user recognition / multiple Jibo support so Jibo's can interact and communicate
6. Advanced Jibo features such as pizza delivery, Uber/Lyft integration, calendar management, smart home control (Home Assistant), etc. can be added after the conversion process is smooth and stable, with a focus on features that leverage the new cloud capabilities and content personalization enabled by Open Jibo
7. LLM integration for more natural dialog, question answering, and content generation can be explored as a longer-term goal after the core platform is stable and has a growing user base to provide feedback and use cases for LLM-powered features
8. Tiered Jibo brain/orchestration plan from README.md can be implemented in parallel with the above, starting with the simplest cloud features and gradually adding more complex capabilities as the platform matures and user feedback is collected, always preserving his unique charm and original features.

View File

@@ -207,6 +207,7 @@ Expected:
- no `ffmpeg` failure should become the dominant failure mode for non-Opus buffered audio - no `ffmpeg` failure should become the dominant failure mode for non-Opus buffered audio
- short replies such as `yes`, `no`, `cancel`, and short alarm times should either map correctly or be classified as STT misses with evidence - short replies such as `yes`, `no`, `cancel`, and short alarm times should either map correctly or be classified as STT misses with evidence
- when chasing a flaky `$YESNO` reply, look for the new turn telemetry categories `binary_audio_received`, `binary_audio_ignored`, `yes_no_turn_received`, `yes_no_turn_resolved`, and `yes_no_no_input`; the useful question is whether the short reply still had `AwaitingTurnCompletion = true`, active listen rules, and buffered audio when it hit the finalizer
### Stop And Volume ### Stop And Volume

View File

@@ -2,5 +2,7 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface ITurnTelemetrySink public interface ITurnTelemetrySink
{ {
Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default);
Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default); Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default);
} }

View File

@@ -4,5 +4,7 @@ namespace Jibo.Cloud.Application.Services;
public sealed class NullTurnTelemetrySink : ITurnTelemetrySink public sealed class NullTurnTelemetrySink : ITurnTelemetrySink
{ {
public Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) => Task.CompletedTask;
} }

View File

@@ -37,8 +37,21 @@ public sealed partial class WebSocketTurnFinalizationService(
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var turnState = session.TurnState; var turnState = session.TurnState;
if (ShouldIgnoreLateAudio(session) || ShouldIgnoreAudioWithoutListen(turnState)) var ignoreLateAudio = ShouldIgnoreLateAudio(session);
var ignoreAudioWithoutListen = ShouldIgnoreAudioWithoutListen(turnState);
if (ignoreLateAudio || ignoreAudioWithoutListen)
{ {
await sink.RecordTurnDiagnosticAsync("binary_audio_ignored", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary<string, object?>
{
["ignored"] = true,
["ignoreLateAudio"] = ignoreLateAudio,
["ignoreAudioWithoutListen"] = ignoreAudioWithoutListen,
["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion,
["bufferedAudioBytes"] = turnState.BufferedAudioBytes,
["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount,
["sawListen"] = turnState.SawListen,
["sawContext"] = turnState.SawContext
}), cancellationToken);
return []; return [];
} }
@@ -53,6 +66,17 @@ public sealed partial class WebSocketTurnFinalizationService(
turnState.LastAudioReceivedUtc = DateTimeOffset.UtcNow; turnState.LastAudioReceivedUtc = DateTimeOffset.UtcNow;
turnState.AwaitingTurnCompletion = true; turnState.AwaitingTurnCompletion = true;
session.Metadata["lastAudioBytes"] = envelope.Binary?.Length ?? 0; session.Metadata["lastAudioBytes"] = envelope.Binary?.Length ?? 0;
await sink.RecordTurnDiagnosticAsync("binary_audio_received", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary<string, object?>
{
["bufferedAudioBytes"] = turnState.BufferedAudioBytes,
["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount,
["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion,
["sawListen"] = turnState.SawListen,
["sawContext"] = turnState.SawContext,
["listenRules"] = turnState.ListenRules,
["listenAsrHints"] = turnState.ListenAsrHints,
["yesNoRule"] = turnState.ListenRules.FirstOrDefault(IsConstrainedYesNoRule)
}), cancellationToken);
if (ShouldAutoFinalize(session)) if (ShouldAutoFinalize(session))
{ {
@@ -328,6 +352,25 @@ public sealed partial class WebSocketTurnFinalizationService(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var turn = ProtocolToTurnContextMapper.MapListenMessage(envelope, session, messageType); var turn = ProtocolToTurnContextMapper.MapListenMessage(envelope, session, messageType);
var turnState = session.TurnState;
if (IsYesNoTurn(turn) || ReadPrimaryYesNoRule(turn) is not null)
{
await sink.RecordTurnDiagnosticAsync("yes_no_turn_received", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary<string, object?>
{
["messageType"] = messageType,
["listenRules"] = ReadRules(turn, "listenRules").ToArray(),
["clientRules"] = ReadRules(turn, "clientRules").ToArray(),
["listenAsrHints"] = ReadRules(turn, "listenAsrHints").ToArray(),
["yesNoRule"] = ReadPrimaryYesNoRule(turn),
["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion,
["bufferedAudioBytes"] = turnState.BufferedAudioBytes,
["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount,
["sawListen"] = turnState.SawListen,
["sawContext"] = turnState.SawContext,
["followUpOpen"] = session.FollowUpOpen,
["followUpExpiresUtc"] = session.FollowUpExpiresUtc
}), cancellationToken);
}
if (ShouldIgnoreBlankAudioHotphraseTurn(turn)) if (ShouldIgnoreBlankAudioHotphraseTurn(turn))
{ {
session.TurnState.AwaitingTurnCompletion = false; session.TurnState.AwaitingTurnCompletion = false;
@@ -366,7 +409,6 @@ public sealed partial class WebSocketTurnFinalizationService(
}; };
} }
var turnState = session.TurnState;
if (ShouldTreatBufferedHotphraseAsGreeting(finalizedTurn, turnState, allowFallbackOnMissingTranscript)) if (ShouldTreatBufferedHotphraseAsGreeting(finalizedTurn, turnState, allowFallbackOnMissingTranscript))
{ {
finalizedTurn = WithSyntheticTranscript(finalizedTurn, "hello"); finalizedTurn = WithSyntheticTranscript(finalizedTurn, "hello");
@@ -393,6 +435,22 @@ public sealed partial class WebSocketTurnFinalizationService(
if (ShouldHandleAsLocalNoInput(finalizedTurn)) if (ShouldHandleAsLocalNoInput(finalizedTurn))
{ {
if (IsYesNoTurn(finalizedTurn))
{
await sink.RecordTurnDiagnosticAsync("yes_no_no_input", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary<string, object?>
{
["messageType"] = messageType,
["listenRules"] = ReadRules(finalizedTurn, "listenRules").ToArray(),
["clientRules"] = ReadRules(finalizedTurn, "clientRules").ToArray(),
["listenAsrHints"] = ReadRules(finalizedTurn, "listenAsrHints").ToArray(),
["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion,
["bufferedAudioBytes"] = turnState.BufferedAudioBytes,
["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount,
["sawListen"] = turnState.SawListen,
["sawContext"] = turnState.SawContext,
["followUpOpen"] = session.FollowUpOpen
}), cancellationToken);
}
turnState.AwaitingTurnCompletion = false; turnState.AwaitingTurnCompletion = false;
session.LastTranscript = string.Empty; session.LastTranscript = string.Empty;
session.LastIntent = null; session.LastIntent = null;
@@ -522,6 +580,24 @@ public sealed partial class WebSocketTurnFinalizationService(
DelayMs = map.DelayMs DelayMs = map.DelayMs
}).ToArray(); }).ToArray();
if (IsYesNoTurn(finalizedTurn))
{
await sink.RecordTurnDiagnosticAsync("yes_no_turn_resolved", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary<string, object?>
{
["messageType"] = messageType,
["transcript"] = finalizedTurn.NormalizedTranscript ?? finalizedTurn.RawTranscript,
["intent"] = plan.IntentName,
["listenRules"] = ReadRules(finalizedTurn, "listenRules").ToArray(),
["clientRules"] = ReadRules(finalizedTurn, "clientRules").ToArray(),
["listenAsrHints"] = ReadRules(finalizedTurn, "listenAsrHints").ToArray(),
["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion,
["bufferedAudioBytes"] = turnState.BufferedAudioBytes,
["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount,
["followUpOpen"] = session.FollowUpOpen,
["followUpExpiresUtc"] = session.FollowUpExpiresUtc
}), cancellationToken);
}
ResetBufferedAudio(session); ResetBufferedAudio(session);
turnState.SawListen = false; turnState.SawListen = false;
turnState.SawContext = false; turnState.SawContext = false;
@@ -1045,6 +1121,25 @@ public sealed partial class WebSocketTurnFinalizationService(
.Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase)); .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase));
} }
private static Dictionary<string, object?> BuildTurnDiagnosticSnapshot(
CloudSession session,
WebSocketMessageEnvelope envelope,
Dictionary<string, object?> details)
{
details["sessionToken"] = session.Token;
details["hostName"] = envelope.HostName;
details["path"] = envelope.Path;
details["kind"] = envelope.Kind;
details["transID"] = session.TurnState.TransId ?? session.LastTransId;
details["lastMessageType"] = session.LastMessageType;
details["awaitingTurnCompletion"] = session.TurnState.AwaitingTurnCompletion;
details["bufferedAudioBytes"] = session.TurnState.BufferedAudioBytes;
details["bufferedAudioChunks"] = session.TurnState.BufferedAudioChunkCount;
details["sawListen"] = session.TurnState.SawListen;
details["sawContext"] = session.TurnState.SawContext;
return details;
}
private static TurnContext WithSyntheticTranscript(TurnContext turn, string transcript) private static TurnContext WithSyntheticTranscript(TurnContext turn, string transcript)
{ {
var attributes = new Dictionary<string, object?>(turn.Attributes, StringComparer.OrdinalIgnoreCase) var attributes = new Dictionary<string, object?>(turn.Attributes, StringComparer.OrdinalIgnoreCase)

View File

@@ -15,6 +15,20 @@ public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger,
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
public async Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default)
{
if (!options.Value.Enabled)
{
return;
}
await WriteEventAsync(new
{
Type = category,
Details = details
}, "Turn telemetry diagnostic", LogLevel.Information, cancellationToken);
}
public async Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) public async Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default)
{ {
if (!options.Value.Enabled) if (!options.Value.Enabled)
@@ -22,15 +36,20 @@ public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger,
return; return;
} }
await WriteErrorAsync(ex, message, cancellationToken); await WriteEventAsync(new
{
Exception = ex.ToString(),
Message = message,
Type = "transcript_error"
}, "Turn telemetry error", LogLevel.Error, cancellationToken);
} }
private async Task WriteErrorAsync(Exception ex, string message, CancellationToken cancellationToken) private async Task WriteEventAsync(object payload, string logMessage, LogLevel level, CancellationToken cancellationToken)
{ {
var directory = GetBaseDirectory(); var directory = GetBaseDirectory();
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
var filePath = Path.Combine(directory, $"{DateTimeOffset.UtcNow:yyyyMMdd}.events.ndjson"); var filePath = Path.Combine(directory, $"{DateTimeOffset.UtcNow:yyyyMMdd}.events.ndjson");
var line = JsonSerializer.Serialize(new { Exception = ex.ToString(), Message = message }, JsonOptions) + Environment.NewLine; var line = JsonSerializer.Serialize(payload, JsonOptions) + Environment.NewLine;
await _writeLock.WaitAsync(cancellationToken); await _writeLock.WaitAsync(cancellationToken);
try try
@@ -42,7 +61,7 @@ public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger,
_writeLock.Release(); _writeLock.Release();
} }
logger.LogError("Turn telemetry Message={Message} Exception={Exception}", message, ex); logger.Log(level, "{LogMessage} {Payload}", logMessage, payload);
} }
private string GetBaseDirectory() private string GetBaseDirectory()
@@ -52,4 +71,4 @@ public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger,
Directory.GetCurrentDirectory(), Directory.GetCurrentDirectory(),
AppContext.BaseDirectory); AppContext.BaseDirectory);
} }
} }

View File

@@ -1,13 +1,44 @@
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Application.Services; using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Domain.Models; using Jibo.Cloud.Domain.Models;
using Jibo.Cloud.Infrastructure.Telemetry;
using Jibo.Runtime.Abstractions; using Jibo.Runtime.Abstractions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq; using Moq;
namespace Jibo.Cloud.Tests.Turn; namespace Jibo.Cloud.Tests.Turn;
public sealed class FileTurnTelemetrySinkTests public sealed class FileTurnTelemetrySinkTests
{ {
[Fact]
public async Task RecordsTurnDiagnosticSnapshot()
{
var directoryPath = Path.Combine(Path.GetTempPath(), "OpenJibo.Tests", Guid.NewGuid().ToString("N"));
var sink = new FileTurnTelemetrySink(
NullLogger<FileTurnTelemetrySink>.Instance,
Options.Create(new TurnTelemetryOptions
{
Enabled = true,
DirectoryPath = directoryPath
}));
await sink.RecordTurnDiagnosticAsync("yes_no_turn_received", new Dictionary<string, object?>
{
["transID"] = "trans-1",
["bufferedAudioBytes"] = 1234,
["listenRules"] = new[] { "shared/yes_no", "globals/gui_nav" },
["awaitingTurnCompletion"] = true
});
var filePath = Directory.GetFiles(directoryPath, "*.events.ndjson").Single();
var payload = JsonDocument.Parse(await File.ReadAllTextAsync(filePath)).RootElement;
Assert.Equal("yes_no_turn_received", payload.GetProperty("type").GetString());
Assert.Equal("trans-1", payload.GetProperty("details").GetProperty("transID").GetString());
Assert.Equal(1234, payload.GetProperty("details").GetProperty("bufferedAudioBytes").GetInt32());
}
[Fact] [Fact]
public async Task RecordsTranscriptErrorOnTurnError() public async Task RecordsTranscriptErrorOnTurnError()
{ {

View File

@@ -85,7 +85,7 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "yeah", NormalizedTranscript = "yeah",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "create/is_it_a_keeper" } ["listenRules"] = (string[])["create/is_it_a_keeper"]
} }
}); });
@@ -104,8 +104,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "no", NormalizedTranscript = "no",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "surprises-ota/want_to_download_now" }, ["listenRules"] = (string[])["surprises-ota/want_to_download_now"],
["listenAsrHints"] = new[] { "$YESNO" } ["listenAsrHints"] = (string[])["$YESNO"]
} }
}); });
@@ -124,8 +124,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "yes", NormalizedTranscript = "yes",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "shared/yes_no", "globals/gui_nav" }, ["listenRules"] = (string[])["shared/yes_no", "globals/gui_nav"],
["listenAsrHints"] = new[] { "$YESNO" } ["listenAsrHints"] = (string[])["$YESNO"]
} }
}); });
@@ -144,8 +144,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "yes", NormalizedTranscript = "yes",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "clock/alarm_timer_change", "globals/gui_nav" }, ["listenRules"] = (string[])["clock/alarm_timer_change", "globals/gui_nav"],
["listenAsrHints"] = new[] { "$YESNO" } ["listenAsrHints"] = (string[])["$YESNO"]
} }
}); });
@@ -164,8 +164,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "no", NormalizedTranscript = "no",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "clock/alarm_timer_none_set", "globals/global_commands_launch" }, ["listenRules"] = (string[])["clock/alarm_timer_none_set", "globals/global_commands_launch"],
["listenAsrHints"] = new[] { "$YESNO" } ["listenAsrHints"] = (string[])["$YESNO"]
} }
}); });
@@ -184,7 +184,7 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "No.", NormalizedTranscript = "No.",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "settings/download_now_later", "globals/global_commands_launch" } ["listenRules"] = (string[])["settings/download_now_later", "globals/global_commands_launch"]
} }
}); });
@@ -203,8 +203,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "Yes!", NormalizedTranscript = "Yes!",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "surprises-date/offer_date_fact", "globals/global_commands_launch" }, ["listenRules"] = (string[])["surprises-date/offer_date_fact", "globals/global_commands_launch"],
["listenAsrHints"] = new[] { "$YESNO" } ["listenAsrHints"] = (string[])["$YESNO"]
} }
}); });
@@ -607,7 +607,7 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "twenty five minutes", NormalizedTranscript = "twenty five minutes",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "clock/timer_set_value" } ["listenRules"] = (string[])["clock/timer_set_value"]
} }
}); });
@@ -628,7 +628,7 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "ten twenty five", NormalizedTranscript = "ten twenty five",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "clock/alarm_set_value" } ["listenRules"] = (string[])["clock/alarm_set_value"]
} }
}); });
@@ -650,7 +650,7 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "7, 44", NormalizedTranscript = "7, 44",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "clock/alarm_set_value" }, ["listenRules"] = (string[])["clock/alarm_set_value"],
["context"] = """{"runtime":{"location":{"iso":"2026-04-26T07:43:00-05:00"}}}""" ["context"] = """{"runtime":{"location":{"iso":"2026-04-26T07:43:00-05:00"}}}"""
} }
}); });
@@ -734,7 +734,7 @@ public sealed class JiboInteractionServiceTests
{ {
["domain"] = "alarm" ["domain"] = "alarm"
}, },
["clientRules"] = new[] { "clock/clock_menu" } ["clientRules"] = (string[])["clock/clock_menu"]
} }
}); });
@@ -756,7 +756,7 @@ public sealed class JiboInteractionServiceTests
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["clientIntent"] = "cancel", ["clientIntent"] = "cancel",
["clientRules"] = new[] { "clock/alarm_timer_query_menu" }, ["clientRules"] = (string[])["clock/alarm_timer_query_menu"],
["lastClockDomain"] = "alarm" ["lastClockDomain"] = "alarm"
} }
}); });
@@ -779,7 +779,7 @@ public sealed class JiboInteractionServiceTests
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["clientIntent"] = "cancel", ["clientIntent"] = "cancel",
["clientRules"] = new[] { "clock/alarm_set_value" } ["clientRules"] = (string[])["clock/alarm_set_value"]
} }
}); });
@@ -902,7 +902,7 @@ public sealed class JiboInteractionServiceTests
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["clientIntent"] = "guess", ["clientIntent"] = "guess",
["clientRules"] = new[] { "word-of-the-day/puzzle" }, ["clientRules"] = (string[])["word-of-the-day/puzzle"],
["clientEntities"] = JsonDocument.Parse("""{"guess":"pastoral"}""").RootElement.Clone() ["clientEntities"] = JsonDocument.Parse("""{"guess":"pastoral"}""").RootElement.Clone()
} }
}); });
@@ -922,7 +922,7 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "pastoral", NormalizedTranscript = "pastoral",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "word-of-the-day/puzzle" } ["listenRules"] = (string[])["word-of-the-day/puzzle"]
} }
}); });
@@ -959,8 +959,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "Two.", NormalizedTranscript = "Two.",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "word-of-the-day/puzzle" }, ["listenRules"] = (string[])["word-of-the-day/puzzle"],
["listenAsrHints"] = new[] { "doodad", "pastoral", "escarpment" } ["listenAsrHints"] = (string[])["doodad", "pastoral", "escarpment"]
} }
}); });
@@ -983,8 +983,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "Haglet.", NormalizedTranscript = "Haglet.",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = new[] { "word-of-the-day/puzzle" }, ["listenRules"] = (string[])["word-of-the-day/puzzle"],
["listenAsrHints"] = new[] { "aglet", "hovel", "wisenheimer" } ["listenAsrHints"] = (string[])["aglet", "hovel", "wisenheimer"]
} }
}); });