Skip to content
Draft
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
3 changes: 3 additions & 0 deletions src/vs/platform/agentHost/browser/agentHostProtocolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1650,6 +1650,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
...(options.sideChat.selection ? { selection: options.sideChat.selection } : {}),
}
} : {}),
...(options?.workingDirectories !== undefined && !options.fork
? { workingDirectories: options.workingDirectories.map(directory => fromAgentHostUri(directory).toString()) }
: {}),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ export interface IChangesetOperationContext {
readonly gitHubState?: ISessionGitHubState;
}

/** Host-owned repository context for one concrete changeset channel. */
export interface IChangesetOperationTargetContext {
readonly ownerUri: URI;
readonly workingDirectories: readonly URI[];
readonly gitState?: ISessionGitState;
readonly gitHubState?: ISessionGitHubState;
}

/**
* Registration surface handed to changeset operation contributions.
*
Expand Down Expand Up @@ -142,6 +150,8 @@ export interface IAgentHostChangesetOperationService extends IDisposable {
* unregisters the handlers and disposes the contribution.
*/
registerContribution(contribution: IChangesetOperationContribution): IDisposable;
/** Associates a concrete changeset channel with its trusted repository context. */
setChangesetTarget(sessionKey: string, changeset: string, target: IChangesetOperationTargetContext | undefined): void;
/**
* Recomputes operations using the provided or current Git state.
* Without Git state, clears cached operations but defers initial publication.
Expand Down
3 changes: 3 additions & 0 deletions src/vs/platform/agentHost/common/agentHostGitStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export interface IAgentHostGitStateService {
*/
refreshSessionGitState(sessionKey: string, workingDirectory?: URI): Promise<void>;

/** Finds the pull request for a working directory without changing session-level Git metadata. */
findPullRequestForWorkingDirectory(sessionKey: string, workingDirectory: URI): Promise<string | undefined>;

/** Merges the branch identity known when an isolated worktree materializes into session metadata. */
getMaterializedWorktreeMeta(sessionKey: string, branchName: string): SessionSummaryMeta | undefined;

