diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 2e1de020..45bc68e6 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -26,6 +26,8 @@ jobs: # you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, # Copilot will do this for you automatically after the steps complete. contents: read + # Required to read and write the GitHub Actions cache for agent-sessions data + actions: write # You can define any steps you want, and they will run before the agent starts. # If you do not check out your code, Copilot will do this for you. @@ -180,3 +182,32 @@ jobs: else echo "⚠️ No aggregated usage data file created" fi + + # Fetch Copilot cloud-agent session statistics (cached daily to limit API calls) + - name: Set agent sessions cache date key + id: cache-date + run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" + + - name: Restore agent sessions cache + id: restore-agent-sessions-cache + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ./usage-data/agent-sessions.json + key: agent-sessions-${{ github.repository }}-${{ steps.cache-date.outputs.date }} + restore-keys: agent-sessions-${{ github.repository }}- + + - name: Fetch Copilot agent sessions + if: steps.restore-agent-sessions-cache.outputs.cache-hit != 'true' + continue-on-error: true + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + echo "🤖 Fetching Copilot cloud-agent session data (not cached yet for today)..." + node scripts/fetch-agent-sessions.js + + - name: Save agent sessions cache + if: steps.restore-agent-sessions-cache.outputs.cache-hit != 'true' && hashFiles('./usage-data/agent-sessions.json') != '' + uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ./usage-data/agent-sessions.json + key: agent-sessions-${{ github.repository }}-${{ steps.cache-date.outputs.date }} diff --git a/scripts/fetch-agent-sessions.js b/scripts/fetch-agent-sessions.js new file mode 100644 index 00000000..47cc970d --- /dev/null +++ b/scripts/fetch-agent-sessions.js @@ -0,0 +1,234 @@ +#!/usr/bin/env node +/** + * Fetch Copilot cloud-agent session statistics for the current GitHub repository + * and write aggregated results to ./usage-data/agent-sessions.json. + * + * Designed for use in GitHub Actions (copilot-setup-steps.yml). + * Exits 0 even on error so the workflow step is non-fatal. + * + * Environment variables: + * GITHUB_TOKEN — GitHub token with repo scope (set by Actions automatically) + * GITHUB_REPOSITORY — "owner/repo" string (set by Actions automatically) + */ + +'use strict'; + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; +const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || ''; +const OUTPUT_PATH = path.join(process.cwd(), 'usage-data', 'agent-sessions.json'); +const MAX_TASKS_DETAIL = 50; +const CONCURRENCY = 5; + +if (!GITHUB_TOKEN) { + console.warn('⚠️ GITHUB_TOKEN not set — skipping agent session fetch'); + writeEmpty('GITHUB_TOKEN not set'); + process.exit(0); +} + +if (!GITHUB_REPOSITORY) { + console.warn('⚠️ GITHUB_REPOSITORY not set — skipping agent session fetch'); + writeEmpty('GITHUB_REPOSITORY not set'); + process.exit(0); +} + +const [owner, repo] = GITHUB_REPOSITORY.split('/'); +if (!owner || !repo) { + console.warn('⚠️ Invalid GITHUB_REPOSITORY format — skipping'); + writeEmpty('Invalid GITHUB_REPOSITORY'); + process.exit(0); +} + +/** @returns {Promise<{statusCode: number, body: string}>} */ +function githubGet(apiPath) { + return new Promise((resolve, reject) => { + const req = https.request( + { + hostname: 'api.github.com', + path: apiPath, + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + 'User-Agent': 'copilot-token-tracker/fetch-agent-sessions', + Accept: 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve({ statusCode: res.statusCode || 0, body })); + }, + ); + req.on('error', reject); + req.setTimeout(20000, () => req.destroy(new Error('Timed out'))); + req.end(); + }); +} + +/** Detect whether a session came from the cloud agent or a CLI/remote session. */ +function detectSessionSource(session) { + if (session.model !== undefined && session.model !== '') { return 'cloud-agent'; } + if (Object.prototype.hasOwnProperty.call(session, 'usage') && + session.usage !== null && session.usage !== undefined) { return 'cloud-agent'; } + if (session.model !== undefined) { return 'cli-remote'; } + return 'unknown'; +} + +async function fetchAllTasks(owner, repo, since) { + const allTasks = []; + const seen = new Set(); + + for (const archived of [false, true]) { + for (let page = 1; page <= 10; page++) { + let qs = `per_page=100&page=${page}`; + if (archived) { qs += '&archived=true'; } + if (since) { qs += `&since=${encodeURIComponent(since)}`; } + + let res; + try { + res = await githubGet(`/agents/repos/${owner}/${repo}/tasks?${qs}`); + } catch (e) { + console.warn(`⚠️ Request error fetching tasks (page ${page}, archived=${archived}): ${e.message}`); + break; + } + + if (res.statusCode === 404) { + if (!archived) { + console.warn(`⚠️ Copilot cloud agent not enabled or not accessible for ${owner}/${repo} (HTTP 404)`); + } + return null; // signal: repo not accessible + } + if (res.statusCode === 403) { + console.warn(`⚠️ Access denied for ${owner}/${repo} (HTTP 403)`); + return null; + } + if (res.statusCode < 200 || res.statusCode >= 300) { + console.warn(`⚠️ HTTP ${res.statusCode} fetching tasks (page ${page})`); + break; + } + + let tasks; + try { + const parsed = JSON.parse(res.body); + tasks = Array.isArray(parsed?.tasks) ? parsed.tasks : (Array.isArray(parsed) ? parsed : []); + } catch (e) { + console.warn(`⚠️ Failed to parse tasks response: ${e.message}`); + break; + } + + if (tasks.length === 0) { break; } + for (const t of tasks) { + if (!seen.has(t.id)) { seen.add(t.id); allTasks.push(t); } + } + if (tasks.length < 100) { break; } + } + } + + return allTasks; +} + +async function fetchTaskDetail(owner, repo, taskId) { + try { + const res = await githubGet(`/agents/repos/${owner}/${repo}/tasks/${encodeURIComponent(taskId)}`); + if (res.statusCode < 200 || res.statusCode >= 300) { return null; } + const parsed = JSON.parse(res.body); + return Array.isArray(parsed?.sessions) ? parsed.sessions : []; + } catch (e) { + return null; + } +} + +function writeEmpty(reason) { + const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const result = { + repos: [], + totalTasks: 0, + totalSessions: 0, + totalCredits: 0, + authenticated: !!GITHUB_TOKEN, + since, + fetchedAt: new Date().toISOString(), + skippedReason: reason, + }; + fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(result, null, 2), 'utf8'); +} + +async function main() { + const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const sinceStr = since.toISOString(); + + console.log(`🤖 Fetching Copilot cloud-agent sessions for ${owner}/${repo} since ${sinceStr}...`); + + const allTasks = await fetchAllTasks(owner, repo, sinceStr); + if (allTasks === null) { + // API not accessible — write empty result (non-fatal) + writeEmpty('API not accessible'); + console.log('✅ Written empty agent sessions result'); + return; + } + + const tasksTotal = allTasks.length; + const tasksToDetail = allTasks.slice(0, MAX_TASKS_DETAIL); + const partial = tasksTotal > MAX_TASKS_DETAIL; + + console.log(` Found ${tasksTotal} tasks, fetching details for ${tasksToDetail.length}${partial ? ' (capped)' : ''}...`); + + let totalTasks = 0; + let totalSessions = 0; + let totalCredits = 0; + + for (let i = 0; i < tasksToDetail.length; i += CONCURRENCY) { + const batch = tasksToDetail.slice(i, i + CONCURRENCY); + const results = await Promise.all(batch.map(t => fetchTaskDetail(owner, repo, t.id))); + for (const sessions of results) { + if (!sessions || sessions.length === 0) { continue; } + const cloudSessions = sessions.filter(s => detectSessionSource(s) === 'cloud-agent'); + if (cloudSessions.length > 0) { + totalTasks++; + totalSessions += cloudSessions.length; + for (const s of cloudSessions) { + if (s.usage && typeof s.usage.credits === 'number') { + totalCredits += s.usage.credits; + } + } + } + } + } + + const repoSummary = { + owner, + repo, + totalTasks, + totalSessions, + totalCredits, + tasksScanned: tasksToDetail.length, + tasksTotal, + partial, + }; + + const result = { + repos: [repoSummary], + totalTasks, + totalSessions, + totalCredits, + authenticated: true, + since: sinceStr, + fetchedAt: new Date().toISOString(), + }; + + fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(result, null, 2), 'utf8'); + + console.log(`✅ Agent sessions: ${totalTasks} tasks, ${totalSessions} sessions, ${totalCredits.toFixed(1)} credits${partial ? ' (partial)' : ''}`); + console.log(` Written to ${OUTPUT_PATH}`); +} + +main().catch((e) => { + console.warn(`⚠️ Unexpected error in fetch-agent-sessions: ${e.message}`); + writeEmpty(`Error: ${e.message}`); + process.exit(0); +}); diff --git a/vscode-extension/src/agentSessionsService.ts b/vscode-extension/src/agentSessionsService.ts new file mode 100644 index 00000000..9bddbe68 --- /dev/null +++ b/vscode-extension/src/agentSessionsService.ts @@ -0,0 +1,256 @@ +import * as https from 'https'; + +export type AgentSessionSource = 'cloud-agent' | 'cli-remote' | 'unknown'; + +export interface AgentRepoSummary { + owner: string; + repo: string; + /** Number of cloud-agent tasks found (tasks with at least one cloud-agent session). */ + totalTasks: number; + /** Total cloud-agent sessions across all tasks. */ + totalSessions: number; + /** Sum of usage.credits for all cloud-agent sessions (0 when unavailable). */ + totalCredits: number; + /** How many tasks we fetched full details for (may be less than tasksTotal when capped). */ + tasksScanned: number; + /** Total tasks found in the list API before detail fetch cap. */ + tasksTotal: number; + /** True when the detail fetch was capped — totals are a lower bound. */ + partial: boolean; + error?: string; +} + +export interface AgentSessionsResult { + repos: AgentRepoSummary[]; + totalTasks: number; + totalSessions: number; + totalCredits: number; + authenticated: boolean; + since: string; + fetchedAt: string; +} + +/** Maximum number of task detail fetches per repo to avoid API rate-limit spikes. */ +const MAX_TASKS_DETAIL_PER_REPO = 50; + +/** + * Detect whether an agent session came from the GitHub Copilot cloud agent or a CLI/remote session. + * + * Heuristic from the undocumented agent API (may change): + * cloud-agent: model field is non-empty (e.g. "sweagent-capi:claude-sonnet-4") OR usage field present + * cli-remote: model field present but empty string + * unknown: model field absent entirely + */ +export function detectSessionSource(session: { model?: string; usage?: unknown }): AgentSessionSource { + if (session.model !== undefined && session.model !== '') { return 'cloud-agent'; } + if (Object.prototype.hasOwnProperty.call(session, 'usage') && session.usage !== null && session.usage !== undefined) { return 'cloud-agent'; } + if (session.model !== undefined) { return 'cli-remote'; } + return 'unknown'; +} + +// --------------------------------------------------------------------------- +// Low-level HTTP helpers (injectable for testing) +// --------------------------------------------------------------------------- + +export interface TaskPageResult { + tasks: any[]; + statusCode?: number; + error?: string; +} + +export interface TaskDetailResult { + sessions?: any[]; + statusCode?: number; + error?: string; +} + +export type FetchTaskPageFn = ( + owner: string, repo: string, token: string, + page: number, archived: boolean, since?: string, +) => Promise; + +export type FetchTaskDetailFn = ( + owner: string, repo: string, taskId: string, token: string, +) => Promise; + +/** Fetch one page of agent tasks from the GitHub API. */ +export function fetchAgentTasksPage( + owner: string, + repo: string, + token: string, + page: number, + archived: boolean, + since?: string, +): Promise { + return new Promise((resolve) => { + let queryParams = `per_page=100&page=${page}`; + if (archived) { queryParams += '&archived=true'; } + if (since) { queryParams += `&since=${encodeURIComponent(since)}`; } + + const req = https.request( + { + hostname: 'api.github.com', + path: `/agents/repos/${owner}/${repo}/tasks?${queryParams}`, + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': 'copilot-token-tracker', + Accept: 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + const statusCode = res.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + resolve({ tasks: [], statusCode, error: `HTTP ${statusCode}` }); + return; + } + try { + const parsed = JSON.parse(data); + const tasks = Array.isArray(parsed?.tasks) + ? parsed.tasks + : (Array.isArray(parsed) ? parsed : []); + resolve({ tasks, statusCode }); + } catch (e) { + resolve({ tasks: [], statusCode, error: String(e) }); + } + }); + }, + ); + req.on('error', (e) => resolve({ tasks: [], error: e.message })); + req.setTimeout(15000, () => { req.destroy(new Error('Request timed out')); }); + req.end(); + }); +} + +/** Fetch session details for a single agent task. */ +export function fetchAgentTaskDetail( + owner: string, + repo: string, + taskId: string, + token: string, +): Promise { + return new Promise((resolve) => { + const req = https.request( + { + hostname: 'api.github.com', + path: `/agents/repos/${owner}/${repo}/tasks/${encodeURIComponent(taskId)}`, + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': 'copilot-token-tracker', + Accept: 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + const statusCode = res.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + resolve({ statusCode, error: `HTTP ${statusCode}` }); + return; + } + try { + const parsed = JSON.parse(data); + const sessions = Array.isArray(parsed?.sessions) ? parsed.sessions : []; + resolve({ sessions, statusCode }); + } catch (e) { + resolve({ error: String(e) }); + } + }); + }, + ); + req.on('error', (e) => resolve({ error: e.message })); + req.setTimeout(15000, () => { req.destroy(new Error('Request timed out')); }); + req.end(); + }); +} + +// --------------------------------------------------------------------------- +// High-level aggregation +// --------------------------------------------------------------------------- + +/** + * Fetch and aggregate cloud-agent session stats for a single GitHub repository. + * + * Only cloud-agent sessions (model != '' or usage present) are counted. + * CLI-remote sessions that appear in the same tasks are excluded so they are + * not double-counted with the chat-session data already tracked by this extension. + * + * Fetches are capped at MAX_TASKS_DETAIL_PER_REPO task-detail calls to limit + * API usage. When the cap is hit, `partial` is set to true and totals are + * conservative lower bounds. + */ +export async function fetchAgentSessionsForRepo( + owner: string, + repo: string, + token: string, + since: Date, + fetchTaskPage: FetchTaskPageFn = fetchAgentTasksPage, + fetchTaskDetail: FetchTaskDetailFn = fetchAgentTaskDetail, +): Promise { + const sinceStr = since.toISOString(); + const allTasks: any[] = []; + const seen = new Set(); + + // Fetch active and archived task lists + for (const archived of [false, true]) { + for (let page = 1; page <= 5; page++) { + const { tasks, statusCode, error } = await fetchTaskPage(owner, repo, token, page, archived, sinceStr); + if (tasks.length === 0 || error) { + if (page === 1 && !archived && error) { + const msg = statusCode === 404 + ? 'Copilot cloud agent not enabled or not accessible for this repo' + : statusCode === 403 + ? 'Access denied — check that your GitHub token has repo scope' + : `API error (HTTP ${statusCode ?? 'unknown'})`; + return { owner, repo, totalTasks: 0, totalSessions: 0, totalCredits: 0, tasksScanned: 0, tasksTotal: 0, partial: false, error: msg }; + } + break; + } + for (const t of tasks) { + if (!seen.has(t.id)) { seen.add(t.id); allTasks.push(t); } + } + if (tasks.length < 100) { break; } + } + } + + const tasksTotal = allTasks.length; + const tasksToDetail = allTasks.slice(0, MAX_TASKS_DETAIL_PER_REPO); + const partial = tasksTotal > MAX_TASKS_DETAIL_PER_REPO; + + let totalTasks = 0; + let totalSessions = 0; + let totalCredits = 0; + + // Fetch task details in small concurrent batches + const CONCURRENCY = 5; + for (let i = 0; i < tasksToDetail.length; i += CONCURRENCY) { + const batch = tasksToDetail.slice(i, i + CONCURRENCY); + const results = await Promise.all( + batch.map(task => fetchTaskDetail(owner, repo, task.id, token)) + ); + for (const { sessions } of results) { + if (!sessions || sessions.length === 0) { continue; } + const cloudSessions = sessions.filter(s => detectSessionSource(s) === 'cloud-agent'); + if (cloudSessions.length > 0) { + totalTasks++; + totalSessions += cloudSessions.length; + for (const s of cloudSessions) { + if (s.usage && typeof s.usage.credits === 'number') { + totalCredits += s.usage.credits; + } + } + } + } + } + + return { + owner, repo, + totalTasks, totalSessions, totalCredits, + tasksScanned: tasksToDetail.length, tasksTotal, partial, + }; +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 8ad0536a..baeedf0d 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -27,6 +27,7 @@ import { type RepoPrInfo, type RepoPrStatsResult, } from './githubPrService'; +import { fetchAgentSessionsForRepo } from './agentSessionsService'; import type { TokenUsageStats, @@ -63,7 +64,8 @@ import type { ActualUsage, ChatTurn, SessionLogData, - WorkspaceCustomizationSummary + WorkspaceCustomizationSummary, + AgentSessionsResult, } from './types'; import { OpenCodeDataAccess } from './opencode'; import { CrushDataAccess } from './crush'; @@ -306,6 +308,9 @@ class CopilotTokenTracker implements vscode.Disposable { // Cached PR stats result for the repos tab private _lastRepoPrStats?: RepoPrStatsResult; + // Cached cloud agent sessions result for the cloud agent tab + private _lastAgentSessionsData?: AgentSessionsResult; + // Tool name mapping - loaded from toolNames.json for friendly display names private toolNameMap: { [key: string]: string } = toolNamesData as { [key: string]: string }; @@ -1234,6 +1239,9 @@ class CopilotTokenTracker implements vscode.Disposable { const result: RepoPrStatsResult = { repos: [], authenticated: false, since: since.toISOString() }; this._lastRepoPrStats = result; this.analysisPanel.webview.postMessage({ command: 'repoPrStatsLoaded', data: result }); + const agentResult: AgentSessionsResult = { repos: [], totalTasks: 0, totalSessions: 0, totalCredits: 0, authenticated: false, since: since.toISOString(), fetchedAt: new Date().toISOString() }; + this._lastAgentSessionsData = agentResult; + this.analysisPanel.webview.postMessage({ command: 'agentSessionsLoaded', data: agentResult }); } } catch (error) { this.error('Failed to sign out from GitHub:', error); @@ -1359,6 +1367,63 @@ class CopilotTokenTracker implements vscode.Disposable { this.analysisPanel.webview.postMessage({ command: 'repoPrStatsLoaded', data: result }); } + /** + * Load Copilot cloud agent session stats for all discovered GitHub repos and send to the analysis panel. + * Only cloud-agent sessions are counted — CLI/remote sessions that share the same task API are excluded + * so they are not double-counted with the chat-session data already shown in "My Activity". + */ + private async loadAgentSessions(): Promise { + if (!this.analysisPanel) { return; } + + const since = new Date(); + since.setDate(since.getDate() - 30); + + if (this._githubSignedOutByUser) { + const result: AgentSessionsResult = { repos: [], totalTasks: 0, totalSessions: 0, totalCredits: 0, authenticated: false, since: since.toISOString(), fetchedAt: new Date().toISOString() }; + this._lastAgentSessionsData = result; + this.analysisPanel.webview.postMessage({ command: 'agentSessionsLoaded', data: result }); + return; + } + + const session = await vscode.authentication.getSession('github', ['read:user'], { createIfNone: false }); + if (!session) { + const result: AgentSessionsResult = { repos: [], totalTasks: 0, totalSessions: 0, totalCredits: 0, authenticated: false, since: since.toISOString(), fetchedAt: new Date().toISOString() }; + this._lastAgentSessionsData = result; + this.analysisPanel.webview.postMessage({ command: 'agentSessionsLoaded', data: result }); + return; + } + + if (!this.githubSession) { + this.githubSession = session; + await this.context.globalState.update('github.authenticated', true); + await this.context.globalState.update('github.username', session.account.label); + } + + const workspacePaths = this._buildWorkspacePaths(); + const repos = discoverGitHubRepos(workspacePaths); + this.analysisPanel.webview.postMessage({ command: 'agentSessionsProgress', total: repos.length, done: 0 }); + + const repoResults = []; + for (let i = 0; i < repos.length; i++) { + const { owner, repo } = repos[i]; + const summary = await fetchAgentSessionsForRepo(owner, repo, session.accessToken, since); + repoResults.push(summary); + this.analysisPanel.webview.postMessage({ command: 'agentSessionsProgress', total: repos.length, done: i + 1 }); + } + + const result: AgentSessionsResult = { + repos: repoResults, + totalTasks: repoResults.reduce((s, r) => s + r.totalTasks, 0), + totalSessions: repoResults.reduce((s, r) => s + r.totalSessions, 0), + totalCredits: repoResults.reduce((s, r) => s + r.totalCredits, 0), + authenticated: true, + since: since.toISOString(), + fetchedAt: new Date().toISOString(), + }; + this._lastAgentSessionsData = result; + this.analysisPanel.webview.postMessage({ command: 'agentSessionsLoaded', data: result }); + } + /** Collect workspace paths from the customization matrix and currently open VS Code workspace folders. */ private _buildWorkspacePaths(): string[] { const workspacePaths: string[] = []; @@ -4425,6 +4490,9 @@ usageAnalysis: undefined case 'loadRepoPrStats': await this.dispatch('loadRepoPrStats', () => this.loadRepoPrStats()); break; + case 'loadAgentSessions': + await this.dispatch('loadAgentSessions', () => this.loadAgentSessions()); + break; } }); diff --git a/vscode-extension/src/types.ts b/vscode-extension/src/types.ts index b1eb8fcb..1561d206 100644 --- a/vscode-extension/src/types.ts +++ b/vscode-extension/src/types.ts @@ -453,6 +453,40 @@ export interface SessionLogData { subAgentsStarted?: number; } +// --------------------------------------------------------------------------- +// GitHub Copilot Cloud Agent session stats +// --------------------------------------------------------------------------- + +export type AgentSessionSource = 'cloud-agent' | 'cli-remote' | 'unknown'; + +export interface AgentRepoSummary { + owner: string; + repo: string; + /** Number of tasks that contained at least one cloud-agent session. */ + totalTasks: number; + /** Total cloud-agent sessions across all scanned tasks. */ + totalSessions: number; + /** Sum of usage.credits for all cloud-agent sessions (0 when unavailable). */ + totalCredits: number; + /** How many tasks we fetched full session details for. */ + tasksScanned: number; + /** Total tasks found in the list API response (before the detail-fetch cap). */ + tasksTotal: number; + /** True when the detail-fetch cap was reached — totals are conservative lower bounds. */ + partial: boolean; + error?: string; +} + +export interface AgentSessionsResult { + repos: AgentRepoSummary[]; + totalTasks: number; + totalSessions: number; + totalCredits: number; + authenticated: boolean; + since: string; + fetchedAt: string; +} + // Local summary type for customization files (mirrors webview/shared/contextRefUtils.ts) export interface WorkspaceCustomizationSummary { workspaces: { diff --git a/vscode-extension/src/webview/usage/main.ts b/vscode-extension/src/webview/usage/main.ts index 6712efbb..4c3e08d2 100644 --- a/vscode-extension/src/webview/usage/main.ts +++ b/vscode-extension/src/webview/usage/main.ts @@ -155,6 +155,10 @@ function showLoadError(message: string): void { let repoPrStatsLoaded = false; let repoPrStatsData: RepoPrStatsResult | null = null; +// State for the Cloud Agent tab +let agentSessionsLoaded = false; +let agentSessionsData: AgentSessionsResult | null = null; + type RepoPrDetail = { number: number; title: string; @@ -180,6 +184,30 @@ type RepoPrStatsResult = { since: string; }; +type AgentRepoSummary = { + owner: string; + repo: string; + /** Pre-validated safe https URL for this repo. */ + repoUrl: string; + totalTasks: number; + totalSessions: number; + totalCredits: number; + tasksScanned: number; + tasksTotal: number; + partial: boolean; + error?: string; +}; + +type AgentSessionsResult = { + repos: AgentRepoSummary[]; + totalTasks: number; + totalSessions: number; + totalCredits: number; + authenticated: boolean; + since: string; + fetchedAt: string; +}; + function escapeHtml(text: string): string { return text .replace(/&/g, '&') @@ -521,6 +549,11 @@ function setupTabs(): void { repoPrStatsLoaded = true; vscode.postMessage({ command: 'loadRepoPrStats' }); } + // Lazy-load cloud agent sessions on first visit to the tab + if (tab === 'agent' && !agentSessionsLoaded) { + agentSessionsLoaded = true; + vscode.postMessage({ command: 'loadAgentSessions' }); + } }); }); } @@ -664,6 +697,38 @@ function renderReposPrContent(data: RepoPrStatsResult): string { `; } +/** Sanitize agent sessions data received from the extension host — escapes all string fields at + * the trust boundary so render functions can interpolate them directly into innerHTML safely. */ +function sanitizeAgentSessionsData(input: unknown): AgentSessionsResult { + const src = (input && typeof input === 'object') ? (input as Record) : {}; + const repos = Array.isArray(src.repos) ? src.repos : []; + return { + authenticated: Boolean(src.authenticated), + since: typeof src.since === 'string' ? escapeHtml(src.since) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + fetchedAt: typeof src.fetchedAt === 'string' ? src.fetchedAt : '', + totalTasks: toSafeNumber(src.totalTasks), + totalSessions: toSafeNumber(src.totalSessions), + totalCredits: toSafeNumber(src.totalCredits), + repos: repos.map((repo) => { + const r = (repo && typeof repo === 'object') ? (repo as Record) : {}; + const owner = escapeHtml(typeof r.owner === 'string' ? r.owner : ''); + const repoName = escapeHtml(typeof r.repo === 'string' ? r.repo : ''); + return { + owner, + repo: repoName, + repoUrl: toSafeHttpUrl(`https://github.com/${owner}/${repoName}`), + totalTasks: toSafeNumber(r.totalTasks), + totalSessions: toSafeNumber(r.totalSessions), + totalCredits: toSafeNumber(r.totalCredits), + tasksScanned: toSafeNumber(r.tasksScanned), + tasksTotal: toSafeNumber(r.tasksTotal), + partial: Boolean(r.partial), + error: typeof r.error === 'string' ? escapeHtml(r.error) : undefined, + }; + }), + }; +} + function updateReposPrPanel(data: RepoPrStatsResult): void { const container = document.querySelector('#repos-pr-content'); if (!container) { return; } @@ -678,6 +743,112 @@ function updateReposPrPanel(data: RepoPrStatsResult): void { `; } +// --------------------------------------------------------------------------- +// Cloud Agent Sessions tab +// --------------------------------------------------------------------------- + +function renderAgentSessionsContent(data: AgentSessionsResult): string { + if (!data.authenticated) { + return ` +
+ 🔒 GitHub authentication required
+ Sign in with GitHub (via the Diagnostics tab) to see Copilot cloud agent session data. +
`; + } + if (data.repos.length === 0) { + return ` +
+ No GitHub repositories detected in your workspace folders. +
`; + } + + const sinceDate = new Date(data.since).toLocaleDateString(); + const cell = 'padding: 6px 8px; border-bottom: 1px solid var(--border-subtle);'; + const cellCenter = `${cell} text-align: center;`; + + const summaryTotals = data.repos.reduce((acc, r) => { + if (!r.error) { + acc.tasks += r.totalTasks; + acc.sessions += r.totalSessions; + acc.credits += r.totalCredits; + } + return acc; + }, { tasks: 0, sessions: 0, credits: 0 }); + + const hasPartial = data.repos.some(r => r.partial && !r.error); + + const rows = data.repos.map((r) => { + // r.owner, r.repo, r.repoUrl and r.error are pre-sanitized by sanitizeAgentSessionsData + const repoLink = `${r.owner}/${r.repo}`; + if (r.error) { + return ` + ${repoLink} + ${r.error} + `; + } + const partialNote = r.partial + ? ` (${r.tasksScanned}/${r.tasksTotal} tasks scanned)` + : ''; + return ` + ${repoLink}${partialNote} + ${r.totalTasks} + ${r.totalSessions} + ${r.totalCredits > 0 ? r.totalCredits.toFixed(1) : '—'} + `; + }).join(''); + + return ` +
+
+
${summaryTotals.tasks}
+
Tasks
+
+
+
${summaryTotals.sessions}
+
Sessions
+
+
+
${summaryTotals.credits > 0 ? summaryTotals.credits.toFixed(1) : '—'}
+
AI Credits
+
+
+
+ Showing cloud-agent sessions from ${sinceDate} to now. + ${hasPartial ? 'Note: Some repos were capped at 50 tasks — totals may be lower bounds. ' : ''} +
+
+ + + + + + + + + + ${rows} +
📂 RepositoryTasksSessionsAI Credits
+
+
+ ℹ️ No double-counting: These are cloud agent sessions only. CLI/remote sessions and local IDE chat sessions (shown in "My Activity") are excluded.
+ ℹ️ Action minutes (GitHub Actions compute used by the agent) are not shown here — they require additional per-branch API calls. +
`; +} + +function updateAgentSessionsPanel(data: AgentSessionsResult): void { + const container = document.querySelector('#agent-sessions-content'); + if (!container) { return; } + container.innerHTML = ` +
🤖Copilot Cloud Agent Sessions
+
+ Cloud agent tasks and sessions from the last 30 days. Each task is a user request to the agent; + each session is an autonomous coding run within that task. + CLI/remote sessions are excluded — they are separate from these cloud agent sessions. +
+ ${renderAgentSessionsContent(data)} + `; +} + function renderLayout(stats: UsageAnalysisStats): void { const root = document.getElementById('root'); if (!root) { @@ -1118,6 +1289,7 @@ function renderLayout(stats: UsageAnalysisStats): void { + + + @@ -1535,6 +1717,10 @@ window.addEventListener('message', (event) => { if (repoPrStatsData) { updateReposPrPanel(repoPrStatsData); } + // Restore cloud agent tab if we already fetched data + if (agentSessionsData) { + updateAgentSessionsPanel(agentSessionsData); + } } else { showLoadError('Received invalid data from the extension. Try refreshing.'); } @@ -1603,6 +1789,43 @@ window.addEventListener('message', (event) => { } break; } + case 'agentSessionsLoaded': { + const raw = message.data; + if (raw && typeof raw === 'object') { + agentSessionsData = sanitizeAgentSessionsData(raw); + if (!agentSessionsData.authenticated) { + // Reset loaded flag so re-authenticating and revisiting the tab triggers a fresh fetch + agentSessionsLoaded = false; + } + updateAgentSessionsPanel(agentSessionsData); + } + break; + } + case 'agentSessionsProgress': { + const agentContainer = document.querySelector('#agent-sessions-content'); + if (agentContainer) { + const done = message.done as number; + const total = message.total as number; + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + const progEl = agentContainer.querySelector('.agent-sessions-progress'); + if (progEl) { + progEl.textContent = `Fetching agent sessions… ${done}/${total} repos (${pct}%)`; + } else { + Array.from(agentContainer.children).forEach(child => { + const el = child as HTMLElement; + if (!el.classList.contains('section-title') && !el.classList.contains('section-subtitle')) { + el.remove(); + } + }); + const div = document.createElement('div'); + div.className = 'agent-sessions-progress'; + div.style.cssText = 'margin-top:8px; font-size:12px; color:var(--text-secondary);'; + div.textContent = `Fetching agent sessions… ${done}/${total} repos (${pct}%)`; + agentContainer.appendChild(div); + } + } + break; + } } }); diff --git a/vscode-extension/test/unit/agentSessionsService.test.ts b/vscode-extension/test/unit/agentSessionsService.test.ts new file mode 100644 index 00000000..bceea43c --- /dev/null +++ b/vscode-extension/test/unit/agentSessionsService.test.ts @@ -0,0 +1,184 @@ +import test from 'node:test'; +import * as assert from 'node:assert/strict'; +import { + detectSessionSource, + fetchAgentSessionsForRepo, + type AgentRepoSummary, + type FetchTaskPageFn, + type FetchTaskDetailFn, +} from '../../src/agentSessionsService'; + +// --------------------------------------------------------------------------- +// detectSessionSource — pure function, no I/O +// --------------------------------------------------------------------------- + +test('detectSessionSource: cloud-agent when model is non-empty', () => { + assert.equal(detectSessionSource({ model: 'sweagent-capi:claude-sonnet-4' }), 'cloud-agent'); + assert.equal(detectSessionSource({ model: 'gpt-4o' }), 'cloud-agent'); +}); + +test('detectSessionSource: cloud-agent when usage field is present (even with empty model)', () => { + assert.equal(detectSessionSource({ model: '', usage: { credits: 10, type: 'ai-credits' } }), 'cloud-agent'); + assert.equal(detectSessionSource({ usage: { credits: 5 } }), 'cloud-agent'); +}); + +test('detectSessionSource: cli-remote when model field is present but empty, no usage', () => { + assert.equal(detectSessionSource({ model: '' }), 'cli-remote'); +}); + +test('detectSessionSource: unknown when model field is entirely absent', () => { + assert.equal(detectSessionSource({}), 'unknown'); + assert.equal(detectSessionSource({ usage: null }), 'unknown'); +}); + +// --------------------------------------------------------------------------- +// fetchAgentSessionsForRepo — pagination, source filtering, credit aggregation +// --------------------------------------------------------------------------- + +function makeTask(id: string): any { + return { id, name: `Task ${id}`, state: 'completed', created_at: new Date().toISOString() }; +} + +function makeSession(model: string, credits?: number): any { + const s: any = { id: `s-${Math.random()}`, state: 'completed', model, created_at: new Date().toISOString() }; + if (credits !== undefined) { s.usage = { credits, type: 'ai-credits' }; } + return s; +} + +const SINCE = new Date('2024-01-01T00:00:00Z'); + +test('fetchAgentSessionsForRepo: returns empty result when task list is empty', async () => { + const fetchPage: FetchTaskPageFn = async () => ({ tasks: [] }); + const fetchDetail: FetchTaskDetailFn = async () => ({ sessions: [] }); + const result = await fetchAgentSessionsForRepo('owner', 'repo', 'token', SINCE, fetchPage, fetchDetail); + assert.equal(result.totalTasks, 0); + assert.equal(result.totalSessions, 0); + assert.equal(result.totalCredits, 0); + assert.equal(result.tasksTotal, 0); + assert.equal(result.partial, false); + assert.equal(result.error, undefined); +}); + +test('fetchAgentSessionsForRepo: counts only cloud-agent sessions', async () => { + const tasks = [makeTask('t1')]; + const fetchPage: FetchTaskPageFn = async (_o, _r, _t, page) => + page === 1 ? { tasks } : { tasks: [] }; + const fetchDetail: FetchTaskDetailFn = async () => ({ + sessions: [ + makeSession('sweagent-capi:claude', 5), // cloud-agent + makeSession('', undefined), // cli-remote (excluded) + makeSession('gpt-4o', 3), // cloud-agent + ], + }); + const result = await fetchAgentSessionsForRepo('owner', 'repo', 'token', SINCE, fetchPage, fetchDetail); + assert.equal(result.totalTasks, 1); + assert.equal(result.totalSessions, 2); // only cloud-agent sessions + assert.equal(result.totalCredits, 8); // 5 + 3 + assert.equal(result.error, undefined); +}); + +test('fetchAgentSessionsForRepo: task with no cloud-agent sessions does not count toward totalTasks', async () => { + const tasks = [makeTask('t1')]; + const fetchPage: FetchTaskPageFn = async (_o, _r, _t, page) => + page === 1 ? { tasks } : { tasks: [] }; + const fetchDetail: FetchTaskDetailFn = async () => ({ + sessions: [makeSession('', undefined)], // cli-remote only + }); + const result = await fetchAgentSessionsForRepo('owner', 'repo', 'token', SINCE, fetchPage, fetchDetail); + assert.equal(result.totalTasks, 0); + assert.equal(result.totalSessions, 0); +}); + +test('fetchAgentSessionsForRepo: handles missing usage.credits gracefully', async () => { + const tasks = [makeTask('t1')]; + const fetchPage: FetchTaskPageFn = async (_o, _r, _t, page) => + page === 1 ? { tasks } : { tasks: [] }; + const fetchDetail: FetchTaskDetailFn = async () => ({ + sessions: [makeSession('cloud-model')], // no usage field + }); + const result = await fetchAgentSessionsForRepo('owner', 'repo', 'token', SINCE, fetchPage, fetchDetail); + assert.equal(result.totalTasks, 1); + assert.equal(result.totalSessions, 1); + assert.equal(result.totalCredits, 0); +}); + +test('fetchAgentSessionsForRepo: returns error result when API returns 404', async () => { + const fetchPage: FetchTaskPageFn = async () => ({ tasks: [], statusCode: 404, error: 'HTTP 404' }); + const fetchDetail: FetchTaskDetailFn = async () => ({ sessions: [] }); + const result = await fetchAgentSessionsForRepo('owner', 'repo', 'token', SINCE, fetchPage, fetchDetail); + assert.equal(result.totalTasks, 0); + assert.ok(result.error?.includes('not enabled') || result.error?.includes('not accessible')); +}); + +test('fetchAgentSessionsForRepo: returns error result when API returns 403', async () => { + const fetchPage: FetchTaskPageFn = async () => ({ tasks: [], statusCode: 403, error: 'HTTP 403' }); + const fetchDetail: FetchTaskDetailFn = async () => ({ sessions: [] }); + const result = await fetchAgentSessionsForRepo('owner', 'repo', 'token', SINCE, fetchPage, fetchDetail); + assert.equal(result.totalTasks, 0); + assert.ok(result.error?.includes('Access denied') || result.error?.includes('token')); +}); + +test('fetchAgentSessionsForRepo: deduplicates tasks that appear in both active and archived lists', async () => { + const task = makeTask('shared-id'); + let activePageCalled = false; + const fetchPage: FetchTaskPageFn = async (_o, _r, _t, page, archived) => { + if (!archived && page === 1) { activePageCalled = true; return { tasks: [task] }; } + if (archived && page === 1) { return { tasks: [task] }; } // same task in archived + return { tasks: [] }; + }; + let detailCallCount = 0; + const fetchDetail: FetchTaskDetailFn = async () => { + detailCallCount++; + return { sessions: [makeSession('cloud-model', 2)] }; + }; + const result = await fetchAgentSessionsForRepo('owner', 'repo', 'token', SINCE, fetchPage, fetchDetail); + assert.ok(activePageCalled); + assert.equal(detailCallCount, 1, 'duplicate task id should be fetched only once'); + assert.equal(result.totalTasks, 1); + assert.equal(result.totalSessions, 1); + assert.equal(result.totalCredits, 2); +}); + +test('fetchAgentSessionsForRepo: marks partial=true when tasksTotal > cap', async () => { + // Create 51 tasks (one over the MAX_TASKS_DETAIL_PER_REPO cap of 50) + const tasks = Array.from({ length: 51 }, (_, i) => makeTask(`t${i}`)); + const fetchPage: FetchTaskPageFn = async (_o, _r, _t, page) => + page === 1 ? { tasks } : { tasks: [] }; + const fetchDetail: FetchTaskDetailFn = async () => ({ + sessions: [makeSession('cloud-model', 1)], + }); + const result = await fetchAgentSessionsForRepo('owner', 'repo', 'token', SINCE, fetchPage, fetchDetail); + assert.equal(result.partial, true); + assert.equal(result.tasksTotal, 51); + assert.equal(result.tasksScanned, 50); + assert.equal(result.totalSessions, 50); // only 50 task details fetched +}); + +test('fetchAgentSessionsForRepo: partial=false when tasksTotal <= cap', async () => { + const tasks = [makeTask('t1'), makeTask('t2')]; + const fetchPage: FetchTaskPageFn = async (_o, _r, _t, page) => + page === 1 ? { tasks } : { tasks: [] }; + const fetchDetail: FetchTaskDetailFn = async () => ({ + sessions: [makeSession('cloud-model', 1)], + }); + const result = await fetchAgentSessionsForRepo('owner', 'repo', 'token', SINCE, fetchPage, fetchDetail); + assert.equal(result.partial, false); + assert.equal(result.tasksScanned, 2); +}); + +test('fetchAgentSessionsForRepo: handles detail fetch failure gracefully (skips task)', async () => { + const tasks = [makeTask('t1'), makeTask('t2')]; + const fetchPage: FetchTaskPageFn = async (_o, _r, _t, page) => + page === 1 ? { tasks } : { tasks: [] }; + let callNum = 0; + const fetchDetail: FetchTaskDetailFn = async () => { + callNum++; + if (callNum === 1) { return { error: 'network error' }; } + return { sessions: [makeSession('cloud-model', 3)] }; + }; + const result = await fetchAgentSessionsForRepo('owner', 'repo', 'token', SINCE, fetchPage, fetchDetail); + assert.equal(result.totalTasks, 1); // only t2 succeeded + assert.equal(result.totalSessions, 1); + assert.equal(result.totalCredits, 3); + assert.equal(result.error, undefined); +});