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
2 changes: 1 addition & 1 deletion src/vs/platform/agentHost/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ If a provider cannot enumerate yet, its initial discovery attempt emits nothing;

`listSessions()` coalesces concurrent computations per external-sessions mode, so the burst of calls a multi-window restore produces shares one registry traversal instead of one per window. The shared entry records the registry epoch it started at and is invalidated by every registry mutation. A computation whose epoch changes restarts against the new registry, so both existing and later callers receive a complete post-mutation snapshot; each caller receives its own array.

Legacy registry migration uses the `listChatsToMigrate()` contract. An array is authoritative even when empty, while `undefined` means the catalog is unavailable and must not advance migration markers. Agent Service retries an unavailable registration-time catalog once before listing; persistent unavailability rejects the aggregate `listSessions()` call with a typed provider-catalog error so clients preserve their last successful snapshots and retry with their existing backoff. Replacement retry ownership is compare-and-swap single-flight: overlapping list computations that observed the same failed attempt await the first caller's installed retry rather than queueing another provider enumeration. Successful providers retain their completed migration state when a sibling provider is unavailable.
Legacy registry migration uses the `listChatsToMigrate()` contract. An array is authoritative even when empty, while `undefined` means the catalog is unavailable and must not advance migration markers. Agent Service retries an unavailable registration-time catalog once before listing; persistent unavailability rejects the aggregate `listSessions()` call with a typed provider-catalog error so clients preserve their last successful snapshots. `BaseAgentHostSessionsProvider` retries failures with exponential backoff; `AgentHostSessionListStore` leaves its cache invalid and retries on the next controller, lifecycle, or workspace refresh trigger. Replacement retry ownership is compare-and-swap single-flight: overlapping list computations that observed the same failed attempt await the first caller's installed retry rather than queueing another provider enumeration. Successful providers retain their completed migration state when a sibling provider is unavailable.

Session-list clients treat only a successful return as authoritative. `BaseAgentHostSessionsProvider` and `AgentHostSessionListStore` retain their last successful snapshots when `listSessions()` rejects; a successful empty array still clears the snapshot. This separation prevents transport, authentication, or catalog failures from becoming deletion deltas.

