A unit can put operations to a blind vote instead of its lead. Not opinions are counted but independent checks enforced. - UnitConfig.votes: operations, voters, approveAt, rejectAt, quorum, hours, perDay - set only by a human (OrgAdministration.SetVotes). - Core/Staging/Votes: routes a covered staged call before the lead; one task per voter with the frozen call and nothing else; cast_vote needs reasoning and evidence. Counted as soon as the result is settled; in between and without the quorum a human decides. The proposer does not vote, voters that read foreign content get no ballot, changes to agents never go to a vote. - Escalation tool: list_ballots, cast_vote. - Approvals view shows each vote with every ballot and the voters' costs; organisation view sets the rule and lists the votes held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
80 lines
3.6 KiB
C#
80 lines
3.6 KiB
C#
using System.Text.Json;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Organisation;
|
|
using ClawdDotNet.Core.Staging;
|
|
using ClawdDotNet.Core.Storage;
|
|
using ClawdDotNet.Core.Tasks;
|
|
using ClawdDotNet.Core.Tools;
|
|
using ClawdDotNet.Tools.Taskboard;
|
|
using ClawdDotNet.Tools.Tests.FileRW;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Tools.Tests.Taskboard;
|
|
|
|
/// <summary>#702: A voter's side of a vote through the Escalation tool.</summary>
|
|
public sealed class VoteToolTests : IDisposable
|
|
{
|
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
|
private readonly SqliteStagingRepository _staging;
|
|
private readonly Votes _votes;
|
|
private readonly EscalationTool _tool = new();
|
|
|
|
private readonly OrgChart _org = new(
|
|
[
|
|
new UnitConfig
|
|
{
|
|
Id = "purchasing", Name = "Purchasing",
|
|
Votes = new UnitVotes { Operations = ["Database.insert"], Voters = ["accounting", "logistics"], ApproveAt = 2, RejectAt = 0 }
|
|
}
|
|
],
|
|
[
|
|
new AgentConfig { AgentId = "buyer", UnitId = "purchasing" },
|
|
new AgentConfig { AgentId = "accounting", UnitId = "purchasing" },
|
|
new AgentConfig { AgentId = "logistics", UnitId = "purchasing" }
|
|
]);
|
|
|
|
public VoteToolTests()
|
|
{
|
|
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
|
_staging = new SqliteStagingRepository(storage);
|
|
var board = new TaskboardService(new SqliteTaskRepository(storage), Path.Combine(_directory, "tasks"));
|
|
_votes = new Votes(storage, _staging, board, () => _org, _ => AgentTrust.Standard, NullLogger.Instance);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
SqliteStorage.ReleasePooledConnections(_directory);
|
|
try { Directory.Delete(_directory, recursive: true); }
|
|
catch { /* cleanup is secondary */ }
|
|
}
|
|
|
|
private Task<ToolResult> Run(string agent, object input)
|
|
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input),
|
|
new AgentToolContext(agent, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance, CancellationToken.None)
|
|
{
|
|
Organisation = _org,
|
|
Votes = _votes,
|
|
RunId = "run-" + agent
|
|
}, CancellationToken.None);
|
|
|
|
[Fact]
|
|
public async Task A_voter_lists_its_ballot_and_casts_it_with_evidence()
|
|
{
|
|
var call = new StagedCall { RunId = "r", AgentId = "buyer", Tool = "Database", Action = "insert", ArgumentsJson = "{\"action\":\"insert\"}" };
|
|
var id = await _staging.AppendAsync(call, default);
|
|
await _votes.RouteAsync(call with { Id = id }, default);
|
|
var vote = (await _votes.ForCallsAsync([id], default))[id];
|
|
|
|
(await Run("accounting", new { action = "list_ballots" })).Content.ShouldContain($"vote #{vote.Id}");
|
|
(await Run("accounting", new { action = "cast_vote", vote = vote.Id, choice = "maybe", reasoning = "x", evidence = "y" }))
|
|
.ErrorMessage!.ShouldContain("'choice'");
|
|
|
|
var cast = await Run("accounting", new { action = "cast_vote", vote = vote.Id, choice = "no", reasoning = "Over budget.", evidence = "Balance 80 EUR, ledger 2026-10-01 08:10" });
|
|
cast.Success.ShouldBeTrue(cast.ErrorMessage);
|
|
cast.Content.ShouldContain("You will not see the others' votes");
|
|
(await _votes.GetAsync(vote.Id, default))!.Ballots.Single(b => b.Voter == "accounting").RunId.ShouldBe("run-accounting");
|
|
(await Run("logistics", new { action = "list_ballots" })).Content.ShouldNotContain("Over budget", customMessage: "blind");
|
|
}
|
|
}
|