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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { IDisposable } from '../../../base/common/lifecycle.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import type { ChangesetKind } from './changesetUri.js';
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js';
import type { ChangesetOperation, ISessionGitState, URI } from './state/sessionState.js';
import type { ChangesetOperation, ISessionGitHubState, ISessionGitState, URI } from './state/sessionState.js';

export const IAgentHostChangesetOperationService = createDecorator<IAgentHostChangesetOperationService>('agentHostChangesetOperationService');

Expand Down Expand Up @@ -49,7 +49,9 @@ export interface IChangesetOperationContext {
/** Well-known changeset kind for {@link changesetUri}. */
readonly changesetKind: ChangesetKind;
/** Current git metadata for the session used to compute operation availability. */
readonly gitState: ISessionGitState;
readonly gitState?: ISessionGitState;
/** Current GitHub metadata for the session used to compute operation availability. */
readonly gitHubState?: ISessionGitHubState;
}

/**
Expand Down Expand Up @@ -112,7 +114,7 @@ export interface IAgentHostChangesetOperationService extends IDisposable {
* session. If `gitState` is not provided, the current git state will
* be used.
*/
updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState): void;
updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void;
/**
* Invokes an advertised operation after validating the changeset, operation id,
* and requested target scope.
Expand Down
16 changes: 15 additions & 1 deletion src/vs/platform/agentHost/common/agentHostGitStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { URI } from '../../../base/common/uri.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { ISessionGitState } from './state/sessionState.js';
import { ISessionGitHubState, ISessionGitState } from './state/sessionState.js';

export const IAgentHostGitStateService = createDecorator<IAgentHostGitStateService>('agentHostGitStateService');

Expand All @@ -19,4 +19,18 @@ export interface IAgentHostGitStateService {
* @returns A promise that resolves to the updated git state, `undefined` if the git state is unchanged, or `null` if git state is unavailable (no working directory, not a git repo, or an error occurred).
*/
refreshSessionGitState(sessionKey: string, workingDirectory?: URI): Promise<ISessionGitState | undefined | null>;

/**
* Gets the GitHub state for a given session.
* @param sessionKey The key of the session for which to get the GitHub state.
* @returns A promise that resolves to the GitHub state, or `undefined` if it is not available.
*/
getSessionGitHubState(sessionKey: string): Promise<ISessionGitHubState | undefined>;

/**
* Sets the GitHub state for a given session.
* @param sessionKey The key of the session for which to set the GitHub state.
* @param state The GitHub state to set.
*/
setSessionGitHubState(sessionKey: string, state: ISessionGitHubState): Promise<void>;
}
10 changes: 9 additions & 1 deletion src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f
import { ProtectedResourceMetadata, type Changeset, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js';
import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction } from './state/sessionActions.js';
import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js';
import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js';
import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState, SessionSummaryMeta } from './state/sessionState.js';

