- @{ var agents = _summary!.ByAgent.OrderByDescending(a => a.TokensIn + a.TokensOut).ToList(); var maxAgent = agents.Count == 0 ? 1 : Math.Max(1, agents.Max(a => a.TokensIn + a.TokensOut)); }
+ @{ var agents = ByRole(_summary!.ByAgent).OrderByDescending(a => a.TokensIn + a.TokensOut).ToList(); var maxAgent = agents.Count == 0 ? 1 : Math.Max(1, agents.Max(a => a.TokensIn + a.TokensOut)); }
@if (agents.Count == 0)
{
No agent calls in this range.
@@ -204,7 +204,7 @@ else
@* ── Chart: latency + QA rounds ──────────────────────────────────────────────────── *@
- @{ var lat = _extra?.ByAgentLatency ?? []; var maxLat = lat.Count == 0 ? 0.0 : lat.Max(l => l.AvgLatencyMs); }
+ @{ var lat = LatencyByRole(_extra?.ByAgentLatency ?? []).ToList(); var maxLat = lat.Count == 0 ? 0.0 : lat.Max(l => l.AvgLatencyMs); }
@if (lat.Count == 0)
{
No latency data in this range.
@@ -409,6 +409,19 @@ else
private static string AgentShort(string agent) => agent
.Replace("Agent", "", StringComparison.Ordinal).Trim() is { Length: > 0 } s ? s : agent;
+ // Weighted average across the role's names (calls × average latency).
+ internal static IEnumerable LatencyByRole(IEnumerable buckets) =>
+ buckets.GroupBy(b => AgentShort(b.Agent), StringComparer.OrdinalIgnoreCase)
+ .Select(g => new AgentOs.Modules.Pipeline.Persistence.AgentLatencyBucket(g.Key,
+ g.Sum(b => b.Calls) == 0 ? 0 : g.Sum(b => b.AvgLatencyMs * b.Calls) / g.Sum(b => b.Calls), g.Sum(b => b.Calls)))
+ .OrderByDescending(b => b.AvgLatencyMs);
+
+ // One row per role: the 5-agent pipeline files calls as "CodingAgent", the Board's agents as "Coding" — the same role.
+ internal static IEnumerable ByRole(IEnumerable buckets) =>
+ buckets.GroupBy(b => AgentShort(b.Key), StringComparer.OrdinalIgnoreCase)
+ .Select(g => new AgentOs.Modules.Pipeline.Persistence.CostBucket(g.Key, g.Sum(b => b.CostUsd), g.Sum(b => b.TokensIn),
+ g.Sum(b => b.TokensOut), g.Sum(b => b.Calls), g.Sum(b => b.CacheReadTokens), g.Sum(b => b.CacheWriteTokens)));
+
private Badge.BadgeVariant BudgetBadge() => _budget?.State switch
{
BudgetState.Exceeded => Badge.BadgeVariant.Err,
diff --git a/tests/AgentOs.Tests/Llm/RemoteAgentTests.cs b/tests/AgentOs.Tests/Llm/RemoteAgentTests.cs
index 063a13a..f227771 100644
--- a/tests/AgentOs.Tests/Llm/RemoteAgentTests.cs
+++ b/tests/AgentOs.Tests/Llm/RemoteAgentTests.cs
@@ -127,6 +127,41 @@ public async Task Client_RunnerReportsProgress_ReachesThatRequestOnly()
steps.ShouldBe(["testing"]);
}
+ // Metrics first: a current runner sends the CLI's real usage; the ~4-chars-per-token estimate is only a fallback.
+ [Fact]
+ public async Task Client_RunnerReportsUsage_UsesTheRealTokens()
+ {
+ var broker = new InProcessRemoteAgentBroker();
+ using var reg = broker.RegisterRunner("conn-1", Conn());
+ broker.Dispatched += d => broker.Complete(new RemoteExecResult(d.Request.Id, true, "ok", null)
+ {
+ InputTokens = 250_000, OutputTokens = 9_000, CacheReadTokens = 200_000, CacheWriteTokens = 30_000,
+ });
+ var client = new RemoteAgentLlmClient(broker, Tenant(), NullLogger.Instance);
+
+ var r = await client.SendAsync(new LlmRequest("s", "u", "m"));
+
+ r.InputTokens.ShouldBe(250_000);
+ r.OutputTokens.ShouldBe(9_000);
+ r.CacheReadTokens.ShouldBe(200_000);
+ r.CacheWriteTokens.ShouldBe(30_000);
+ r.CostUsd.ShouldBe(0m); // the member's subscription — never billed on the server
+ }
+
+ [Fact]
+ public async Task Client_OlderRunnerWithoutUsage_Estimates()
+ {
+ var broker = new InProcessRemoteAgentBroker();
+ using var reg = broker.RegisterRunner("conn-1", Conn());
+ broker.Dispatched += d => broker.Complete(new RemoteExecResult(d.Request.Id, true, new string('x', 400), null));
+ var client = new RemoteAgentLlmClient(broker, Tenant(), NullLogger.Instance);
+
+ var r = await client.SendAsync(new LlmRequest("s", "u", "m"));
+
+ r.OutputTokens.ShouldBe(100);
+ r.CacheReadTokens.ShouldBe(0);
+ }
+
[Fact]
public async Task Broker_NoRunner_DispatchThrows()
{
diff --git a/tests/AgentOs.Tests/Sessions/CliProgressTests.cs b/tests/AgentOs.Tests/Sessions/CliProgressTests.cs
index 8a08191..83fede5 100644
--- a/tests/AgentOs.Tests/Sessions/CliProgressTests.cs
+++ b/tests/AgentOs.Tests/Sessions/CliProgressTests.cs
@@ -57,6 +57,15 @@ public void Parse_ResultEvent_ReturnsTheAnswerUsageAndCost()
final.Text.ShouldBe("{\"branch\":\"issue-7-ai-fix\",\"summary\":\"Done.\"}");
final.CostUsd.ShouldBe(0.4213m);
final.Usage!.Value.GetProperty("cache_read_input_tokens").GetInt32().ShouldBe(3000);
+ // Claude reports input WITHOUT cache reads/writes; normalised, the cache is a subset of the input.
+ final.Tokens().ShouldBe((3012, 900, 3000, 0));
+ }
+
+ [Fact]
+ public void Tokens_NoUsageReported_IsNull()
+ {
+ var (_, final) = CliProgress.Parse("""{"type":"result","is_error":false,"result":"ok"}""");
+ final!.Tokens().ShouldBeNull();
}
[Theory]
diff --git a/tests/AgentOs.Tests/Web/InsightsByRoleTests.cs b/tests/AgentOs.Tests/Web/InsightsByRoleTests.cs
new file mode 100644
index 0000000..a2114a1
--- /dev/null
+++ b/tests/AgentOs.Tests/Web/InsightsByRoleTests.cs
@@ -0,0 +1,39 @@
+// Insights shows one row per agent role: the 5-agent pipeline files calls as "CodingAgent", the Board's agents as
+// "Coding" — on the lab both showed as two separate "Coding" rows.
+
+using AgentOs.Modules.Pipeline.Persistence;
+using AgentOs.Web.Components.Pages;
+using Shouldly;
+using Xunit;
+
+namespace AgentOs.Tests.Web;
+
+public sealed class InsightsByRoleTests
+{
+ [Fact]
+ public void ByRole_MergesTheSameRole_AndSumsEverything()
+ {
+ CostBucket[] buckets =
+ [
+ new("CodingAgent", 0.01m, 1000, 200, 3, 100, 10),
+ new("Coding", 0m, 5000, 900, 5, 4000, 300),
+ new("Reviewer", 0m, 700, 50, 1),
+ ];
+
+ var rows = InsightsApp.ByRole(buckets).ToDictionary(b => b.Key);
+
+ rows.Count.ShouldBe(2);
+ rows["Coding"].TokensIn.ShouldBe(6000);
+ rows["Coding"].Calls.ShouldBe(8);
+ rows["Coding"].CacheReadTokens.ShouldBe(4100);
+ rows["Coding"].CostUsd.ShouldBe(0.01m);
+ }
+
+ [Fact]
+ public void LatencyByRole_IsCallWeighted()
+ {
+ AgentLatencyBucket[] buckets = [new("CodingAgent", 1000, 1), new("Coding", 4000, 3)];
+
+ InsightsApp.LatencyByRole(buckets).ShouldHaveSingleItem().AvgLatencyMs.ShouldBe(3250);
+ }
+}