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
@@ -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);
}
}
}