feat: surface Copilot usage and quota data across the UI

Add three layers of usage visibility:

- ChatPane footer: premium request count, AIU consumed, and quota
  remaining below the existing context-window bar
- Settings / CopilotStatusCard: on-demand account quota section with
  progress bars, overage indicators, and reset dates fetched via
  the new get-quota sidecar command
- Activity Panel: per-agent token/cost/duration totals on each agent
  row and a Session Usage summary section between agents and timeline

Backend (sidecar):
- New get-quota command using SDK account.getQuota RPC
- New assistant-usage turn-scoped event from SDK assistant.usage
- QuotaSnapshotMapper for both typed and untyped SDK quota payloads
- DTOs: GetQuotaCommandDto, QuotaSnapshotDto, AccountQuotaResultEventDto,
  AssistantUsageEventDto

Frontend:
- Shared types: AssistantUsageEvent, QuotaSnapshot, GetQuotaCommand
- IPC bridge: getQuota channel, assistant-usage event dispatch
- State: SessionRequestUsageMap accumulator with per-agent breakdown
- 8 new tests for accumulator logic and formatting helpers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-29 00:46:09 +01:00
co-authored by Copilot
parent 48efbf36f9
commit 92832c6116
26 changed files with 978 additions and 18 deletions
@@ -224,6 +224,8 @@ public sealed class DisconnectSessionCommandDto : SidecarCommandEnvelope
public string SessionId { get; init; } = string.Empty;
}
public sealed class GetQuotaCommandDto : SidecarCommandEnvelope;
public sealed class RunTurnToolingConfigDto
{
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
@@ -386,6 +388,37 @@ public sealed class HookLifecycleEventDto : SidecarEventDto
public string? Error { get; init; }
}
public sealed class QuotaSnapshotDto
{
public double EntitlementRequests { get; init; }
public double UsedRequests { get; init; }
public double RemainingPercentage { get; init; }
public double Overage { get; init; }
public bool OverageAllowedWithExhaustedQuota { get; init; }
public string? ResetDate { get; init; }
}
public sealed class AccountQuotaResultEventDto : SidecarEventDto
{
public Dictionary<string, QuotaSnapshotDto> QuotaSnapshots { get; init; } = new(StringComparer.Ordinal);
}
public sealed class AssistantUsageEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string Model { get; init; } = string.Empty;
public double? InputTokens { get; init; }
public double? OutputTokens { get; init; }
public double? CacheReadTokens { get; init; }
public double? CacheWriteTokens { get; init; }
public double? Cost { get; init; }
public double? Duration { get; init; }
public double? TotalNanoAiu { get; init; }
public Dictionary<string, QuotaSnapshotDto>? QuotaSnapshots { get; init; }
}
public sealed class SessionUsageEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
@@ -1,10 +1,19 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using GitHub.Copilot.SDK.Rpc;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotSessionManager : ICopilotSessionManager
{
public async Task<IReadOnlyDictionary<string, QuotaSnapshotDto>> GetQuotaAsync(
CancellationToken cancellationToken)
{
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
AccountGetQuotaResult result = await client.Rpc.Account.GetQuotaAsync(cancellationToken).ConfigureAwait(false);
return QuotaSnapshotMapper.Map(result.QuotaSnapshots);
}
public async Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
CopilotSessionListFilterDto? filter,
CancellationToken cancellationToken)
@@ -127,6 +127,10 @@ internal sealed class CopilotTurnExecutionState
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data));
}
break;
case AssistantUsageEvent assistantUsage:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage.Data));
break;
case SessionUsageInfoEvent usageInfo:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo.Data));
@@ -410,6 +414,29 @@ internal sealed class CopilotTurnExecutionState
};
}
private AssistantUsageEventDto CreateAssistantUsageEvent(
AgentIdentity agent,
AssistantUsageData? data)
{
return new AssistantUsageEventDto
{
Type = "assistant-usage",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Model = data?.Model ?? string.Empty,
InputTokens = data?.InputTokens,
OutputTokens = data?.OutputTokens,
CacheReadTokens = data?.CacheReadTokens,
CacheWriteTokens = data?.CacheWriteTokens,
Cost = data?.Cost,
Duration = data?.Duration,
TotalNanoAiu = data?.CopilotUsage?.TotalNanoAiu,
QuotaSnapshots = QuotaSnapshotMapper.MapOrNull(data?.QuotaSnapshots),
};
}
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, SessionUsageInfoData? data)
{
return new SessionUsageEventDto
@@ -12,5 +12,8 @@ public interface ICopilotSessionManager
string? aryxSessionId,
string? copilotSessionId,
CancellationToken cancellationToken);
Task<IReadOnlyDictionary<string, QuotaSnapshotDto>> GetQuotaAsync(
CancellationToken cancellationToken);
}
@@ -0,0 +1,105 @@
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK.Rpc;
namespace Aryx.AgentHost.Services;
internal static class QuotaSnapshotMapper
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
};
public static Dictionary<string, QuotaSnapshotDto> Map(
IReadOnlyDictionary<string, AccountGetQuotaResultQuotaSnapshotsValue>? snapshots)
{
Dictionary<string, QuotaSnapshotDto> mapped = new(StringComparer.Ordinal);
if (snapshots is null)
{
return mapped;
}
foreach ((string key, AccountGetQuotaResultQuotaSnapshotsValue snapshot) in snapshots)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
mapped[key.Trim()] = Map(snapshot);
}
return mapped;
}
public static Dictionary<string, QuotaSnapshotDto>? MapOrNull(
IReadOnlyDictionary<string, object>? snapshots)
{
if (snapshots is not { Count: > 0 })
{
return null;
}
Dictionary<string, QuotaSnapshotDto> mapped = new(StringComparer.Ordinal);
foreach ((string key, object snapshot) in snapshots)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
QuotaSnapshotDto? mappedSnapshot = TryMap(snapshot);
if (mappedSnapshot is null)
{
continue;
}
mapped[key.Trim()] = mappedSnapshot;
}
return mapped.Count == 0 ? null : mapped;
}
public static QuotaSnapshotDto Map(AccountGetQuotaResultQuotaSnapshotsValue snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
return new QuotaSnapshotDto
{
EntitlementRequests = snapshot.EntitlementRequests,
UsedRequests = snapshot.UsedRequests,
RemainingPercentage = snapshot.RemainingPercentage,
Overage = snapshot.Overage,
OverageAllowedWithExhaustedQuota = snapshot.OverageAllowedWithExhaustedQuota,
ResetDate = snapshot.ResetDate,
};
}
private static QuotaSnapshotDto? TryMap(object? snapshot)
{
if (snapshot is null)
{
return null;
}
if (snapshot is AccountGetQuotaResultQuotaSnapshotsValue typedSnapshot)
{
return Map(typedSnapshot);
}
JsonElement element = snapshot is JsonElement jsonElement
? jsonElement
: JsonSerializer.SerializeToElement(snapshot, JsonOptions);
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
AccountGetQuotaResultQuotaSnapshotsValue? deserialized =
element.Deserialize<AccountGetQuotaResultQuotaSnapshotsValue>(JsonOptions);
return deserialized is null ? null : Map(deserialized);
}
}
@@ -18,6 +18,7 @@ public sealed class SidecarProtocolHost
private const string ListSessionsCommandType = "list-sessions";
private const string DeleteSessionCommandType = "delete-session";
private const string DisconnectSessionCommandType = "disconnect-session";
private const string GetQuotaCommandType = "get-quota";
private const string AskUserToolName = "ask_user";
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
{
@@ -82,6 +83,7 @@ public sealed class SidecarProtocolHost
[ListSessionsCommandType] = HandleListSessionsAsync,
[DeleteSessionCommandType] = HandleDeleteSessionAsync,
[DisconnectSessionCommandType] = HandleDisconnectSessionAsync,
[GetQuotaCommandType] = HandleGetQuotaAsync,
};
}
@@ -313,6 +315,23 @@ public sealed class SidecarProtocolHost
}, context.CancellationToken).ConfigureAwait(false);
}
private async Task HandleGetQuotaAsync(CommandContext context)
{
_ = DeserializeCommand<GetQuotaCommandDto>(context);
IReadOnlyDictionary<string, QuotaSnapshotDto> quotaSnapshots =
await _sessionManager.GetQuotaAsync(context.CancellationToken).ConfigureAwait(false);
await WriteAsync(context.Output, new AccountQuotaResultEventDto
{
Type = "quota-result",
RequestId = context.Envelope.RequestId,
QuotaSnapshots = quotaSnapshots.ToDictionary(
snapshot => snapshot.Key,
snapshot => snapshot.Value,
StringComparer.Ordinal),
}, context.CancellationToken).ConfigureAwait(false);
}
private TCommand DeserializeCommand<TCommand>(CommandContext context)
where TCommand : SidecarCommandEnvelope
{
@@ -251,6 +251,69 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Empty(state.DrainPendingEvents());
}
[Fact]
public void ObserveSessionEvent_AssistantUsage_QueuesAssistantUsageEvent()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.usage",
"data": {
"model": "gpt-5.4",
"inputTokens": 1200,
"outputTokens": 300,
"cacheReadTokens": 50,
"cacheWriteTokens": 10,
"cost": 0.42,
"duration": 8200,
"quotaSnapshots": {
"premium_interactions": {
"entitlementRequests": 50,
"usedRequests": 12,
"remainingPercentage": 76,
"overage": 0,
"overageAllowedWithExhaustedQuota": true,
"resetDate": "2026-04-01T00:00:00Z"
}
},
"copilotUsage": {
"tokenDetails": [
{
"batchSize": 1,
"costPerBatch": 1,
"tokenCount": 1500,
"tokenType": "input"
}
],
"totalNanoAiu": 1200000000
}
},
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
AssistantUsageEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<AssistantUsageEventDto>());
Assert.Equal("session-1", evt.SessionId);
Assert.Equal("agent-1", evt.AgentId);
Assert.Equal("Primary", evt.AgentName);
Assert.Equal("gpt-5.4", evt.Model);
Assert.Equal(1200, evt.InputTokens);
Assert.Equal(300, evt.OutputTokens);
Assert.Equal(0.42, evt.Cost);
Assert.Equal(8200, evt.Duration);
Assert.Equal(1200000000, evt.TotalNanoAiu);
QuotaSnapshotDto snapshot = Assert.Single(evt.QuotaSnapshots!.Values);
Assert.Equal(50, snapshot.EntitlementRequests);
Assert.Equal(12, snapshot.UsedRequests);
Assert.Equal(76, snapshot.RemainingPercentage);
}
[Fact]
public void ObserveSessionEvent_SessionCompactionComplete_QueuesCompactionEvent()
{
@@ -850,6 +850,44 @@ public sealed class SidecarProtocolHostTests
Assert.Equal("session-1", sessionManager.DeletedAryxSessionId);
}
[Fact]
public async Task GetQuotaCommand_ReturnsQuotaResultEvent()
{
SidecarProtocolHost host = new(
new PatternValidator(),
sessionManager: new FakeSessionManager
{
QuotaSnapshots = new Dictionary<string, QuotaSnapshotDto>(StringComparer.Ordinal)
{
["premium_interactions"] = new()
{
EntitlementRequests = 50,
UsedRequests = 12,
RemainingPercentage = 76,
Overage = 0,
OverageAllowedWithExhaustedQuota = true,
ResetDate = "2026-04-01T00:00:00Z",
},
},
});
IReadOnlyList<JsonElement> events = await RunHostAsync(
new GetQuotaCommandDto
{
Type = "get-quota",
RequestId = "quota-1",
},
host);
JsonElement quotaEvent = AssertSingleEvent(events, "quota-result", "quota-1");
JsonElement snapshot = quotaEvent.GetProperty("quotaSnapshots").GetProperty("premium_interactions");
Assert.Equal(50, snapshot.GetProperty("entitlementRequests").GetDouble());
Assert.Equal(12, snapshot.GetProperty("usedRequests").GetDouble());
Assert.Equal(76, snapshot.GetProperty("remainingPercentage").GetDouble());
Assert.True(snapshot.GetProperty("overageAllowedWithExhaustedQuota").GetBoolean());
Assert.Equal("2026-04-01T00:00:00Z", snapshot.GetProperty("resetDate").GetString());
}
[Fact]
public async Task DisconnectSessionCommand_CancelsActiveTurnsForSession()
{
@@ -1115,6 +1153,9 @@ public sealed class SidecarProtocolHostTests
public IReadOnlyList<CopilotSessionInfoDto> DeletedSessions { get; init; } = [];
public IReadOnlyDictionary<string, QuotaSnapshotDto> QuotaSnapshots { get; init; }
= new Dictionary<string, QuotaSnapshotDto>(StringComparer.Ordinal);
public string? DeletedAryxSessionId { get; private set; }
public string? DeletedCopilotSessionId { get; private set; }
@@ -1135,5 +1176,11 @@ public sealed class SidecarProtocolHostTests
DeletedCopilotSessionId = copilotSessionId;
return Task.FromResult(DeletedSessions);
}
public Task<IReadOnlyDictionary<string, QuotaSnapshotDto>> GetQuotaAsync(
CancellationToken cancellationToken)
{
return Task.FromResult(QuotaSnapshots);
}
}
}