Expand Down
11 changes: 8 additions & 3 deletions src/vs/platform/agentHost/node/agentHostGitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class AgentHostGitService implements IAgentHostGitService {

async getDefaultBranch(workingDirectory: URI): Promise<IDefaultBranch | undefined> {
// Try to read the default branch from the remote HEAD reference
const remoteRef = (await this._runGit(workingDirectory, ['symbolic-ref', 'refs/remotes/origin/HEAD']))?.trim();
const remoteRef = (await this._runGit(workingDirectory, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']))?.trim();
if (remoteRef) {
if (!remoteRef.startsWith('refs/remotes/origin/')) {
return { name: remoteRef, startPoint: remoteRef };
Expand Down Expand Up @@ -1308,7 +1308,9 @@ export function parseUntrackedPaths(output: string | undefined): string[] {
* Parses NUL-separated `git status --porcelain=v1 -z --untracked-files=all`
* output and returns all changed repo-relative paths. Rename/copy entries
* include both the destination and source paths so scoped `git add -A`
* stages both sides of the change.
* stages both sides of the change. Paths added to the index and then deleted
* from the worktree are omitted because they do not exist in either HEAD or
* the worktree.
*
* Exported for tests.
*/
Expand All @@ -1334,7 +1336,10 @@ export function parseChangedPaths(output: string | undefined, includeStatus: (st
const path = seg.substring(3);
const isRenameOrCopy = status[0] === 'R' || status[1] === 'R' || status[0] === 'C' || status[1] === 'C';
if (includeStatus(status)) {
addPath(path);
const isDeletedIndexAddition = status[1] === 'D' && (status[0] === 'A' || status[0] === 'R' || status[0] === 'C');
if (!isDeletedIndexAddition) {
addPath(path);
}
if (isRenameOrCopy) {
const sourcePath = segments[++i];
if (sourcePath) {
Expand Down
41 changes: 36 additions & 5 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1836,7 +1836,18 @@ export class AgentService extends Disposable implements IAgentService {
// chat backings and subagent sessions never enter it; ephemeral sessions
// are tombstoned at creation. A transiently missing provider snapshot no
// longer evicts a session.
const registered = await this._listRegisteredSessions();
const allRegistered = await this._listRegisteredSessions();
// External sessions that the current mode hides outright are dropped
// before any provider or database read. On a large catalogue these are
// most of the registry, and each one otherwise costs a provider metadata
// round-trip plus several session-database opens. Their keys are kept so
// the state-manager overlay below cannot re-surface them as fallbacks.
const hiddenExternal = this._hidesAllExternalSessions(mode)
? new Set(allRegistered.filter(entry => entry.external).map(entry => entry.session.toString()))
: new Set<string>();
const registered = hiddenExternal.size > 0
? allRegistered.filter(entry => !hiddenExternal.has(entry.session.toString()))
: allRegistered;
const metadataLimiter = new Limiter<IAgentSessionMetadata | undefined>(4);
const results = await Promise.all(registered.map(registeredSession => metadataLimiter.queue(async (): Promise<IAgentSessionMetadata | undefined> => {
const { session, provider, external } = registeredSession;
Expand Down Expand Up @@ -2000,7 +2011,10 @@ export class AgentService extends Disposable implements IAgentService {
// Idle provisional sessions are deliberately *not* overlaid so the
// new-session composer's eagerly-created session doesn't leak into the
// list before its first message (#321269).
const known = new Set(withStatus.map(s => s.session.toString()));
const known = new Set(hiddenExternal);
for (const session of withStatus) {
known.add(session.session.toString());
}
const additions: IAgentSessionMetadata[] = [];
for (const summary of this._stateManager.getOverlaySessionSummaries()) {
if (known.has(summary.resource)) {
Expand Down Expand Up @@ -2039,19 +2053,21 @@ export class AgentService extends Disposable implements IAgentService {
: undefined;
const visible: IAgentSessionMetadata[] = [];
// Adoptable-legacy rows are withheld by migrate-legacy, not by the external mode.
let hiddenByExternalMode = 0;
// Sessions skipped above were hidden by the mode too, so they still count.
let hiddenByExternalMode = hiddenExternal.size;
for (const session of combined) {
if (this._shouldIncludeSession(session, mode, now, recentSessionKeys)) {
visible.push(session);
} else if (!readSessionEhcliAdoptable(session._meta)) {
hiddenByExternalMode++;
}
}
this._logHiddenSessions(hiddenByExternalMode, combined.length, mode);
const total = combined.length + hiddenExternal.size;
this._logHiddenSessions(hiddenByExternalMode, total, mode);

// A catalog pass opens every registered session's database, so it can be slow.
const duration = Date.now() - startedAt;
const message = `[AgentService] listSessions computed ${visible.length} of ${combined.length} session(s) for mode '${mode}' in ${duration}ms (${additions.length} state-manager fallback)`;
const message = `[AgentService] listSessions computed ${visible.length} of ${total} session(s) for mode '${mode}' in ${duration}ms (${additions.length} state-manager fallback)`;
if (duration >= SLOW_LIST_SESSIONS_THRESHOLD_MS) {
this._logService.info(message);
} else {
Expand Down Expand Up @@ -2134,6 +2150,21 @@ export class AgentService extends Disposable implements IAgentService {
}
}

/**
* Whether {@link _shouldIncludeSession} is guaranteed to reject every
* external session under `mode`, letting {@link _computeSessions} drop them
* on the registry's `external` flag alone.
*
* `None` is the only mode that rejects external sessions outright. The
* adoptable-legacy exemption is the single way one could still be visible,
* and while migration is off that marker forces exclusion as well — which
* matters because the marker is only discoverable from the provider
* metadata read this skip avoids.
*/
private _hidesAllExternalSessions(mode: AgentHostExternalSessionsMode): boolean {
return mode === AgentHostExternalSessionsMode.None && !this._isMigrateLegacyEnabled();
}

/**
* Stage-1 validation surface for the session URIs currently held by the
* orchestrator-owned {@link AgentSessionRegistry}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvid
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { AgentHostGitService } from '../../node/agentHostGitService.js';

function createGitService(disposables: Pick<DisposableStore, 'add'>): AgentHostGitService {
const logService = new NullLogService();
class TestLogService extends NullLogService {
readonly warnings: string[] = [];

override warn(message: string): void {
this.warnings.push(message);
}
}

function createGitService(disposables: Pick<DisposableStore, 'add'>, logService: NullLogService = new NullLogService()): AgentHostGitService {
const fileService = disposables.add(new FileService(logService));
disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService))));
const env: Partial<INativeEnvironmentService> = { tmpDir: URI.file(tmpdir()) };
Expand All @@ -54,10 +61,12 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => {

let tmpRoot: string | undefined;
let svc: AgentHostGitService | undefined;
let logService: TestLogService;

setup(() => {
tmpRoot = undefined;
svc = createGitService(disposables);
logService = new TestLogService();
svc = createGitService(disposables, logService);
});

teardown(() => {
Expand Down Expand Up @@ -150,6 +159,18 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => {
});
});

(hasGit ? test : test.skip)('does not warn when the default remote-tracking ref is missing', async () => {
const dir = initRepo();

assert.deepStrictEqual({
defaultBranch: await svc!.getDefaultBranch(URI.file(dir)),
warnings: logService.warnings,
}, {
defaultBranch: undefined,
warnings: [],
});
});

(hasGit ? test : test.skip)('falls back to the local branch when the default remote-tracking ref is missing', async () => {
const dir = initRepo();
cp.execFileSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'], { cwd: dir, stdio: 'pipe' });
Expand Down Expand Up @@ -310,6 +331,23 @@ suite('AgentHostGitService - computeSessionFileDiffs (real git)', () => {
});
});

(hasGit ? test : test.skip)('ignores an index addition deleted from the worktree during temp-index staging', async () => {
const fs = await import('fs/promises');
const { dir, run } = initRepo();
await fs.writeFile(join(dir, 'tracked.txt'), 'tracked\n');
run('add', '.');
run('commit', '-q', '-m', 'init');

await fs.writeFile(join(dir, 'deleted-addition.txt'), 'temporary\n');
run('add', 'deleted-addition.txt');
await fs.unlink(join(dir, 'deleted-addition.txt'));
await fs.writeFile(join(dir, 'fresh.txt'), 'fresh\n');

const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' });

assert.deepStrictEqual(result?.map(diff => URI.parse(diff.after?.uri ?? diff.before!.uri).path.split('/').pop()), ['fresh.txt']);
});

(hasGit && !isWindows ? test : test.skip)('returns undefined when temp-index staging fails', async () => {
const fs = await import('fs/promises');
const { dir } = initRepo();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,9 @@ suite('AgentHostGitService', () => {
'renamed-old.txt',
' C copied-new.txt',
'copied-old.txt',
'AD deleted-index-addition.txt',
'RD deleted-rename-destination.txt',
'rename-source.txt',
' M modified.txt',
'',
].join('\x00');
Expand All @@ -291,6 +294,7 @@ suite('AgentHostGitService', () => {
'renamed-old.txt',
'copied-new.txt',
'copied-old.txt',
'rename-source.txt',
]);
});

Expand Down
77 changes: 59 additions & 18 deletions src/vs/platform/agentHost/test/node/agentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import { ChatInteractivity, type MessageAttachment } from '../../common/state/pr
import { IProductService } from '../../../product/common/productService.js';
import { AgentService } from '../../node/agentService.js';
import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions } from '../../node/agentHostDatabase.js';
import { AgentSessionRegistry } from '../../node/agentSessionRegistry.js';
import { AgentSessionRegistry, type IRegisteredSession } from '../../node/agentSessionRegistry.js';
import { AgentHostManagementService } from '../../node/agentHostManagementService.js';
import { AGENT_HOST_TITLE_SOURCE_AUTO, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js';
import { MockAgent, ScriptedMockAgent } from './mockAgent.js';
Expand Down Expand Up @@ -3101,6 +3101,42 @@ suite('AgentService (node dispatcher)', () => {
});
});

test('a mode that hides every external session skips the catalog work for them', async () => {
const now = Date.now();
const perSession = createPerSessionDataService();
const svc = createExternalSessionService(() => now, perSession.service);
const agent = disposables.add(new TimedExternalAgent('copilot'));
agent.addSession('external-one', now);
agent.addSession('external-two', now);
svc.registerProvider(agent);
await svc.listSessions(AgentHostExternalSessionsMode.All);

// A catalog pass otherwise opens every registered session's database,
// so a mode that discards the row regardless must not pay for it.
const opened: string[] = [];
const dataService = perSession.service as { tryOpenDatabase(session: URI): Promise<unknown> };
const originalTryOpen = dataService.tryOpenDatabase;
dataService.tryOpenDatabase = async (session: URI) => {
opened.push(AgentSession.id(session));
return originalTryOpen.call(perSession.service, session);
};
try {
const hidden = (await svc.listSessions(AgentHostExternalSessionsMode.None)).map(session => AgentSession.id(session.session));
const openedWhileHidden = [...new Set(opened)].sort();
opened.length = 0;
const visible = (await svc.listSessions(AgentHostExternalSessionsMode.All)).map(session => AgentSession.id(session.session)).sort();

assert.deepStrictEqual({ hidden, openedWhileHidden, visible, openedWhileVisible: [...new Set(opened)].sort() }, {
hidden: [],
openedWhileHidden: [],
visible: ['external-one', 'external-two'],
openedWhileVisible: ['external-one', 'external-two'],
});
} finally {
dataService.tryOpenDatabase = originalTryOpen;
}
});

test('a mode change reconciles with a single catalog pass', async () => {
const day = 24 * 60 * 60 * 1000;
const now = Date.now();
Expand Down Expand Up @@ -3603,29 +3639,34 @@ suite('AgentService (node dispatcher)', () => {
const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new MockAgent('copilot'));
svc.registerProvider(agent);
const gate = new DeferredPromise<void>();
const inner = svc as unknown as { _computeSessions(mode: AgentHostExternalSessionsMode): Promise<readonly IAgentSessionMetadata[]> };
const original = inner._computeSessions;
let computations = 0;
inner._computeSessions = async mode => {
computations++;
await gate.p;
return original.call(svc, mode);
await svc.listSessions();
const snapshotCaptured = new DeferredPromise<void>();
const releaseSnapshot = new DeferredPromise<void>();
const inner = svc as unknown as { _listRegisteredSessions(): Promise<IRegisteredSession[]> };
const original = inner._listRegisteredSessions;
let listCalls = 0;
inner._listRegisteredSessions = async () => {
const snapshot = await original.call(svc);
listCalls++;
if (listCalls === 1) {
snapshotCaptured.complete();
await releaseSnapshot.p;
}
return snapshot;
};

const preInvalidation = svc.listSessions();
const listing = svc.listSessions();
await snapshotCaptured.p;
await svc.createSession({ provider: 'copilot' });
const postInvalidation = svc.listSessions();
gate.complete();
releaseSnapshot.complete();
const listed = await listing;

assert.deepStrictEqual({
computations,
preInvalidation: (await preInvalidation).length,
postInvalidation: (await postInvalidation).length,
listCalls,
listed: listed.length,
}, {
computations: 2,
preInvalidation: 1,
postInvalidation: 1,
listCalls: 2,
listed: 1,
});
});

Expand Down
55 changes: 55 additions & 0 deletions src/vs/platform/github/test/node/programmableGitHubServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,61 @@ suite('ProgrammableGitHubServer', () => {
});
});

test('rejects only the selected mutation when it selects a Query-root-only field', async () => {
const documents: Record<string, { readonly query: string; readonly operationName?: string }> = {
'valid mutation': {
query: 'mutation M($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { id } } }',
},
'mutation selecting rateLimit': {
query: 'mutation M($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { id } } rateLimit { limit } }',
},
'mutation preceded by a fragment definition': {
query: 'fragment F on PullRequestReviewThread { id }\nmutation M($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { ...F } } rateLimit { limit } }',
},
'selected mutation of a multi-operation document': {
query: 'query Q { repository(owner: "o", name: "r") { id } }\nmutation M { enqueuePullRequest(input: {}) { clientMutationId } rateLimit { limit } }',
operationName: 'M',
},
'valid mutation beside a query selecting rateLimit': {
query: 'mutation M { enqueuePullRequest(input: {}) { clientMutationId } }\nquery Q { repository(owner: "o", name: "r") { id } rateLimit { limit } }',
operationName: 'M',
},
'query selecting rateLimit': {
query: 'query Q { repository(owner: "o", name: "r") { id } rateLimit { limit } }',
},
'mutation selecting rateLimit below the root': {
query: 'mutation M { enqueuePullRequest(input: {}) { rateLimit { limit } } }',
},
'mutation naming rateLimit inside a string argument': {
query: 'mutation M { addComment(input: { body: "rateLimit { limit }" }) { clientMutationId } }',
},
};

const rejected: Record<string, boolean> = {};
for (const [name, body] of Object.entries(documents)) {
await withServer(async server => {
server.enqueue(gitHubGraphQLStep({ response: gitHubGraphQLResponse({ ok: true }) }));
const response = await nodeFetch(server.graphQlUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
rejected[name] = response.status === 500 && (await response.text()).includes('only exposes on Query');
});
}

assert.deepStrictEqual(rejected, {
'valid mutation': false,
'mutation selecting rateLimit': true,
'mutation preceded by a fragment definition': true,
'selected mutation of a multi-operation document': true,
'valid mutation beside a query selecting rateLimit': false,
'query selecting rateLimit': false,
'mutation selecting rateLimit below the root': false,
'mutation naming rateLimit inside a string argument': false,
});
});

test('assertSatisfied reports unconsumed steps', async () => {
await withServer(async server => {
server.enqueue(gitHubRestStep({
Expand Down
Loading
Loading