Public Access
Add tray rules and identities editor
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
using MeetingAssistant.Workflow;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class WorkflowRulesEditorTests
|
||||
{
|
||||
[Fact]
|
||||
public void EditorOptionsInheritSummarizerDefaultsAndOverrideConfiguredValues()
|
||||
{
|
||||
var defaults = new AgentOptions
|
||||
{
|
||||
Endpoint = "https://summary.example",
|
||||
Key = "summary-key",
|
||||
KeyEnv = "SUMMARY_KEY",
|
||||
Model = "summary-model",
|
||||
EnableThinking = true,
|
||||
ReasoningEffort = ReasoningEffortOption.High,
|
||||
ReconnectionAttempts = 7,
|
||||
ReconnectionDelay = TimeSpan.FromSeconds(7),
|
||||
ContextWindowTokens = 1000,
|
||||
MaxOutputTokens = 100,
|
||||
EnableCompaction = true,
|
||||
CompactionRemainingRatio = 0.2,
|
||||
ResponsesCompactPath = "responses/compact"
|
||||
};
|
||||
var editor = new WorkflowRulesEditorOptions
|
||||
{
|
||||
Model = "editor-model",
|
||||
EnableThinking = false,
|
||||
MaxOutputTokens = 50
|
||||
};
|
||||
|
||||
var effective = editor.ToEffectiveAgentOptions(defaults);
|
||||
|
||||
Assert.Equal("https://summary.example", effective.Endpoint);
|
||||
Assert.Equal("summary-key", effective.Key);
|
||||
Assert.Equal("SUMMARY_KEY", effective.KeyEnv);
|
||||
Assert.Equal("editor-model", effective.Model);
|
||||
Assert.False(effective.EnableThinking);
|
||||
Assert.Equal(ReasoningEffortOption.High, effective.ReasoningEffort);
|
||||
Assert.Equal(7, effective.ReconnectionAttempts);
|
||||
Assert.Equal(TimeSpan.FromSeconds(7), effective.ReconnectionDelay);
|
||||
Assert.Equal(1000, effective.ContextWindowTokens);
|
||||
Assert.Equal(50, effective.MaxOutputTokens);
|
||||
Assert.True(effective.EnableCompaction);
|
||||
Assert.Equal(0.2, effective.CompactionRemainingRatio);
|
||||
Assert.Equal("responses/compact", effective.ResponsesCompactPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsReadWriteAndSearchOnlyConfiguredRulesFile()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||
var unrelatedPath = Path.Combine(root, "other.yaml");
|
||||
await File.WriteAllTextAsync(unrelatedPath, "rules:\n- name: do-not-find\n");
|
||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
||||
{
|
||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
||||
});
|
||||
|
||||
var writeResult = await tools.WriteRules("""
|
||||
rules:
|
||||
- name: add-me
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Manuel
|
||||
""");
|
||||
var readResult = await tools.ReadRules();
|
||||
var searchResult = await tools.Search("add-me");
|
||||
|
||||
Assert.Equal(rulesPath, writeResult);
|
||||
Assert.Contains("name: add-me", readResult);
|
||||
Assert.Contains("rules.yaml:", searchResult);
|
||||
Assert.DoesNotContain("other.yaml", searchResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsRefuseInvalidYamlWithoutOverwritingFile()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||
await File.WriteAllTextAsync(rulesPath, "rules: []");
|
||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
||||
{
|
||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
||||
});
|
||||
|
||||
var result = await tools.WriteRules("rules:\n- name: [");
|
||||
|
||||
Assert.StartsWith("Refused: workflow rules YAML is invalid.", result);
|
||||
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstructionBuilderIncludesConfiguredRulesPathAndWorkflowDocs()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||
var builder = new WorkflowRulesEditorInstructionBuilder(
|
||||
NullLogger<WorkflowRulesEditorInstructionBuilder>.Instance);
|
||||
|
||||
var instructions = await builder.BuildAsync(new MeetingAssistantOptions
|
||||
{
|
||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
||||
}, CancellationToken.None);
|
||||
|
||||
Assert.Contains(rulesPath, instructions);
|
||||
Assert.Contains("Meeting Workflow Engine", instructions);
|
||||
Assert.Contains("Workflow rules reference documentation", instructions);
|
||||
Assert.Contains("speaker identity database", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsCrudAndSearchSpeakerIdentities()
|
||||
{
|
||||
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
|
||||
var tools = fixture.CreateTools();
|
||||
|
||||
var createResult = await tools.CreateIdentity(
|
||||
"Sabrina",
|
||||
["Sabi"],
|
||||
["Guest-01"]);
|
||||
using var created = JsonDocument.Parse(createResult);
|
||||
var identityId = created.RootElement
|
||||
.GetProperty("identity")
|
||||
.GetProperty("id")
|
||||
.GetInt32();
|
||||
|
||||
var searchResult = await tools.SearchIdentities("sabi");
|
||||
var readResult = await tools.ReadIdentity(identityId);
|
||||
var updateResult = await tools.UpdateIdentity(
|
||||
identityId,
|
||||
"Sabrina M.",
|
||||
["Sabrina"],
|
||||
["Guest-02"]);
|
||||
var deleteResult = await tools.DeleteIdentity(identityId);
|
||||
|
||||
Assert.Contains("\"displayName\": \"Sabrina\"", searchResult);
|
||||
Assert.Contains("\"canonicalName\": \"Sabrina\"", readResult);
|
||||
Assert.Contains("\"canonicalName\": \"Sabrina M.\"", updateResult);
|
||||
Assert.Equal($"Deleted identity {identityId}.", deleteResult);
|
||||
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsMergeSpeakerIdentitiesIntoTarget()
|
||||
{
|
||||
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
|
||||
var target = await fixture.AddIdentityAsync("Sabrina", aliases: ["Sabi"], sample: [1]);
|
||||
var source = await fixture.AddIdentityAsync("Sabine", aliases: ["Guest Name"], candidates: ["Guest-01"], sample: [2, 3]);
|
||||
var tools = fixture.CreateTools();
|
||||
|
||||
var result = await tools.MergeIdentities(target.Id, source.Id);
|
||||
|
||||
Assert.Contains("\"canonicalName\": \"Sabrina\"", result);
|
||||
var saved = await fixture.LoadIdentityAsync(target.Id);
|
||||
Assert.Equal(["Guest Name", "Guest-01", "Sabi", "Sabine"], saved.Aliases.Select(alias => alias.Name).Order());
|
||||
var snippets = saved.Snippets.Select(sample => sample.WavBytes).OrderBy(bytes => bytes.Length).ToArray();
|
||||
Assert.Equal([1], snippets[0]);
|
||||
Assert.Equal([2, 3], snippets[1]);
|
||||
Assert.False(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == source.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsListReadQueueAndDeleteSpeakerSamples()
|
||||
{
|
||||
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync("Sabrina", sample: [4, 5, 6]);
|
||||
var sampleId = identity.Snippets.Single().Id;
|
||||
var queue = new CapturingSamplePlaybackQueue();
|
||||
var tools = fixture.CreateTools(queue);
|
||||
|
||||
var listResult = await tools.ListIdentitySamples(identity.Id);
|
||||
var readResult = await tools.ReadIdentitySample(sampleId);
|
||||
var playResult = await tools.QueuePlayIdentitySample(sampleId);
|
||||
var deleteResult = await tools.DeleteIdentitySample(sampleId);
|
||||
|
||||
Assert.Contains($"\"id\": {sampleId}", listResult);
|
||||
Assert.Contains(Convert.ToBase64String([4, 5, 6]), readResult);
|
||||
Assert.Equal($"Queued sample {sampleId}.", playResult);
|
||||
var queued = queue.Queued.Single();
|
||||
Assert.Equal(sampleId, queued.SampleId);
|
||||
Assert.Equal([4, 5, 6], queued.WavBytes);
|
||||
Assert.Equal($"Deleted sample {sampleId}.", deleteResult);
|
||||
Assert.Empty(await fixture.Context.SpeakerSnippets.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelShowsUserMessageThinkingStateAndFinalAgentMessage()
|
||||
{
|
||||
var pipeline = new BlockingRulesEditorPipeline("Updated rules.");
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "Rename ABS Daily"
|
||||
};
|
||||
|
||||
var sendTask = viewModel.SendAsync();
|
||||
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(viewModel.IsThinking);
|
||||
Assert.Equal("", viewModel.Draft);
|
||||
Assert.Equal(
|
||||
[new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "Rename ABS Daily")],
|
||||
viewModel.Messages.ToArray());
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Equal(2, viewModel.Messages.Count);
|
||||
Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[1].Role);
|
||||
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 500, 0, true)]
|
||||
[InlineData(0, 500, 400, true)]
|
||||
[InlineData(492, 500, 1000, true)]
|
||||
[InlineData(300, 500, 1000, false)]
|
||||
public void ScrollPolicyAutoScrollsOnlyWhenAlreadyNearBottom(
|
||||
double verticalOffset,
|
||||
double viewportHeight,
|
||||
double contentHeight,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
|
||||
verticalOffset,
|
||||
viewportHeight,
|
||||
contentHeight));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollPolicyAutoScrollsWhenPreviousContentFitBeforeNewCardOverflows()
|
||||
{
|
||||
Assert.True(WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
|
||||
verticalOffset: 0,
|
||||
viewportHeight: 500,
|
||||
previousContentHeight: 500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollPolicyComputesNonNegativeBottomOffset()
|
||||
{
|
||||
Assert.Equal(500, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 1000));
|
||||
Assert.Equal(0, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 400));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserRecognizesRequestedInlineStyles()
|
||||
{
|
||||
var inlines = WorkflowRulesEditorMarkdown.ParseInline(
|
||||
"Normal *italic* **bold** and `code`.");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
new WorkflowRulesEditorMarkdownInline("Normal ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline("italic", WorkflowRulesEditorMarkdownInlineStyle.Italic),
|
||||
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline("bold", WorkflowRulesEditorMarkdownInlineStyle.Bold),
|
||||
new WorkflowRulesEditorMarkdownInline(" and ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline("code", WorkflowRulesEditorMarkdownInlineStyle.Code),
|
||||
new WorkflowRulesEditorMarkdownInline(".", WorkflowRulesEditorMarkdownInlineStyle.Normal)
|
||||
],
|
||||
inlines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserPreservesParagraphLineBreaks()
|
||||
{
|
||||
var blocks = WorkflowRulesEditorMarkdown.Parse("""
|
||||
First line
|
||||
Second line with `code`
|
||||
""");
|
||||
|
||||
var paragraph = Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(Assert.Single(blocks));
|
||||
Assert.Contains(paragraph.Inlines, inline =>
|
||||
inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Normal &&
|
||||
inline.Text.Contains('\n'));
|
||||
Assert.Contains(paragraph.Inlines, inline =>
|
||||
inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code &&
|
||||
inline.Text == "code");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserRecognizesPipeTables()
|
||||
{
|
||||
var blocks = WorkflowRulesEditorMarkdown.Parse("""
|
||||
Current identities:
|
||||
|
||||
| ID | Name | Samples |
|
||||
|----|------|---------|
|
||||
| 1 | Manuel | 2 |
|
||||
| 2 | Sabrina | 1 |
|
||||
""");
|
||||
|
||||
Assert.Collection(
|
||||
blocks,
|
||||
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block),
|
||||
block =>
|
||||
{
|
||||
var table = Assert.IsType<WorkflowRulesEditorMarkdownTable>(block);
|
||||
Assert.Equal(["ID", "Name", "Samples"], table.Header);
|
||||
Assert.Equal(["1", "Manuel", "2"], table.Rows[0]);
|
||||
Assert.Equal(["2", "Sabrina", "1"], table.Rows[1]);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserRecognizesFencedCodeBlocks()
|
||||
{
|
||||
var blocks = WorkflowRulesEditorMarkdown.Parse("""
|
||||
Before
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- name: example
|
||||
```
|
||||
|
||||
After **bold**
|
||||
""");
|
||||
|
||||
Assert.Collection(
|
||||
blocks,
|
||||
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block),
|
||||
block =>
|
||||
{
|
||||
var code = Assert.IsType<WorkflowRulesEditorMarkdownCodeBlock>(block);
|
||||
Assert.Equal("rules:" + Environment.NewLine + "- name: example", code.Text);
|
||||
},
|
||||
block =>
|
||||
{
|
||||
var paragraph = Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block);
|
||||
Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold);
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class BlockingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
private readonly string response;
|
||||
|
||||
public BlockingRulesEditorPipeline(string response)
|
||||
{
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Started.SetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new WorkflowRulesEditorChatResult(
|
||||
response,
|
||||
conversation
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class WorkflowRulesEditorIdentityFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly string tempDirectory;
|
||||
private readonly string dbPath;
|
||||
|
||||
private WorkflowRulesEditorIdentityFixture(
|
||||
string tempDirectory,
|
||||
string dbPath,
|
||||
SpeakerIdentityDbContext context)
|
||||
{
|
||||
this.tempDirectory = tempDirectory;
|
||||
this.dbPath = dbPath;
|
||||
Context = context;
|
||||
}
|
||||
|
||||
public SpeakerIdentityDbContext Context { get; }
|
||||
|
||||
public static async Task<WorkflowRulesEditorIdentityFixture> CreateAsync()
|
||||
{
|
||||
var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDirectory);
|
||||
var dbPath = Path.Combine(tempDirectory, "speaker-identities.db");
|
||||
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
return new WorkflowRulesEditorIdentityFixture(tempDirectory, dbPath, context);
|
||||
}
|
||||
|
||||
public WorkflowRulesEditorTools CreateTools(
|
||||
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null)
|
||||
{
|
||||
return new WorkflowRulesEditorTools(
|
||||
new MeetingAssistantOptions(),
|
||||
new TestSpeakerIdentityDbContextFactory(dbPath),
|
||||
samplePlaybackQueue);
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentity> AddIdentityAsync(
|
||||
string canonicalName,
|
||||
IReadOnlyList<string>? aliases = null,
|
||||
IReadOnlyList<string>? candidates = null,
|
||||
byte[]? sample = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
|
||||
CandidateNames = candidates?.Select(candidate => new SpeakerCandidateName { Name = candidate }).ToList() ?? [],
|
||||
Snippets = sample is null
|
||||
? []
|
||||
:
|
||||
[
|
||||
new SpeakerSnippet
|
||||
{
|
||||
WavBytes = sample,
|
||||
CreatedAt = now
|
||||
}
|
||||
]
|
||||
};
|
||||
Context.SpeakerIdentities.Add(identity);
|
||||
await Context.SaveChangesAsync();
|
||||
return identity;
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentity> LoadIdentityAsync(int id)
|
||||
{
|
||||
Context.ChangeTracker.Clear();
|
||||
return Context.SpeakerIdentities
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Snippets)
|
||||
.SingleAsync(identity => identity.Id == id);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Context.DisposeAsync();
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory<SpeakerIdentityDbContext>
|
||||
{
|
||||
private readonly string dbPath;
|
||||
|
||||
public TestSpeakerIdentityDbContextFactory(string dbPath)
|
||||
{
|
||||
this.dbPath = dbPath;
|
||||
}
|
||||
|
||||
public SpeakerIdentityDbContext CreateDbContext()
|
||||
{
|
||||
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue
|
||||
{
|
||||
public List<(int SampleId, byte[] WavBytes)> Queued { get; } = [];
|
||||
|
||||
public Task<string> QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queued.Add((sampleId, wavBytes));
|
||||
return Task.FromResult($"Queued sample {sampleId}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user