Expand Down
9 changes: 9 additions & 0 deletions src/vs/platform/agentHost/common/agentHostSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,9 @@ export const agentHostProxyConfigSchema = createSchema(agentHostProxyConfigDefin
/** Root config key forwarded from the renderer for active-agent title generation. */
export const AgentHostActiveAgentTitleGenerationConfigKey = 'activeAgentTitleGeneration';

/** Root config key controlling whether create_session exposes its relationship choice. */
export const AgentHostCreateSessionRelationshipConfigKey = 'createSessionRelationship';

/** Root config key controlling rich-link guidance for Markdown plan documents. */
export const AgentHostMarkdownPlanRichLinksEnabledConfigKey = 'markdownPlanRichLinksEnabled';

Expand Down Expand Up @@ -881,6 +884,12 @@ export const platformRootSchema = createSchema({
description: localize('agentHost.config.activeAgentTitleGeneration.description', "Whether the active agent names sessions and chats with rename tools instead of utility-model title generation."),
default: false,
}),
[AgentHostCreateSessionRelationshipConfigKey]: schemaProperty<boolean>({
type: 'boolean',
title: localize('agentHost.config.createSessionRelationship.title', "Create Session Relationship"),
description: localize('agentHost.config.createSessionRelationship.description', "Whether agents can choose between creating a chat in the current session and creating an independent session. When disabled, delegated work is always created as a chat in the current session."),
default: true,
}),
[AgentHostMarkdownPlanRichLinksEnabledConfigKey]: schemaProperty<boolean>({
type: 'boolean',
title: localize('agentHost.config.markdownPlanRichLinks.title', "Markdown Plan Rich Links"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
AgentHostByokModelsEnabledSettingId,
AgentHostGitHubMcpServerEnabledSettingId,
AgentHostActiveAgentTitleGenerationSettingId,
AgentHostCreateSessionRelationshipSettingId,
AgentHostClaudeAgentEnabledSettingId,
AgentHostClaudeMultiRootEnabledSettingId,
AgentHostCodexAgentBinaryArgsSettingId,
Expand All @@ -40,6 +41,7 @@ import {
AgentHostActiveAgentTitleGenerationConfigKey,
AgentHostAutoAttachPullRequestsConfigKey,
AgentHostByokModelsEnabledConfigKey,
AgentHostCreateSessionRelationshipConfigKey,
AgentHostGitHubMcpServerEnabledConfigKey,
AgentHostCodexEnabledConfigKey,
AgentHostCodexMultiRootEnabledConfigKey,
Expand Down Expand Up @@ -191,6 +193,15 @@ configurationRegistry.registerConfiguration({
experiment: { mode: 'auto' },
agentHost: { key: AgentHostActiveAgentTitleGenerationConfigKey },
},
[AgentHostCreateSessionRelationshipSettingId]: {
type: 'boolean',
description: nls.localize('chat.agentHost.experimental.createSessionRelationship', "Controls whether agents can choose to create delegated work in the current session or as an independent session. When disabled, delegated work is always created as a chat in the current session."),
default: true,
scope: ConfigurationScope.APPLICATION,
tags: ['experimental', 'advanced'],
experiment: { mode: 'auto' },
agentHost: { key: AgentHostCreateSessionRelationshipConfigKey },
},
...artifactToolsConfigurationProperties,
[AgentHostAutoAttachPullRequestsSettingId]: {
type: 'boolean',
Expand Down
13 changes: 13 additions & 0 deletions src/vs/platform/agentHost/common/agentMerge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export interface AgentMergeSessionOverrides {
export interface AgentMergeTarget {
readonly branchName: string;
readonly pullRequestUrl?: string;
/** Chat whose repository and turns Agent Merge owns. Absent on legacy targets. */
readonly chatUri?: string;
/** Working directory Agent Merge must inspect and mutate. Absent on legacy targets. */
readonly workingDirectory?: string;
/** Whether the controller still needs to announce this newly enabled target. */
readonly announcementPending?: boolean;
readonly enabledAt: string;
readonly commentWatermark: string;
}
Expand Down Expand Up @@ -283,6 +289,10 @@ export const agentMergeDisableReasons = {
log: 'the associated pull request URL is invalid',
notice: localize('agentMerge.disabled.invalidPullRequestUrl', "Agent Merge was disabled because the associated pull request URL is invalid."),
}),
targetUnavailable: (): AgentMergeDisableReason => ({
log: 'the owning chat or working directory is no longer available',
notice: localize('agentMerge.disabled.targetUnavailable', "Agent Merge was disabled because its chat or working directory is no longer available in this session."),
}),
differentGitHubHost: (): AgentMergeDisableReason => ({
log: 'the bound pull request belongs to a different GitHub host than the signed-in account',
notice: localize('agentMerge.disabled.differentGitHubHost', "Agent Merge was disabled because its pull request belongs to a different GitHub host than the signed-in account."),
Expand Down Expand Up @@ -597,6 +607,9 @@ function readTarget(value: unknown): AgentMergeTarget | undefined {
enabledAt: value.enabledAt,
commentWatermark: value.commentWatermark,
...(typeof value.pullRequestUrl === 'string' ? { pullRequestUrl: value.pullRequestUrl } : {}),
...(typeof value.chatUri === 'string' ? { chatUri: value.chatUri } : {}),
...(typeof value.workingDirectory === 'string' ? { workingDirectory: value.workingDirectory } : {}),
...(value.announcementPending === true ? { announcementPending: true } : {}),
};
}

Expand Down
3 changes: 3 additions & 0 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ export const AgentHostGitHubMcpServerEnabledSettingId = 'chat.agentHost.githubMc
/** Configuration key gating active-agent session and chat title generation. */
export const AgentHostActiveAgentTitleGenerationSettingId = 'chat.agentHost.experimental.activeAgentTitleGeneration';

/** Configuration key controlling whether agents choose how delegated work relates to the current session. */
export const AgentHostCreateSessionRelationshipSettingId = 'chat.agentHost.experimental.createSessionRelationship';

/** Configuration key enabling rich-link guidance for Markdown plan documents. */
export const AgentHostMarkdownPlanRichLinksEnabledSettingId = 'chat.agentHost.experimental.markdownPlanRichLinks';

Expand Down
11 changes: 10 additions & 1 deletion src/vs/platform/agentHost/common/changesetUri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { localize } from '../../../nls.js';
import { readAgentMergeSessionState } from './agentMerge.js';
import { isAgentMergeMessage } from './meta/agentMergeMessageMeta.js';
import { AgentSystemNotificationKind, readAgentSystemNotificationMeta } from './meta/agentSystemNotificationMeta.js';
import { MessageKind, readSessionGitState, readSessionWorkspaceless, ResponsePartKind, SessionLifecycle, type Changeset, type ISessionGitState, type ISessionWithDefaultChat, type URI } from './state/sessionState.js';
import { isAhpChatChannel, MessageKind, parseDefaultChatUri, readSessionGitState, readSessionWorkspaceless, ResponsePartKind, SessionLifecycle, type Changeset, type ISessionGitState, type ISessionWithDefaultChat, type URI } from './state/sessionState.js';

/**
* Helpers for building / parsing the URI clients subscribe to in order to
Expand Down Expand Up @@ -282,6 +282,15 @@ export function parseChangesetUri(uri: URI): { sessionUri: URI; changesetId: str
return { sessionUri, changesetId, kind: ChangesetKind.Unknown };
}

/** Resolves the parent session of either a session- or chat-owned changeset URI. */
export function getChangesetSessionUri(uri: URI): URI | undefined {
const owner = parseChangesetUri(uri)?.sessionUri;
if (!owner) {
return undefined;
}
return isAhpChatChannel(owner) ? parseDefaultChatUri(owner) : owner;
}

/** Returns `true` iff `uri` looks like a changeset URI we recognise. */
export function isChangesetUri(uri: URI): boolean {
return parseChangesetUri(uri) !== undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface ISessionArtifactInput {
readonly label: string;
/** `true` for an artifact the session produced, `false` for a reference. */
readonly isArtifact: boolean;
/** Host-owned chat attribution. Model-provided inputs never populate this field. */
readonly chat?: string;
readonly link?: string;
readonly uri?: string;
readonly commitHash?: string;
Expand Down Expand Up @@ -153,7 +155,8 @@ export class SessionArtifactCollection {
add(input: ISessionArtifactInput, createId: () => string): IAddSessionArtifactResult {
const artifact = this._create(input, createId);
const value = getSessionArtifactValue(artifact);
const existing = this._artifacts.find(candidate => getSessionArtifactValue(candidate) === value);
const existing = this._artifacts.find(candidate =>
candidate.chat === artifact.chat && getSessionArtifactValue(candidate) === value);
if (existing) {
return { artifacts: this._artifacts, artifact: existing, added: false };
}
Expand Down Expand Up @@ -187,13 +190,15 @@ export class SessionArtifactCollection {
id: string;
type: SessionArtifactType;
label: string;
chat?: string;
isArtifact: boolean;
link?: string;
uri?: string;
commitHash?: string;
isGitHub?: boolean;
} = { id: createId(), type: input.type, label: input.label, isArtifact: input.isArtifact };

if (input.chat !== undefined) { artifact.chat = input.chat; }
if (input.link !== undefined) { artifact.link = input.link; }
if (input.uri !== undefined) { artifact.uri = input.uri; }
if (input.commitHash !== undefined) { artifact.commitHash = input.commitHash; }
Expand Down
4 changes: 4 additions & 0 deletions src/vs/platform/agentHost/common/sessionArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface ISessionArtifact {
readonly id: string;
readonly type: SessionArtifactType;
readonly label: string;
/** Chat that recorded this entry. Absent for session-wide entries. */
readonly chat?: string;
/**
* `true` for an artifact — something this session produced — and `false` for
* a reference, something it only points the user at.
Expand Down Expand Up @@ -79,6 +81,7 @@ function parseSessionArtifact(value: unknown): ISessionArtifact | undefined {
id: string;
type: SessionArtifactType;
label: string;
chat?: string;
isArtifact: boolean;
link?: string;
uri?: string;
Expand All @@ -95,6 +98,7 @@ function parseSessionArtifact(value: unknown): ISessionArtifact | undefined {
if (typeof raw['uri'] === 'string') { artifact.uri = raw['uri']; }
if (typeof raw['commitHash'] === 'string') { artifact.commitHash = raw['commitHash']; }
if (typeof raw['isGitHub'] === 'boolean') { artifact.isGitHub = raw['isGitHub']; }
if (typeof raw['chat'] === 'string') { artifact.chat = raw['chat']; }
return artifact;
}

Expand Down
16 changes: 16 additions & 0 deletions src/vs/platform/agentHost/common/worktreePaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { basename } from '../../../base/common/path.js';
import { Schemas } from '../../../base/common/network.js';
import { isEqual, isEqualOrParent, normalizePath } from '../../../base/common/resources.js';
import { URI } from '../../../base/common/uri.js';

Expand Down Expand Up @@ -37,3 +38,18 @@ export function isWorktreeUnderRepository(candidate: URI, repositoryRoot: URI):
const normalizedCandidate = normalizePath(candidate);
return isEqualOrParent(normalizedCandidate, worktreesRoot) && !isEqual(normalizedCandidate, worktreesRoot);
}

/** Derives the source repository from a worktree directly under its conventional `<repo>.worktrees` sibling. */
export function deriveRepositoryRootFromWorktree(worktree: URI): URI | undefined {
if (worktree.scheme !== Schemas.file) {
return undefined;
}
const worktreesRoot = URI.joinPath(worktree, '..');
const worktreesRootName = basename(worktreesRoot.fsPath);
const suffix = '.worktrees';
if (!worktreesRootName.endsWith(suffix)) {
return undefined;
}
const repositoryName = worktreesRootName.slice(0, -suffix.length);
return repositoryName ? URI.joinPath(worktreesRoot, '..', repositoryName) : undefined;
}
27 changes: 23 additions & 4 deletions src/vs/platform/agentHost/node/agentConfigurationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,48 @@ import type { ISessionSandboxPolicy } from './sessionSandbox.js';

export const IAgentConfigurationService = createDecorator<IAgentConfigurationService>('agentConfigurationService');

function getSessionWorkingDirectories(stateManager: AgentHostStateManager, session: ProtocolURI): string[] | undefined {
const summaryWorkingDirectories = stateManager.getSessionSummary(session)?.workingDirectories;
const stateWorkingDirectories = stateManager.getSessionState(session)?.workingDirectories;
if (!stateWorkingDirectories?.length) {
return summaryWorkingDirectories;
}
if (!summaryWorkingDirectories?.length) {
return stateWorkingDirectories;
}

const workingDirectories = [...stateWorkingDirectories];
for (const workingDirectory of summaryWorkingDirectories.slice(1)) {
if (!workingDirectories.includes(workingDirectory)) {
workingDirectories.push(workingDirectory);
}
}
return workingDirectories;
}

/**
* @deprecated Use {@link getEffectiveWorkingDirectories} instead, which preserves every root instead of collapsing to the primary.
*/
export function getEffectiveWorkingDirectory(stateManager: AgentHostStateManager, session: ProtocolURI): string | undefined {
const own = stateManager.getSessionState(session)?.workingDirectories?.[0];
const own = getSessionWorkingDirectories(stateManager, session)?.[0];
if (own !== undefined) {
return own;
}
const parentInfo = parseSubagentSessionUri(session);
if (parentInfo) {
return stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectories?.[0];
return getSessionWorkingDirectories(stateManager, parentInfo.parentSession.toString())?.[0];
}
return undefined;
}

export function getEffectiveWorkingDirectories(stateManager: AgentHostStateManager, session: ProtocolURI): string[] | undefined {
const own = stateManager.getSessionState(session)?.workingDirectories;
const own = getSessionWorkingDirectories(stateManager, session);
if (own !== undefined) {
return own;
}
const parentInfo = parseSubagentSessionUri(session);
if (parentInfo) {
return stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectories;
return getSessionWorkingDirectories(stateManager, parentInfo.parentSession.toString());
}
return undefined;
}
Expand Down
12 changes: 7 additions & 5 deletions src/vs/platform/agentHost/node/agentHostCatalogProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ const artifactValidator = plainObject(vObj({
id: boundedString(),
type: vEnum('pullRequest', 'issue', 'commit', 'website', 'file', 'resource'),
label: boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT),
chat: vOptionalProp(uriString()),
isArtifact: vOptionalProp(vBoolean()),
link: vOptionalProp(boundedString()),
uri: vOptionalProp(boundedString()),
Expand Down Expand Up @@ -299,6 +300,11 @@ const metadataValidator = plainObject(vObj({
[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: vOptionalProp(devContainerWorktreeValidator),
}));

const workingDirectoriesValidator = new RefinedValidator(
boundedArray(uriString(), AGENT_HOST_CATALOG_CHILD_LIMIT),
value => hasUniqueValues(value, directory => directory) ? value : { message: 'Working directories must be unique.' },
);

const chatValidator = plainObject(vObj({
uri: uriString(),
order: safeInteger(),
Expand All @@ -307,6 +313,7 @@ const chatValidator = plainObject(vObj({
titleSource: vOptionalProp(vEnum('user', 'agent', 'auto')),
origin: vOptionalProp(jsonValue()),
inheritedTurnId: vOptionalProp(boundedString(AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT)),
workingDirectories: vOptionalProp(workingDirectoriesValidator),
}));

const chatsValidator = new RefinedValidator(
Expand All @@ -323,11 +330,6 @@ const chatsValidator = new RefinedValidator(
},
);

const workingDirectoriesValidator = new RefinedValidator(
boundedArray(uriString(), AGENT_HOST_CATALOG_CHILD_LIMIT),
value => hasUniqueValues(value, directory => directory) ? value : { message: 'Working directories must be unique.' },
);

export const agentHostCatalogDataValidator = plainObject(vObj({
modifiedTime: safeInteger(),
summary: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface ICatalogSourceState {
readonly title?: string;
readonly origin?: ChatOrigin;
readonly inheritedTurnId?: string;
readonly workingDirectories?: readonly string[];
}[];
}

Expand Down Expand Up @@ -225,6 +226,7 @@ export class AgentHostCatalogSourceResolver {
titleSource: normalizeCatalogTitleSource(titleSource),
origin: toCatalogChatOrigin(chat.origin),
...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}),
...(chat.workingDirectories !== undefined ? { workingDirectories: chat.workingDirectories } : {}),
};
}),
};
Expand Down
Loading
Loading