From f3c22993f5979a313c9f2f9a39b71dc03deb2a62 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sun, 10 May 2026 14:18:13 +0200 Subject: [PATCH 1/3] feat: add Copilot cloud agent sessions view - New 'Cloud Agent' tab in Usage Analysis showing tasks, sessions, AI credits - agentSessionsService.ts: fetches /agents/repos/{owner}/{repo}/tasks API - Source detection: cloud-agent vs cli-remote vs unknown (no double-counting) - Detail fetch capped at 50 tasks/repo with partial flag for transparency - Lazy-loaded on first tab visit with progress updates - scripts/fetch-agent-sessions.js for workflow data pre-fetch - copilot-setup-steps.yml: daily cached agent session fetch via actions/cache - 11 new unit tests for detectSessionSource and fetchAgentSessionsForRepo Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/copilot-setup-steps.yml | 41 ++- scripts/fetch-agent-sessions.js | 234 ++++++++++++++++ vscode-extension/src/agentSessionsService.ts | 256 ++++++++++++++++++ vscode-extension/src/extension.ts | 70 ++++- vscode-extension/src/types.ts | 34 +++ vscode-extension/src/webview/usage/main.ts | 188 +++++++++++++ .../test/unit/agentSessionsService.test.ts | 184 +++++++++++++ 7 files changed, 1001 insertions(+), 6 deletions(-) create mode 100644 scripts/fetch-agent-sessions.js create mode 100644 vscode-extension/src/agentSessionsService.ts create mode 100644 vscode-extension/test/unit/agentSessionsService.test.ts diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 2e1de020..fd467855 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -21,11 +21,13 @@ jobs: # Set the permissions to the lowest permissions possible needed for your steps. # Copilot will be given its own token for its operations. - permissions: - # If you want to clone the repository as part of your setup steps, for example to install dependencies, - # 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 +permissions: + # If you want to clone the repository as part of your setup steps, for example to install dependencies, + # 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..9c3432ae 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,28 @@ type RepoPrStatsResult = { since: string; }; +type AgentRepoSummary = { + owner: string; + repo: 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 +547,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' }); + } }); }); } @@ -678,6 +709,111 @@ 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 = escapeHtml(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) => { + const repoLink = `${escapeHtml(r.owner)}/${escapeHtml(r.repo)}`; + if (r.error) { + return ` + ${repoLink} + ${escapeHtml(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 +1254,7 @@ function renderLayout(stats: UsageAnalysisStats): void { + + + @@ -1535,6 +1682,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 +1754,43 @@ window.addEventListener('message', (event) => { } break; } + case 'agentSessionsLoaded': { + const raw = message.data; + if (raw && typeof raw === 'object') { + agentSessionsData = raw as AgentSessionsResult; + 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); +}); From d50ea50143d968c57ddd1739a794eace2c77830d Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sun, 10 May 2026 14:47:04 +0200 Subject: [PATCH 2/3] fix: correct permissions block indentation in copilot-setup-steps.yml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/copilot-setup-steps.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index fd467855..45bc68e6 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -21,13 +21,13 @@ jobs: # Set the permissions to the lowest permissions possible needed for your steps. # Copilot will be given its own token for its operations. -permissions: - # If you want to clone the repository as part of your setup steps, for example to install dependencies, - # 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 + permissions: + # If you want to clone the repository as part of your setup steps, for example to install dependencies, + # 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. From 4bbfd25de2b17c24f52c7c84375398f586bec51f Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sun, 10 May 2026 14:51:53 +0200 Subject: [PATCH 3/3] fix: sanitize agent sessions data at trust boundary to prevent XSS Add sanitizeAgentSessionsData() that escapes all string fields when receiving message.data from the extension host, matching the existing sanitizeRepoPrStatsData() pattern. renderAgentSessionsContent() then works with pre-sanitized strings and no longer calls escapeHtml() internally. Resolves CodeQL cross-site scripting finding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vscode-extension/src/webview/usage/main.ts | 43 ++++++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/vscode-extension/src/webview/usage/main.ts b/vscode-extension/src/webview/usage/main.ts index 9c3432ae..4c3e08d2 100644 --- a/vscode-extension/src/webview/usage/main.ts +++ b/vscode-extension/src/webview/usage/main.ts @@ -187,6 +187,8 @@ type RepoPrStatsResult = { type AgentRepoSummary = { owner: string; repo: string; + /** Pre-validated safe https URL for this repo. */ + repoUrl: string; totalTasks: number; totalSessions: number; totalCredits: number; @@ -695,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; } @@ -728,7 +762,7 @@ function renderAgentSessionsContent(data: AgentSessionsResult): string { `; } - const sinceDate = escapeHtml(new Date(data.since).toLocaleDateString()); + 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;`; @@ -744,11 +778,12 @@ function renderAgentSessionsContent(data: AgentSessionsResult): string { const hasPartial = data.repos.some(r => r.partial && !r.error); const rows = data.repos.map((r) => { - const repoLink = `${escapeHtml(r.owner)}/${escapeHtml(r.repo)}`; + // 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} - ${escapeHtml(r.error)} + ${r.error} `; } const partialNote = r.partial @@ -1757,7 +1792,7 @@ window.addEventListener('message', (event) => { case 'agentSessionsLoaded': { const raw = message.data; if (raw && typeof raw === 'object') { - agentSessionsData = raw as AgentSessionsResult; + agentSessionsData = sanitizeAgentSessionsData(raw); if (!agentSessionsData.authenticated) { // Reset loaded flag so re-authenticating and revisiting the tab triggers a fresh fetch agentSessionsLoaded = false;