// IPC contract between the renderer and the agent host utility process.
// Defines all serializable event types, the IAgent provider interface,
Expand Down Expand Up @@ -488,6 +488,14 @@ export interface IAgentSessionMetadata {
* file list.
*/
readonly changesets?: readonly Changeset[];
/**
* Side-channel metadata for the session summary, propagated
* to clients via per-session state subscriptions.
* Producers SHOULD use namespaced keys; consumers MUST ignore unknown
* keys. Use the typed accessors in `sessionState.ts` (e.g.
* `readSessionGitHubState`) for well-known slots.
*/
readonly _summaryMeta?: SessionSummaryMeta;
/**
* Side-channel metadata mirroring {@link SessionState._meta}, propagated
* to clients via per-session state subscriptions.
Expand Down
78 changes: 78 additions & 0 deletions src/vs/platform/agentHost/common/state/sessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,13 @@ export function getDefaultChat(session: SessionState): ChatSummary | undefined {
*/
export type SessionMeta = Record<string, unknown>;

/**
* VS Code-side alias for the protocol's open `_meta` property bag on
* {@link SessionSummary}. Keys SHOULD be namespaced (e.g. `git`, `vscode.foo`)
* to avoid collisions; values MUST be JSON-serializable.
*/
export type SessionSummaryMeta = Record<string, unknown>;

/**
* Reserved key under {@link SessionMeta} for the well-known git-state
* payload. Value at this key, when present, MUST be shaped like
Expand All @@ -736,6 +743,15 @@ export type SessionMeta = Record<string, unknown>;
*/
export const SESSION_META_GIT_KEY = 'git';

/**
* Reserved key under {@link SessionMeta} for the well-known GitHub-state
* payload. Value at this key, when present, MUST be shaped like
* {@link ISessionGitHubState}. This is a VS Code-specific convention layered
* on top of the protocol's generic `_meta` bag — the protocol itself does
* not know about GitHub state.
*/
export const SESSION_META_GITHUB_KEY = 'github';

/**
* Git state of a session's working directory, carried under
* {@link SessionMeta} at {@link SESSION_META_GIT_KEY}. Used by clients to
Expand Down Expand Up @@ -767,6 +783,24 @@ export interface ISessionGitState {
readonly githubRepo?: string;
}

/**
* GitHub state of a session, carried under {@link SessionMeta} at
* {@link SESSION_META_GITHUB_KEY}. Used by clients to drive GitHub-specific
* affordances (e.g. PR/merge buttons in the Agents app).
*
* All fields are optional — agents that do not track a particular field
* should omit it rather than send a placeholder, so clients can distinguish
* "unknown" from "known to be zero".
*/
export interface ISessionGitHubState {
/** The owner of the GitHub repository. */
readonly owner?: string;
/** The name of the GitHub repository. */
readonly repo?: string;
/** The URL of the GitHub pull request. */
readonly pullRequestUrl?: string;
}

/**
* Reads the well-known git-state payload from {@link SessionMeta}, if
* present. Returns `undefined` when the meta bag is absent or the value at
Expand Down Expand Up @@ -822,6 +856,50 @@ export function withSessionGitState(meta: SessionMeta | undefined, gitState: ISe
return Object.keys(next).length > 0 ? next : undefined;
}

/**
* Reads the well-known GitHub state payload from {@link SessionSummaryMeta}, if
* present. Returns `undefined` when the meta bag is absent or the value at the
* GitHub key is not a plain object (e.g. an array or a primitive).
* Individual fields with wrong types are silently dropped so partial state
* still propagates.
*
* Unlike the other typed readers, this takes the raw {@link SessionSummaryMeta}
* value rather than its parent {@link SessionState}: the sessions provider stores and
* reads a detached meta snapshot without retaining the owning state.
*/
Comment thread
Copilot marked this conversation as resolved.
export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): ISessionGitHubState | undefined {
const value = meta?.[SESSION_META_GITHUB_KEY];
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const raw = value as Record<string, unknown>;
const result: {
owner?: string;
repo?: string;
pullRequestUrl?: string;
} = {};

if (typeof raw['owner'] === 'string') { result.owner = raw['owner']; }
if (typeof raw['repo'] === 'string') { result.repo = raw['repo']; }
if (typeof raw['pullRequestUrl'] === 'string') { result.pullRequestUrl = raw['pullRequestUrl']; }
return result;
}

/**
* Returns a new {@link SessionSummaryMeta} with the GitHub-state payload set to
* `gitHubState`, or with the GitHub slot removed if `gitHubState` is `undefined`.
* Returns `undefined` if the result would be empty.
*/
export function withSessionGitHubState(meta: SessionSummaryMeta | undefined, gitHubState: ISessionGitHubState | undefined): SessionSummaryMeta | undefined {
const next: { [key: string]: unknown } = { ...meta };
if (gitHubState !== undefined) {
next[SESSION_META_GITHUB_KEY] = gitHubState;
} else {
delete next[SESSION_META_GITHUB_KEY];
}
return Object.keys(next).length > 0 ? next : undefined;
}

