Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .github/scripts/Aggregate-CopilotTokenUsage.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env pwsh
#Requires -Modules Pester
<#
.SYNOPSIS
Pester tests for Aggregate-CopilotTokenUsage.ps1.
#>

Describe 'Aggregate-CopilotTokenUsage.ps1' {
BeforeEach {
$script:fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) "token-usage-fixtures-$([guid]::NewGuid())"
$script:inputRoot = Join-Path $script:fixtureRoot 'input'
$script:outputRoot = Join-Path $script:fixtureRoot 'output'
New-Item -ItemType Directory -Path $script:inputRoot -Force | Out-Null
}

AfterEach {
Remove-Item -Path $script:fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue
}

It 'writes raw and summarized artifacts with zero rows for stages without Copilot invocations' {
$nested = Join-Path $script:inputRoot 'CopilotLogs/copilot-token-usage/raw'
New-Item -ItemType Directory -Path $nested -Force | Out-Null

[ordered]@{
schemaVersion = 1
prNumber = 35677
pipeline = [ordered]@{ stageName = 'ReviewPR' }
scriptPhase = 'CopilotReview'
copilotStep = 'STEP 5a: TRY-FIX'
model = 'gpt-5.5'
durationMs = 5000
apiDurationMs = 2000
turnCount = 2
toolCount = 3
cliUsage = [ordered]@{
aicUsed = 7.5
contextWindow = 1100000
contextWindowRaw = '1.1M'
}
normalizedTokens = [ordered]@{
inputTokens = 100
outputTokens = 40
cachedInputTokens = 10
reasoningOutputTokens = 5
totalTokens = 140
}
} | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $nested 'copilot-token-usage-a.json') -Encoding UTF8

$scriptPath = Join-Path $PSScriptRoot 'shared/Aggregate-CopilotTokenUsage.ps1'
& $scriptPath `
-InputRoot $script:inputRoot `
-OutputDir $script:outputRoot `
-PRNumber '35677' `
-ExpectedStages @('ReviewPR', 'RunDeepUITests', 'UpdateAISummaryComment', 'AnalyzeCopilotTokenUsage')

Test-Path (Join-Path $script:outputRoot 'token-usage-raw.jsonl') | Should -Be $true
Test-Path (Join-Path $script:outputRoot 'token-usage-summary.md') | Should -Be $true
Test-Path (Join-Path $script:outputRoot 'token-usage-by-step.csv') | Should -Be $true

$summary = Get-Content (Join-Path $script:outputRoot 'token-usage-summary.json') -Raw | ConvertFrom-Json
$summary.recordCount | Should -Be 1
$summary.totals.inputTokens | Should -Be 100
$summary.totals.outputTokens | Should -Be 40
$summary.totals.cachedInputTokens | Should -Be 10
$summary.totals.reasoningOutputTokens | Should -Be 5
$summary.totals.totalTokens | Should -Be 140
$summary.totals.aicUsed | Should -Be 7.5

$reviewStage = $summary.stages | Where-Object { $_.stageName -eq 'ReviewPR' }
$reviewStage.invocationCount | Should -Be 1
$reviewStage.totalTokens | Should -Be 140
$reviewStage.reasoningOutputTokens | Should -Be 5
$reviewStage.aicUsed | Should -Be 7.5

$deepStage = $summary.stages | Where-Object { $_.stageName -eq 'RunDeepUITests' }
$deepStage.invocationCount | Should -Be 0
$deepStage.totalTokens | Should -Be 0
$deepStage.reasoningOutputTokens | Should -Be 0
$deepStage.aicUsed | Should -Be 0
$deepStage.note | Should -Be 'No Copilot invocation observed in this stage.'
}

