Public Access
533 lines
20 KiB
C#
533 lines
20 KiB
C#
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 RulesEditorToolsAppendByDefaultAndReplaceOnlyWhenRequested()
|
|
{
|
|
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:
|
|
- name: existing
|
|
on:
|
|
- created: {}
|
|
steps:
|
|
- uses: add_attendee
|
|
value: Ada
|
|
""");
|
|
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
|
{
|
|
Automation = new AutomationOptions { RulesPath = rulesPath }
|
|
});
|
|
|
|
await tools.WriteRules("""
|
|
- name: appended
|
|
on:
|
|
- created: {}
|
|
steps:
|
|
- uses: add_attendee
|
|
value: Grace
|
|
""");
|
|
|
|
var appended = await File.ReadAllTextAsync(rulesPath);
|
|
Assert.Contains("name: existing", appended);
|
|
Assert.Contains("name: appended", appended);
|
|
|
|
await tools.WriteRules("rules: []", replace_file: true);
|
|
|
|
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
|
|
}
|
|
|
|
[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: [", replace_file: true);
|
|
|
|
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}.");
|
|
}
|
|
}
|
|
}
|