Skip to content
Merged
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
45 changes: 30 additions & 15 deletions build/lib/policies/policyData.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,6 @@
}
],
"policies": [
{
"key": "extensions.gallery.serviceUrl",
"name": "ExtensionGalleryServiceUrl",
"category": "Extensions",
"minimumVersion": "1.99",
"localization": {
"description": {
"key": "extensions.gallery.serviceUrl",
"value": "Configure the Marketplace service URL to connect to"
}
},
"type": "string",
"default": "",
"included": false
},
{
"key": "chat.mcp.gallery.serviceUrl",
"name": "McpGalleryServiceUrl",
Expand Down Expand Up @@ -83,6 +68,21 @@
"default": [],
"included": false
},
{
"key": "extensions.gallery.serviceUrl",
"name": "ExtensionGalleryServiceUrl",
"category": "Extensions",
"minimumVersion": "1.99",
"localization": {
"description": {
"key": "extensions.gallery.serviceUrl",
"value": "Configure the Marketplace service URL to connect to"
}
},
"type": "string",
"default": "",
"included": false
},
{
"key": "extensions.allowed",
"name": "AllowedExtensions",
Expand Down Expand Up @@ -113,6 +113,21 @@
"default": false,
"included": true
},
{
"key": "chat.sessionSync.enabled",
"name": "CopilotSessionSync",
"category": "InteractiveSession",
"minimumVersion": "1.119",
"localization": {
"description": {
"key": "chat.sessionSync.enabled.policy",
"value": "Enable session sync to GitHub.com for cross-device Copilot session history. When disabled by organization policy, session data is kept local only."
}
},
"type": "boolean",
"default": false,
"included": true
},
{
"key": "chat.tools.eligibleForAutoApproval",
"name": "ChatToolsEligibleForAutoApproval",
Expand Down
6 changes: 6 additions & 0 deletions extensions/copilot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2678,6 +2678,12 @@
"category": "Chat",
"enablement": "config.github.copilot.chat.otel.dbSpanExporter.enabled"
},
{
"command": "github.copilot.sessionSync.deleteSessions",
"title": "%github.copilot.command.sessionSync.deleteSessions%",
"category": "Chat",
"enablement": "github.copilot.sessionSearch.enabled && config.chat.sessionSync.enabled"
},
{
"command": "github.copilot.nes.captureExpected.start",
"title": "Record Expected Edit (NES)",
Expand Down
1 change: 1 addition & 0 deletions extensions/copilot/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
"copilot.chronicle.description": "Session history tools and insights",
"copilot.chronicle.standup.description": "Generate a standup report from recent chat sessions",
"copilot.chronicle.tips.description": "Get personalized tips based on your chat session usage patterns",
"github.copilot.command.sessionSync.deleteSessions": "Delete Session Sync Data",
"copilot.chronicle.reindex.description": "Rebuild the local session index from stored session logs. Add 'force' to re-process already indexed sessions.",
"github.copilot.config.sessionSearch.enabled": "Enable session search and /chronicle commands. This is a team-internal setting.",
"github.copilot.config.sessionSearch.localIndex.enabled": "Enable local session tracking. When enabled, Copilot tracks session data locally for /chronicle commands.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,8 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
const model = asString(span.attributes[GenAiAttr.REQUEST_MODEL])
?? asString(span.attributes[GenAiAttr.RESPONSE_MODEL])
?? 'unknown';
const debugName = asString(span.attributes[CopilotChatAttr.DEBUG_NAME])
?? asString(span.attributes[GenAiAttr.AGENT_NAME]);
return {
ts: span.startTime,
dur: duration,
Expand All @@ -926,6 +928,7 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
status: isError ? 'error' : 'ok',
attrs: {
model,
...(debugName ? { debugName } : {}),
...(span.attributes[GenAiAttr.USAGE_INPUT_TOKENS] !== undefined
? { inputTokens: asNumber(span.attributes[GenAiAttr.USAGE_INPUT_TOKENS]) }
: {}),
Expand All @@ -938,6 +941,9 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
...(span.attributes[CopilotChatAttr.TIME_TO_FIRST_TOKEN] !== undefined
? { ttft: asNumber(span.attributes[CopilotChatAttr.TIME_TO_FIRST_TOKEN]) }
: {}),
...(span.attributes[GenAiAttr.RESPONSE_ID] !== undefined
? { responseId: asString(span.attributes[GenAiAttr.RESPONSE_ID]) }
: {}),
...(span.attributes[CopilotChatAttr.USER_REQUEST] !== undefined
? { userRequest: String(span.attributes[CopilotChatAttr.USER_REQUEST]) }
: {}),
Expand All @@ -953,6 +959,9 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
...(span.attributes[GenAiAttr.REQUEST_TOP_P] !== undefined
? { topP: asNumber(span.attributes[GenAiAttr.REQUEST_TOP_P]) }
: {}),
...(span.attributes[CopilotChatAttr.REQUEST_OPTIONS] !== undefined
? { requestOptions: String(span.attributes[CopilotChatAttr.REQUEST_OPTIONS]) }
: {}),
...(isError && span.status.message ? { error: span.status.message } : {}),
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { ConfigKey, IConfigurationService } from '../../../platform/configuratio
import { INativeEnvService } from '../../../platform/env/common/envService';
import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';
import { ILogService } from '../../../platform/log/common/logService';
import { IChatEndpoint } from '../../../platform/networking/common/networking';
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
import { Emitter, Event } from '../../../util/vs/base/common/event';
Expand Down Expand Up @@ -144,7 +143,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
// and the response footer details — they otherwise both call
// `resolveEndpoint` (which hits the cached endpoint list, then
// re-filters), which is wasted work and risks divergence.
const endpoint = await this._resolveEndpointForRequest(modelId.toEndpointModelId());
const endpoint = await this.claudeModels.resolveEndpoint(modelId.toEndpointModelId(), undefined);
const rawReasoningEffort = request.modelConfiguration?.[CLAUDE_REASONING_EFFORT_PROPERTY];
const reasoningEffort = pickReasoningEffort(endpoint, typeof rawReasoningEffort === 'string' ? rawReasoningEffort : undefined);
this.sessionStateService.setReasoningEffortForSession(effectiveSessionId, reasoningEffort);
Expand Down Expand Up @@ -200,19 +199,6 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
};
}

/**
* Resolves a Claude model id to its endpoint. Wraps `resolveEndpoint` in a
* try/catch so transient failures degrade gracefully (return `undefined`)
* instead of breaking the response or session-load path.
*/
private async _resolveEndpointForRequest(modelId: string): Promise<IChatEndpoint | undefined> {
try {
return await this.claudeModels.resolveEndpoint(modelId, undefined);
} catch {
return undefined;
}
}

/**
* Resolves the display string for each unique non-synthetic model id observed in the
* session's assistant messages. Returns `undefined` (not an empty map) when no model
Expand Down Expand Up @@ -240,7 +226,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
if (token.isCancellationRequested) {
return;
}
const endpoint = await this._resolveEndpointForRequest(modelId);
const endpoint = await this.claudeModels.resolveEndpoint(modelId, undefined);
if (endpoint) {
detailsByModelId.set(modelId, formatClaudeModelDetails(endpoint));
}
Expand Down
118 changes: 117 additions & 1 deletion extensions/copilot/src/extension/chronicle/common/eventTranslator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { generateUuid } from '../../../util/vs/base/common/uuid';
import { CopilotChatAttr, GenAiAttr, GenAiOperationName } from '../../../platform/otel/common/genAiAttributes';
import type { ICompletedSpanData } from '../../../platform/otel/common/otelService';
import type { IDebugLogEntry } from '../../../platform/chat/common/chatDebugFileLoggerService';
import type { SessionEvent, WorkingDirectoryContext } from './cloudSessionTypes';

// ── Content size limits (bytes) ─────────────────────────────────────────────────
Expand Down Expand Up @@ -192,18 +193,133 @@ export function makeShutdownEvent(state: SessionTranslationState): SessionEvent
return makeEvent(state, 'session.shutdown', {});
}

// ── Debug log entry → cloud event translation ───────────────────────────────────

/**
* Translate a JSONL debug log entry into zero or more cloud SessionEvents.
*
* Used by the cloud reindex phase to upload historical sessions that were
* never live-synced. Mirrors the event types produced by {@link translateSpan}
* so the cloud sees a consistent format regardless of how events were captured.
*
* Mutates `state` to maintain parentId chaining across entries.
*/
export function translateDebugLogEntry(
entry: IDebugLogEntry,
sessionId: string,
state: SessionTranslationState,
): SessionEvent[] {
const events: SessionEvent[] = [];
const ts = new Date(entry.ts).toISOString();

switch (entry.type) {
case 'session_start': {
if (!state.started) {
state.started = true;
events.push(makeEventAt(state, ts, 'session.start', {
sessionId,
version: 1,
producer: 'vscode-copilot-chat',
copilotVersion: typeof entry.attrs.copilotVersion === 'string' ? entry.attrs.copilotVersion : '1.0.0',
startTime: ts,
context: {
cwd: typeof entry.attrs.cwd === 'string' ? entry.attrs.cwd : undefined,
repository: typeof entry.attrs.repository === 'string' ? entry.attrs.repository : undefined,
hostType: 'github',
branch: typeof entry.attrs.branch === 'string' ? entry.attrs.branch : undefined,
},
}));
}
break;
}

case 'user_message':
case 'turn_start': {
const content = typeof entry.attrs.content === 'string'
? entry.attrs.content
: typeof entry.attrs.userRequest === 'string'
? entry.attrs.userRequest
: undefined;
if (content) {
events.push(makeEventAt(state, ts, 'user.message', {
content: truncate(content, MAX_USER_MESSAGE_SIZE),
source: 'chat',
agentMode: 'interactive',
}));
}
break;
}

case 'agent_response': {
const response = typeof entry.attrs.response === 'string' ? entry.attrs.response : undefined;
if (response) {
events.push(makeEventAt(state, ts, 'assistant.message', {
messageId: generateUuid(),
content: truncate(response, MAX_ASSISTANT_MESSAGE_SIZE),
}));
}
break;
}

case 'tool_call': {
const toolName = entry.name;
if (toolName) {
const toolCallId = entry.spanId || generateUuid();
const resultText = typeof entry.attrs.result === 'string' ? entry.attrs.result : undefined;
const success = entry.status === 'ok';
const truncatedResult = resultText ? truncate(resultText, MAX_TOOL_RESULT_SIZE) : '';

events.push(makeEventAt(state, ts, 'tool.execution_complete', {
toolCallId,
toolName,
success,
result: success ? {
content: truncatedResult,
detailedContent: truncatedResult,
} : undefined,
error: !success ? {
message: truncatedResult || (typeof entry.attrs.error === 'string' ? entry.attrs.error : 'Tool execution failed'),
code: 'failure',
} : undefined,
}));
}
break;
}
}

// Filter out oversized events
return events.filter(event => {
const size = estimateEventSize(event);
if (size > MAX_EVENT_SIZE) {
state.droppedCount++;
return false;
}
return true;
});
}

// ── Internal helpers ────────────────────────────────────────────────────────────

function makeEvent(
state: SessionTranslationState,
type: string,
data: Record<string, unknown>,
ephemeral?: boolean,
): SessionEvent {
return makeEventAt(state, new Date().toISOString(), type, data, ephemeral);
}

function makeEventAt(
state: SessionTranslationState,
timestamp: string,
type: string,
data: Record<string, unknown>,
ephemeral?: boolean,
): SessionEvent {
const id = generateUuid();
const event: SessionEvent = {
id,
timestamp: new Date().toISOString(),
timestamp,
parentId: state.lastEventId,
type,
data,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { IConfigurationService } from '../../../platform/configuration/common/configurationService';
import picomatch from 'picomatch';

/**
Expand All @@ -16,6 +16,12 @@ export type SessionIndexingLevel = 'local' | 'user' | 'repo_and_user';

/**
* Manages user preferences for session indexing via VS Code settings.
*
* Two settings control behavior:
* - `chat.localIndex.enabled` (ExP) — enables local
* SQLite tracking and /chronicle commands
* - `chat.sessionSync.enabled` (core setting with enterprise policy) — enables
* cloud upload
*/
export class SessionIndexingPreference {

Expand All @@ -24,7 +30,7 @@ export class SessionIndexingPreference {
) { }

/**
* Get the effective storage level for a given repo. *
* Get the effective storage level for a given repo.
* - If cloud sync is enabled and repo is not excluded → 'user'
* - Otherwise → 'local'
*/
Expand All @@ -36,16 +42,16 @@ export class SessionIndexingPreference {
}

/**
* Check if cloud sync is enabled for a given repo.
* Returns true if cloudSync.enabled is true AND the repo is not excluded.
* Check if session sync is enabled for a given repo.
* Returns true if `chat.sessionSync.enabled` is true AND the repo is not excluded.
*/
hasCloudConsent(repoNwo?: string): boolean {
if (!this._configService.getConfig(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled)) {
if (!(this._configService.getNonExtensionConfig<boolean>('chat.sessionSync.enabled') ?? false)) {
return false;
}

if (repoNwo) {
const excludePatterns = this._configService.getConfig(ConfigKey.TeamInternal.SessionSearchCloudSyncExcludeRepositories);
const excludePatterns = this._configService.getNonExtensionConfig<string[]>('chat.sessionSync.excludeRepositories');
if (excludePatterns && excludePatterns.length > 0) {
for (const pattern of excludePatterns) {
if (pattern === repoNwo || picomatch.isMatch(repoNwo, pattern)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Event } from '../../../util/vs/base/common/event';
import { createServiceIdentifier } from '../../../util/common/services';

// ── Service identifier ──────────────────────────────────────────────────────────

export const ISessionSyncStateService = createServiceIdentifier<ISessionSyncStateService>('ISessionSyncStateService');

// ── Types ────────────────────────────────────────────────────────────────────────

export type SessionSyncState =
| { kind: 'not-enabled' }
| { kind: 'disabled-by-policy' }
| { kind: 'on' }
| { kind: 'syncing'; sessionCount: number }
| { kind: 'up-to-date'; syncedCount: number }
| { kind: 'deleting'; sessionCount: number }
| { kind: 'error' };

// ── Service interface ────────────────────────────────────────────────────────────

export interface ISessionSyncStateService {
readonly _serviceBrand: undefined;

/** The current sync state. */
readonly syncState: SessionSyncState;

/** Fires when the sync state changes. */
readonly onDidChangeSyncState: Event<SessionSyncState>;
}
Loading
Loading