It 'emits a no-record summary when the input artifact is missing' {
$scriptPath = Join-Path $PSScriptRoot 'shared/Aggregate-CopilotTokenUsage.ps1'
& $scriptPath `
-InputRoot (Join-Path $script:fixtureRoot 'missing') `
-OutputDir $script:outputRoot `
-PRNumber '35677'

$summary = Get-Content (Join-Path $script:outputRoot 'token-usage-summary.json') -Raw | ConvertFrom-Json
$summary.recordCount | Should -Be 0
($summary.stages | Where-Object { $_.stageName -eq 'ReviewPR' }).invocationCount | Should -Be 0
Test-Path (Join-Path $script:outputRoot 'token-usage-by-step.csv') | Should -Be $true
}
}
41 changes: 41 additions & 0 deletions .github/scripts/Post-AISummaryComment.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ BeforeAll {
foreach ($functionName in @(
'Test-PhaseContentIsNoOp',
'Get-AIReviewEvent',
'Test-RunValidationFailed',
'Test-HasNonPRWinner',
'Get-AIReviewEventForRun',
'New-FutureActionSection'
Expand Down Expand Up @@ -132,6 +133,46 @@ Describe 'Get-AIReviewEventForRun' {
Should -Be 'APPROVE'
}

It 'vetoes APPROVE to REQUEST_CHANGES when the trusted gate-result is FAILED' {
$gateDir = Join-Path $script:testDir 'gate'
New-Item -ItemType Directory -Path $gateDir -Force | Out-Null
'FAILED' | Set-Content (Join-Path $gateDir 'gate-result.txt') -Encoding UTF8

Get-AIReviewEventForRun -ReportContent '## ✅ Final Recommendation: APPROVE' -PRAgentDir $script:testDir |
Should -Be 'REQUEST_CHANGES'
}

It 'keeps APPROVE when the trusted gate-result is PASSED (ignores a forged content.md)' {
$gateDir = Join-Path $script:testDir 'gate'
New-Item -ItemType Directory -Path $gateDir -Force | Out-Null
'PASSED' | Set-Content (Join-Path $gateDir 'gate-result.txt') -Encoding UTF8
# A forged content.md claiming PASSED must be irrelevant — the veto keys off gate-result.txt.
'Gate Result: ✅ PASSED' | Set-Content (Join-Path $gateDir 'content.md') -Encoding UTF8

Get-AIReviewEventForRun -ReportContent '## ✅ Final Recommendation: APPROVE' -PRAgentDir $script:testDir |
Should -Be 'APPROVE'
}

It 'vetoes APPROVE when deep UI tests report failures (real render format)' {
$uiDir = Join-Path $script:testDir 'uitests'
New-Item -ItemType Directory -Path $uiDir -Force | Out-Null
'❌ **Deep UI tests** — 12 passed, 3 failed across 4 categories on platform-pool agent (replaces in-process counts above).' |
Set-Content (Join-Path $uiDir 'content.md') -Encoding UTF8

Get-AIReviewEventForRun -ReportContent 'Final Recommendation: APPROVE' -PRAgentDir $script:testDir |
Should -Be 'REQUEST_CHANGES'
}

It 'keeps APPROVE when deep UI tests pass (TRX-marked-failed wording does not false-trigger)' {
$uiDir = Join-Path $script:testDir 'uitests'
New-Item -ItemType Directory -Path $uiDir -Force | Out-Null
'✅ **Deep UI tests** — 50 passed; 2 setup categories (1 marked failed by TRX) across 4 categories on platform-pool agent.' |
Set-Content (Join-Path $uiDir 'content.md') -Encoding UTF8

Get-AIReviewEventForRun -ReportContent 'Final Recommendation: APPROVE' -PRAgentDir $script:testDir |
Should -Be 'APPROVE'
}

It 'does not force changes for missing, malformed, or PR-fix winner files' {
Get-AIReviewEventForRun -ReportContent '' -PRAgentDir $script:testDir |
Should -Be 'COMMENT'
Expand Down
217 changes: 217 additions & 0 deletions .github/scripts/Review-PR.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Get-TrxResults (parses VSTest TRX produced by `dotnet test --logger trx`)
- Get-DotNetTestResults (legacy console-output scraper, still used as fallback
when TRX is missing)
- Copilot token usage helpers

These functions sit on the critical path of STEP 3 (UI Test Execution
Results in the AI summary review). A regression here can silently
Expand Down Expand Up @@ -41,6 +42,206 @@ BeforeAll {

Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-TrxResults')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-DotNetTestResults')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Test-IsNumericValue')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'ConvertTo-AzdoSafeConsole')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-ObjectMemberValue')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-CopilotUsageTokenFields')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-TokenFieldSum')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-TokenFieldPathDepth')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Select-CanonicalTokenFields')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-CopilotTokenMetrics')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Convert-CopilotCompactNumber')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-CopilotCliUsageLineData')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-CopilotOtelTokenMetrics')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'New-CopilotTokenUsageRecord')
}

Describe 'Copilot token usage helpers' {
It 'normalizes known token fields while preserving raw token field paths' {
$usage = [pscustomobject]@{
inputTokens = 100
outputTokens = 40
totalApiDurationMs = 1234
nested = [pscustomobject]@{
cachedInputTokens = 12
}
}

$metrics = Get-CopilotTokenMetrics -Usage $usage

$metrics.inputTokens | Should -Be 100
$metrics.outputTokens | Should -Be 40
$metrics.cachedInputTokens | Should -Be 12
$metrics.totalTokens | Should -Be 140
@($metrics.rawTokenFields).Count | Should -Be 3
@($metrics.rawTokenFields | Where-Object { $_.Path -eq 'nested.cachedInputTokens' }).Count | Should -Be 1
}

It 'prefers the root token aggregate over a nested per-model breakdown (no double-count)' {
# Regression guard: a payload carrying BOTH a root aggregate and a per-model
# breakdown must not sum both (1000 + 600 + 400 = 2000); the root wins.
$usage = [pscustomobject]@{
inputTokens = 1000
outputTokens = 200
perModel = @(
[pscustomobject]@{ inputTokens = 600; outputTokens = 120 },
[pscustomobject]@{ inputTokens = 400; outputTokens = 80 }
)
}

$metrics = Get-CopilotTokenMetrics -Usage $usage

$metrics.inputTokens | Should -Be 1000
$metrics.outputTokens | Should -Be 200
}

It 'sums a nested-only token breakdown when no root aggregate exists' {
# When only the per-model breakdown is present, it should be summed.
$usage = [pscustomobject]@{
perModel = @(
[pscustomobject]@{ inputTokens = 600 },
[pscustomobject]@{ inputTokens = 400 }
)
}

$metrics = Get-CopilotTokenMetrics -Usage $usage

$metrics.inputTokens | Should -Be 1000
}

It 'parses Copilot CLI AIC and context footer lines' {
$aicLine = Get-CopilotCliUsageLineData -Line 'Session: 1030 AIC used'
$contextLine = Get-CopilotCliUsageLineData -Line 'GPT-5.5 • 1.1M context'

$aicLine.aicUsed | Should -Be 1030
$contextLine.model | Should -Be 'GPT-5.5'
$contextLine.contextWindowRaw | Should -Be '1.1M'
$contextLine.contextWindow | Should -Be 1100000
}

It 'reads token counts from Copilot OTel spans with both cache/reasoning naming variants' {
$otelPath = Join-Path ([System.IO.Path]::GetTempPath()) "copilot-otel-$([guid]::NewGuid()).jsonl"
try {
@(
[ordered]@{
type = 'span'
attributes = [ordered]@{
'gen_ai.usage.input_tokens' = 1000
'gen_ai.usage.output_tokens' = 200
'gen_ai.usage.cache_read.input_tokens' = 800
'gen_ai.usage.reasoning.output_tokens' = 50
'github.copilot.cost' = 7.5
}
},
[ordered]@{
type = 'span'
attributes = [ordered]@{
'gen_ai.usage.input_tokens' = 500
'gen_ai.usage.output_tokens' = 40
'gen_ai.usage.cache_read_input_tokens' = 400
'gen_ai.usage.reasoning_output_tokens' = 10
}
}
) | ForEach-Object { $_ | ConvertTo-Json -Depth 10 -Compress } | Set-Content $otelPath -Encoding UTF8

$metrics = Get-CopilotOtelTokenMetrics -Path $otelPath

$metrics.available | Should -Be $true
$metrics.inputTokens | Should -Be 1500
$metrics.outputTokens | Should -Be 240
$metrics.cachedInputTokens | Should -Be 1200
$metrics.reasoningOutputTokens | Should -Be 60
$metrics.totalTokens | Should -Be 1740
$metrics.copilotCost | Should -Be 7.5
} finally {
Remove-Item $otelPath -Force -ErrorAction SilentlyContinue
}
}

It 'builds a telemetry record with raw usage and no hardcoded cost estimate' {
$usage = [pscustomobject]@{
prompt_tokens = 25
completion_tokens = 15
total_tokens = 45
totalApiDurationMs = 2000
}

$record = New-CopilotTokenUsageRecord `
-PRNumber 35677 `
-Platform 'android' `
-Phase 'CopilotReview' `
-StepName 'STEP 5a: TRY-FIX' `
-ModelName 'gpt-5.5' `
-StartedAtUtc ([DateTimeOffset]::Parse('2026-06-05T10:00:00Z')) `
-EndedAtUtc ([DateTimeOffset]::Parse('2026-06-05T10:00:05Z')) `
-DurationMs 5000 `
-TurnCount 2 `
-ToolCount 3 `
-FailedToolCount 1 `
-Usage $usage `
-OtelMetrics $null `
-AicUsed 1030 `
-ContextWindow 1100000 `
-ContextWindowRaw '1.1M' `
-ResultEventSeen $true `
-ExitCode 0

$record.prNumber | Should -Be 35677
$record.scriptPhase | Should -Be 'CopilotReview'
$record.copilotStep | Should -Be 'STEP 5a: TRY-FIX'
$record.apiDurationMs | Should -Be 2000
$record.normalizedTokens.inputTokens | Should -Be 25
$record.normalizedTokens.outputTokens | Should -Be 15
$record.normalizedTokens.totalTokens | Should -Be 45
$record.cliUsage.aicUsed | Should -Be 1030
$record.cliUsage.contextWindow | Should -Be 1100000
$record.cliUsage.contextWindowRaw | Should -Be '1.1M'
$record.usage.total_tokens | Should -Be 45
$record.costEstimateAvailable | Should -Be $false
}

It 'uses OTel token metrics when result usage has no token fields' {
$otelMetrics = [ordered]@{
inputTokens = 500
outputTokens = 75
cachedInputTokens = 400
reasoningOutputTokens = 25
totalTokens = 575
copilotCost = 7.5
file = '/tmp/copilot-otel.jsonl'
}

$record = New-CopilotTokenUsageRecord `
-PRNumber 35677 `
-Platform 'android' `
-Phase 'CopilotReview' `
-StepName 'STEP 5a: TRY-FIX' `
-ModelName 'gpt-5.5' `
-StartedAtUtc ([DateTimeOffset]::Parse('2026-06-05T10:00:00Z')) `
-EndedAtUtc ([DateTimeOffset]::Parse('2026-06-05T10:00:05Z')) `
-DurationMs 5000 `
-TurnCount 2 `
-ToolCount 3 `
-FailedToolCount 0 `
-Usage ([pscustomobject]@{ totalApiDurationMs = 1000 }) `
-OtelMetrics $otelMetrics `
-AicUsed $null `
-ContextWindow $null `
-ContextWindowRaw $null `
-ResultEventSeen $true `
-ExitCode 0

$record.normalizedTokens.inputTokens | Should -Be 500
$record.normalizedTokens.outputTokens | Should -Be 75
$record.normalizedTokens.cachedInputTokens | Should -Be 400
$record.normalizedTokens.reasoningOutputTokens | Should -Be 25
$record.normalizedTokens.totalTokens | Should -Be 575
$record.normalizedTokens.otelFile | Should -Be '/tmp/copilot-otel.jsonl'
# aicUsed stays AIC-only (null here); the dollar cost is reported in its own field,
# never conflated into aicUsed.
$record.cliUsage.aicUsed | Should -BeNullOrEmpty
$record.cliUsage.copilotCost | Should -Be 7.5
}
}

Describe 'Get-TrxResults' {
Expand Down Expand Up @@ -235,3 +436,19 @@ Describe 'Get-DotNetTestResults (console-scrape fallback)' {
(Get-DotNetTestResults -Lines @()).Count | Should -Be 0
}
}

Describe 'ConvertTo-AzdoSafeConsole' {
It 'defangs ##vso[ and ##[ logging-command prefixes' {
ConvertTo-AzdoSafeConsole '##vso[task.setvariable variable=x]y' | Should -Be '## vso[task.setvariable variable=x]y'
ConvertTo-AzdoSafeConsole '##[command]z' | Should -Be '## [command]z'
}

It 'collapses CR/LF that could fabricate a fresh column-0 log line' {
ConvertTo-AzdoSafeConsole "safe`r##vso[task.complete]" | Should -Be 'safe ## vso[task.complete]'
ConvertTo-AzdoSafeConsole "Reviewing`n##vso[task.complete result=Succeeded;]done" | Should -Be 'Reviewing ## vso[task.complete result=Succeeded;]done'
}

It 'leaves ordinary text untouched' {
ConvertTo-AzdoSafeConsole 'Reading file src/Foo.cs (## of total)' | Should -Be 'Reading file src/Foo.cs (## of total)'
}
}
Loading
Loading