// ---- RootState _meta accessors ---------------------------------------------

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { parseChangesetUri } from '../common/changesetUri.js';
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js';
import { ActionType } from '../common/state/sessionActions.js';
import { ChangesetOperationScope, ChangesetOperationStatus, ChangesetOperationTargetKind, readSessionGitState, type ChangesetOperation, type ErrorInfo, type ISessionGitState } from '../common/state/sessionState.js';
import { ChangesetOperationScope, ChangesetOperationStatus, ChangesetOperationTargetKind, ISessionGitHubState, readSessionGitHubState, readSessionGitState, type ChangesetOperation, type ErrorInfo, type ISessionGitState } from '../common/state/sessionState.js';
import type { IChangesetOperationContribution, IAgentHostChangesetOperationService, IChangesetOperationContext, IChangesetOperationHandler, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js';
import { AgentHostStateManager } from './agentHostStateManager.js';
import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js';
Expand Down Expand Up @@ -59,7 +59,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA
});
}

updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState): void {
updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void {
if (!gitState) {
const sessionState = this._stateManager.getSessionState(sessionKey);
gitState = readSessionGitState(sessionState?._meta);
Expand All @@ -68,6 +68,11 @@ export class AgentHostChangesetOperationService extends Disposable implements IA
}
}

if (!gitHubState) {
const sessionState = this._stateManager.getSessionState(sessionKey);
gitHubState = readSessionGitHubState(sessionState?.summary._meta);
}
Comment thread
Copilot marked this conversation as resolved.

const changesets = changeset
? [changeset]
: this._changesetSubscriptions.getSessionSubscriptions(sessionKey);
Expand All @@ -82,7 +87,8 @@ export class AgentHostChangesetOperationService extends Disposable implements IA
sessionKey,
changesetUri: changeset,
changesetKind: parsed.kind,
gitState
gitState,
gitHubState
});

this._stateManager.dispatchServerAction(changeset, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class AgentHostCommitOperationContribution extends Disposable implements
}

getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined {
if (changesetKind !== ChangesetKind.Uncommitted || (gitState.uncommittedChanges ?? 0) <= 0) {
if (changesetKind !== ChangesetKind.Uncommitted || (gitState?.uncommittedChanges ?? 0) <= 0) {
return undefined;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class AgentHostDiscardChangesOperationContribution extends Disposable imp
}

getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined {
if (changesetKind !== ChangesetKind.Uncommitted || (gitState.uncommittedChanges ?? 0) <= 0) {
if (changesetKind !== ChangesetKind.Uncommitted || (gitState?.uncommittedChanges ?? 0) <= 0) {
return undefined;
}

Expand Down
100 changes: 97 additions & 3 deletions src/vs/platform/agentHost/node/agentHostGitStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js';
import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri, formatSessionChangesetDescription } from '../common/changesetUri.js';
import { readSessionGitState, withSessionGitState, type Changeset, type ISessionGitState } from '../common/state/sessionState.js';
import { ISessionGitHubState, readSessionGitHubState, readSessionGitState, SessionLifecycle, withSessionGitHubState, withSessionGitState, type Changeset, type ISessionGitState } from '../common/state/sessionState.js';
import { IAgentHostGitService } from '../common/agentHostGitService.js';
import { AgentHostStateManager } from './agentHostStateManager.js';
import { ISessionDataService } from '../common/sessionDataService.js';

export const META_GIT_STATE = 'agentHost.git';
export const META_GITHUB_STATE = 'agentHost.github';

export class AgentHostGitStateService implements IAgentHostGitStateService {
declare readonly _serviceBrand: undefined;
Expand All @@ -19,6 +23,7 @@ export class AgentHostGitStateService implements IAgentHostGitStateService {
private readonly _stateManager: AgentHostStateManager,
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
@ILogService private readonly _logService: ILogService,
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
) { }

async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise<ISessionGitState | undefined | null> {
Expand Down Expand Up @@ -46,17 +51,82 @@ export class AgentHostGitStateService implements IAgentHostGitStateService {
}

this._setSessionGitState(sessionKey, gitState);

if (gitState.githubOwner || gitState.githubRepo) {
void this.setSessionGitHubState(sessionKey, {
owner: gitState.githubOwner,
repo: gitState.githubRepo
} satisfies ISessionGitHubState);
}

return gitState;
} catch (e) {
this._logService.warn(`[AgentHostGitStateService][refreshSessionGitState] Failed to compute git state for ${sessionKey}`, e);
return null;
}
}

async getSessionGitHubState(sessionKey: string): Promise<ISessionGitHubState | undefined> {
// Attempt to load the GitHub state from the state manager
const currentMeta = this._stateManager.getSessionState(sessionKey)?.summary._meta;
const currentGitHubState = readSessionGitHubState(currentMeta);
if (currentGitHubState) {
return currentGitHubState;
}

// Load the GitHub state from the session database
let databaseRef;
try {
databaseRef = this._sessionDataService.openDatabase(URI.parse(sessionKey));
} catch (error) {
this._logService.warn(`[AgentHostGitStateService][getSessionGitHubState] Failed to open session database for ${sessionKey}`, error);
return undefined;
}

try {
const githubStateStr = await databaseRef.object.getMetadata(META_GITHUB_STATE);
if (githubStateStr) {
const githubState = JSON.parse(githubStateStr) as ISessionGitHubState;
this._stateManager.setSessionSummaryMeta(sessionKey, withSessionGitHubState(currentMeta, githubState));

return githubState;
}
} catch (error) {
this._logService.warn(`[AgentHostGitStateService][_getSessionGitHubState] Failed to load GitHub state for ${sessionKey}`, error);
} finally {
databaseRef.dispose();
}

return undefined;
}

async setSessionGitHubState(sessionKey: string, state: ISessionGitHubState): Promise<void> {
const current = await this.getSessionGitHubState(sessionKey);
const next = { ...current, ...state } satisfies ISessionGitHubState;

if (objectEquals(current, next)) {
return;
}

// Update session state manager
const currentMeta = this._stateManager.getSessionState(sessionKey)?.summary._meta;
const nextMeta = withSessionGitHubState(currentMeta, next);
this._stateManager.setSessionSummaryMeta(sessionKey, nextMeta);

// Update session database
void this._saveSessionState(sessionKey, META_GITHUB_STATE, JSON.stringify(next));
}

private _setSessionGitState(sessionKey: string, gitState: ISessionGitState): void {
const current = this._stateManager.getSessionState(sessionKey)?._meta;
this._stateManager.setSessionMeta(sessionKey, withSessionGitState(current, gitState));
this._updateBranchChangesetDescription(sessionKey, gitState);

// Update session state manager
const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta;
const nextMeta = withSessionGitState(currentMeta, gitState);
this._stateManager.setSessionMeta(sessionKey, nextMeta);

// Update session database
void this._saveSessionState(sessionKey, META_GIT_STATE, JSON.stringify(gitState));
}

private _stripGitOnlyChangesetEntries(sessionKey: string): void {
Expand Down Expand Up @@ -102,4 +172,28 @@ export class AgentHostGitStateService implements IAgentHostGitStateService {
}
this._stateManager.setSessionChangesets(sessionKey, next);
}

private async _saveSessionState(sessionKey: string, key: string, value: string): Promise<void> {
// Skip saving session state if the session is not materialized
const state = this._stateManager.getSessionState(sessionKey);
if (state?.lifecycle === SessionLifecycle.Creating) {
return;
}

let databaseRef;
try {
databaseRef = this._sessionDataService.openDatabase(URI.parse(sessionKey));
} catch (error) {
this._logService.warn(`[AgentHostGitStateService][_saveSessionState] Failed to open session database for ${sessionKey}`, error);
return;
}

try {
await databaseRef.object.setMetadata(key, value);
} catch (error) {
this._logService.warn(`[AgentHostGitStateService][_saveSessionState] Failed to persist ${key}`, error);
} finally {
databaseRef.dispose();
}
}
}
Loading
Loading