diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index bd75274eec2430..7d8ad2078ddb5c 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -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. diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index cfaeb27d56b67f..891246628310fb 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -59,7 +59,7 @@ export class AgentHostGitService implements IAgentHostGitService { async getDefaultBranch(workingDirectory: URI): Promise { // 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 }; @@ -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. */ @@ -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) { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 954cc39b78ccde..02cc9da6f8d40b 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -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(); + const registered = hiddenExternal.size > 0 + ? allRegistered.filter(entry => !hiddenExternal.has(entry.session.toString())) + : allRegistered; const metadataLimiter = new Limiter(4); const results = await Promise.all(registered.map(registeredSession => metadataLimiter.queue(async (): Promise => { const { session, provider, external } = registeredSession; @@ -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)) { @@ -2039,7 +2053,8 @@ 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); @@ -2047,11 +2062,12 @@ export class AgentService extends Disposable implements IAgentService { 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 { @@ -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}. diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 00adf4eb4fda8c..bdace033db24e5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -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): 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, 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 = { tmpDir: URI.file(tmpdir()) }; @@ -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(() => { @@ -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' }); @@ -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(); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index e5261c6c7376d1..d01bc18bbc823e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -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'); @@ -291,6 +294,7 @@ suite('AgentHostGitService', () => { 'renamed-old.txt', 'copied-new.txt', 'copied-old.txt', + 'rename-source.txt', ]); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index e5b6a84a3d8a23..9f17a9b43c8a94 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -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'; @@ -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 }; + 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(); @@ -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(); - const inner = svc as unknown as { _computeSessions(mode: AgentHostExternalSessionsMode): Promise }; - 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(); + const releaseSnapshot = new DeferredPromise(); + const inner = svc as unknown as { _listRegisteredSessions(): Promise }; + 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, }); }); diff --git a/src/vs/platform/github/test/node/programmableGitHubServer.test.ts b/src/vs/platform/github/test/node/programmableGitHubServer.test.ts index 085844e4b51f1a..fa09d897f372bf 100644 --- a/src/vs/platform/github/test/node/programmableGitHubServer.test.ts +++ b/src/vs/platform/github/test/node/programmableGitHubServer.test.ts @@ -235,6 +235,61 @@ suite('ProgrammableGitHubServer', () => { }); }); + test('rejects only the selected mutation when it selects a Query-root-only field', async () => { + const documents: Record = { + '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 = {}; + 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({ diff --git a/src/vs/platform/github/test/node/programmableGitHubServer.ts b/src/vs/platform/github/test/node/programmableGitHubServer.ts index 463a1e6f7bdc17..d22214a66697e6 100644 --- a/src/vs/platform/github/test/node/programmableGitHubServer.ts +++ b/src/vs/platform/github/test/node/programmableGitHubServer.ts @@ -427,6 +427,7 @@ export class ProgrammableGitHubServer extends Disposable { if (request.service !== 'graphql') { throw new Error(`Expected GraphQL request for ${describeStep(step)}, got ${request.pathname}`); } + assertGraphQlRootSelections(request.graphQl); if (step.operationName !== undefined && request.graphQl?.operationName !== step.operationName) { throw new Error(`Expected GraphQL operation ${step.operationName}, got ${request.graphQl?.operationName ?? ''}`); } @@ -558,6 +559,178 @@ function normalizeHeaders(headers: http.IncomingHttpHeaders): Readonly operation.name === request.operationName) + : operations.length === 1 ? operations[0] : undefined; + if (selected?.type !== 'mutation') { + return; + } + const invalid = selected.rootFields.filter(field => queryOnlyRootFields.includes(field)); + if (invalid.length > 0) { + throw new Error(`GraphQL mutation ${selected.name ?? ''} selected ${invalid.join(', ')} on the Mutation root, which GitHub only exposes on Query`); + } +} + +/** + * Parses the operations of a GraphQL document, capturing the fields selected at each operation root. + * Fields reached through a fragment spread are not expanded, so a spread at an operation root is opaque. + */ +function parseGraphQlOperations(document: string): readonly IGraphQlOperation[] { + const source = blankGraphQlLiterals(document); + const operations: IGraphQlOperation[] = []; + let index = 0; + while (index < source.length) { + if (!/[_A-Za-z{]/.test(source[index])) { + index++; + continue; + } + let type: IGraphQlOperation['type'] | undefined; + let name: string | undefined; + if (source[index] === '{') { + type = 'query'; // Shorthand for an anonymous query. + } else { + const keyword = readGraphQlName(source, index); + index = keyword.next; + if (keyword.value === 'query' || keyword.value === 'mutation' || keyword.value === 'subscription') { + type = keyword.value; + const operationName = readGraphQlName(source, skipGraphQlIgnored(source, index)); + if (operationName.value) { + name = operationName.value; + index = operationName.next; + } + } else if (keyword.value !== 'fragment') { + continue; + } + } + // Variable definitions and directives precede the selection set and may themselves contain + // braces, so the opening brace is only recognized outside of them. + const open = findGraphQlSelectionSet(source, index); + if (open < 0) { + break; + } + const close = matchGraphQlBrace(source, open); + if (type) { + operations.push({ type, name, rootFields: graphQlSelectedFieldNames(source.substring(open + 1, close)) }); + } + index = close + 1; + } + return operations; +} + +/** Collects the fields selected directly in a selection set body, resolving aliases to the selected field. */ +function graphQlSelectedFieldNames(selectionSet: string): readonly string[] { + const names: string[] = []; + let depth = 0; + for (let index = 0; index < selectionSet.length; index++) { + const char = selectionSet[index]; + if (char === '(') { + index = matchGraphQlParen(selectionSet, index); + continue; + } + if (char === '{') { depth++; continue; } + if (char === '}') { depth--; continue; } + if (char === '.') { + // Skip a fragment spread or the `on Type` header of an inline fragment. + while (index < selectionSet.length && selectionSet[index] === '.') { index++; } + const first = readGraphQlName(selectionSet, skipGraphQlIgnored(selectionSet, index)); + index = first.value === 'on' ? readGraphQlName(selectionSet, skipGraphQlIgnored(selectionSet, first.next)).next : first.next; + index--; + continue; + } + if (depth !== 0 || !/[_A-Za-z]/.test(char)) { continue; } + const field = readGraphQlName(selectionSet, index); + index = field.next - 1; + // `alias: field` selects `field`, so the alias is dropped and the field read on the next pass. + if (!/^\s*:/.test(selectionSet.substring(field.next))) { + names.push(field.value); + } + } + return names; +} + +/** Blanks comments and string literals so that brace matching cannot be confused by their contents. */ +function blankGraphQlLiterals(document: string): string { + const chars = [...document]; + for (let index = 0; index < chars.length; index++) { + if (chars[index] === '#') { + while (index < chars.length && chars[index] !== '\n') { chars[index++] = ' '; } + continue; + } + if (chars[index] !== '"') { continue; } + const terminator = document.startsWith('"""', index) ? '"""' : '"'; + let cursor = index + terminator.length; + while (cursor < chars.length) { + if (chars[cursor] === '\\') { cursor += 2; continue; } + if (document.startsWith(terminator, cursor)) { cursor += terminator.length; break; } + cursor++; + } + for (let position = index; position < Math.min(cursor, chars.length); position++) { chars[position] = ' '; } + index = cursor - 1; + } + return chars.join(''); +} + +function readGraphQlName(source: string, from: number): { readonly value: string; readonly next: number } { + let index = from; + while (index < source.length && /[_0-9A-Za-z]/.test(source[index])) { index++; } + return { value: source.substring(from, index), next: index }; +} + +function skipGraphQlIgnored(source: string, from: number): number { + let index = from; + while (index < source.length && /[\s,]/.test(source[index])) { index++; } + return index; +} + +function findGraphQlSelectionSet(source: string, from: number): number { + for (let index = from; index < source.length; index++) { + if (source[index] === '(') { + index = matchGraphQlParen(source, index); + continue; + } + if (source[index] === '{') { return index; } + } + return -1; +} + +function matchGraphQlParen(source: string, open: number): number { + let depth = 0; + for (let index = open; index < source.length; index++) { + if (source[index] === '(') { depth++; } + else if (source[index] === ')' && --depth === 0) { return index; } + } + return source.length; +} + +function matchGraphQlBrace(source: string, open: number): number { + let depth = 0; + for (let index = open; index < source.length; index++) { + if (source[index] === '{') { depth++; } + else if (source[index] === '}' && --depth === 0) { return index; } + } + return source.length; +} + function readGraphQlRequest(bodyJson: unknown): IRecordedGraphQLRequest | undefined { if (!bodyJson || typeof bodyJson !== 'object') { return undefined; diff --git a/src/vs/platform/github/test/node/pullRequestQueryService.test.ts b/src/vs/platform/github/test/node/pullRequestQueryService.test.ts index 0c42a82e79380b..3e95deb4d9aad0 100644 --- a/src/vs/platform/github/test/node/pullRequestQueryService.test.ts +++ b/src/vs/platform/github/test/node/pullRequestQueryService.test.ts @@ -447,6 +447,53 @@ suite('PullRequestQueryService', () => { }); test('derives merge permission from the repository permission of the viewer', async () => { + // `null` is what GitHub returns when the request is authenticated as a GitHub App. + const permissions = ['ADMIN', 'MAINTAIN', 'WRITE', 'TRIAGE', 'READ', null]; + const canMerge: Record = {}; + for (const viewerPermission of permissions) { + await withServer(async server => { + server.enqueue(gitHubGraphQLStep({ + queryIncludes: ['AgentHostPullRequestMergeability', 'viewerPermission'], + response: gitHubGraphQLResponse({ + repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: false, + viewerPermission, + mergeQueue: null, + pullRequest: { + headRefOid: 'head-1', + baseRefOid: 'base', + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + reviewDecision: 'APPROVED', + viewerCanUpdateBranch: false, + viewerCanEnableAutoMerge: false, + autoMergeRequest: null, + mergeQueueEntry: null, + }, + }, + }), + })); + const { query, ref, credential } = setup(server); + const result = await query.fetch('mergeability', ref, core('head-1'), { priority: 'interactive', mergeability: true }, credential, new AbortController().signal); + assert.ok(result.fragment === 'mergeability', `expected a mergeability fragment for ${viewerPermission ?? 'null'}, got ${result.fragment}`); + canMerge[viewerPermission ?? 'null'] = result.value.viewerCanMerge; + server.assertSatisfied(); + }); + } + + assert.deepStrictEqual(canMerge, { + ADMIN: true, + MAINTAIN: true, + WRITE: true, + TRIAGE: false, + READ: false, + null: false, + }); + }); + + test('normalizes the remaining mergeability fields', async () => { await withServer(async server => { server.enqueue(gitHubGraphQLStep({ queryIncludes: ['AgentHostPullRequestMergeability', 'viewerPermission'], diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index c790e00aaac7ec..8278bc3ba0bc6a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2290,23 +2290,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const chatURI = initial.chat.toString(); if (initial.kind === SessionInputRequestKind.ChatInput) { - // A user-facing elicitation with no tool call. If no turn - // observer renders it within the grace window, nobody could - // answer it, so cancel it (the agent asked; nobody was there). - const inputKey = this._inputRequestKey(chatURI, initial.request.id); - let cancelled = false; - itemStore.add(disposableTimeout(() => { - if (cancelled || this._renderedRequests.get().has(inputKey)) { - return; - } - cancelled = true; - this._logService.warn(`[AgentHost] Cancelling chat input request ${initial.request.id}: no session claimed it within ${UNOBSERVED_CLIENT_TOOL_GRACE_MS}ms`); - this._dispatchAction(backendSession, { - type: ActionType.ChatInputCompleted, - requestId: initial.request.id, - response: ChatInputResponseKind.Cancel, - }, chatURI); - }, UNOBSERVED_CLIENT_TOOL_GRACE_MS)); + return; + } + if (initial.kind !== SessionInputRequestKind.ToolClientExecution || initial.clientId !== this._config.connection.clientId) { return; } @@ -2314,10 +2300,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const requestLifecycle = itemStore.add(new MutableDisposable()); itemStore.add(this._retainToolCall(key)); - if (initial.kind === SessionInputRequestKind.ToolClientExecution) { - if (initial.clientId !== this._config.connection.clientId) { - return; // A different client owns this call. - } + { let execution = clientToolExecutions.get(key); if (!execution) { execution = { source: new CancellationTokenSource(), retain: this._retainToolCall(key), activeAttempts: 0 }; @@ -2418,43 +2401,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }, UNOBSERVED_CLIENT_TOOL_GRACE_MS); } })); - } else if (initial.kind === SessionInputRequestKind.ToolAuthentication) { - // An MCP tool call blocked on authentication. The token is - // pushed out-of-band via the `authenticate` command, so this - // watcher does not resolve it — but if no observer renders the - // call within the grace window nobody can drive that flow, so - // cancel the call rather than leave the agent blocked forever. - itemStore.add(disposableTimeout(() => { - if (!this._renderedRequests.get().has(key)) { - this._logService.warn(`[AgentHost] Cancelling MCP authentication for ${initial.toolCall.toolName} (callId=${initial.toolCall.toolCallId}): no session claimed it within ${UNOBSERVED_CLIENT_TOOL_GRACE_MS}ms`); - this._resolveToolCall(chatURI, initial.turnId, initial.toolCall.toolCallId, { - type: ActionType.ChatToolCallComplete, - turnId: initial.turnId, - toolCallId: initial.toolCall.toolCallId, - result: { - success: false, - pastTenseMessage: localize('agentHost.mcpToolAuthentication.cancelled', "Cancelled tool call"), - error: { message: localize('agentHost.mcpToolAuthentication.cancelledError', "MCP authentication was cancelled"), code: 'cancelled' }, - }, - }); - } - }, UNOBSERVED_CLIENT_TOOL_GRACE_MS)); - } else { - // A confirmation that no sub/agent observer claims within the - // grace window is auto-denied so the agent is not left blocked - // on a surface that never renders. - itemStore.add(disposableTimeout(() => { - if (!this._renderedRequests.get().has(key)) { - this._logService.warn(`[AgentHost] Denying confirmation for ${initial.toolCall.toolName} (callId=${initial.toolCall.toolCallId}): no session claimed it within ${UNOBSERVED_CLIENT_TOOL_GRACE_MS}ms`); - this._resolveToolCall(chatURI, initial.turnId, initial.toolCall.toolCallId, { - type: ActionType.ChatToolCallConfirmed, - turnId: initial.turnId, - toolCallId: initial.toolCall.toolCallId, - approved: false, - reason: ToolCallCancellationReason.Denied, - }); - } - }, UNOBSERVED_CLIENT_TOOL_GRACE_MS)); } })); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 709f4feabdde4c..03a352a3cd0fa9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -101,6 +101,7 @@ export class AgentHostSessionListStore extends Disposable { // doesn't know which workspace this VS Code window has open. this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(() => { this._cacheValid = false; + this._filterEntriesToWorkspace(); void this.refresh(CancellationToken.None); })); } @@ -391,6 +392,22 @@ export class AgentHostSessionListStore extends Disposable { return this._matchesAnyFolder(workingDirectories, folders); } + private _filterEntriesToWorkspace(): void { + // The retained projection can only narrow; a successful refresh supplies newly eligible sessions. + const removed: IAgentHostSessionListRemoval[] = []; + for (const [key, entry] of this._entries) { + if (!this._isSessionInWorkspace(entry)) { + this._entries.delete(key); + this._pendingNewSessions.delete(key); + removed.push(this._toRemoval(entry)); + } + } + if (removed.length > 0) { + this._mutationGeneration++; + this._onDidChangeSessions.fire({ removed }); + } + } + private _matchesAnyFolder(workingDirectories: readonly URI[], folders: readonly IWorkspaceFolder[]): boolean { return workingDirectories.some(directory => folders.some(folder => extUriBiasedIgnorePathCase.isEqualOrParent(directory, folder.uri)) diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 1248514978aff3..52732493b884e4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -3208,6 +3208,40 @@ suite('AgentHostChatContribution', () => { }); }); + test('workspace folder change re-filters the retained snapshot when refresh fails', async () => { + const { instantiationService, agentHostService } = createTestServices(disposables); + const workspaceFolder = URI.file('/workspace/root'); + let folders: { uri: URI; name: string; index: number; toResource: () => URI }[] = []; + const onDidChangeWorkspaceFolders = disposables.add(new Emitter<{ readonly added: never[]; readonly removed: never[]; readonly changed: never[] }>()); + instantiationService.stub(IWorkspaceContextService, { + getWorkbenchState: () => folders.length === 0 ? WorkbenchState.EMPTY : WorkbenchState.FOLDER, + getWorkspace: () => ({ id: '', folders: [...folders] }), + getWorkspaceFolder: () => null, + onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'in-ws'), startTime: 1000, modifiedTime: 2000, summary: 'In workspace', workingDirectories: [URI.file('/workspace/root/sub')] }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'out-ws'), startTime: 1000, modifiedTime: 2000, summary: 'Outside workspace', workingDirectories: [URI.file('/other/place')] }); + const listController = createSessionListController(disposables, instantiationService, agentHostService); + await listController.refresh(CancellationToken.None); + + folders = [{ uri: workspaceFolder, name: 'root', index: 0, toResource: () => workspaceFolder }]; + let listCalls = 0; + agentHostService.listSessions = async () => { + listCalls++; + throw new Error('catalog unavailable'); + }; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); + await timeout(0); + + assert.deepStrictEqual({ + listCalls, + labels: listController.items.map(item => item.label), + }, { + listCalls: 1, + labels: ['In workspace'], + }); + }); + test('multi-root workspace filtering uses workspace-file metadata', async () => { const { instantiationService, agentHostService } = createTestServices(disposables); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index fdbc7495999b4f..428a3604314d3c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -2016,15 +2016,13 @@ suite('AgentHostClientTools', () => { 'the initial snapshot invocation should be completed, not orphaned'); }); - test('auto-denies an unclaimed session confirmation after the grace period', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('does not auto-deny an unclaimed session confirmation', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { handler, connection } = createHandlerWithMocks(disposables, []); const sessionResource = URI.parse('agent-host-copilot:/session-1'); const backendSession = AgentSession.uri('copilot', 'session-1').toString(); const subagentChat = buildSubagentChatUri(backendSession, 'task-call-1'); await handler.provideChatSessionContent(sessionResource, CancellationToken.None); - // No turn observer ever renders this confirmation, so nothing can - // answer it; the watcher denies it once the grace window expires. connection.applySessionAction(URI.parse(backendSession), { type: ActionType.SessionInputNeededSet, request: { @@ -2043,32 +2041,16 @@ suite('AgentHostClientTools', () => { }); await timeout(UNOBSERVED_CLIENT_TOOL_GRACE_MS + 1); - assert.deepStrictEqual( - connection.dispatchedActions - .filter(entry => entry.action.type === ActionType.ChatToolCallConfirmed && entry.action.toolCallId === 'powershell-call-1') - .map(entry => ({ channel: entry.channel, action: entry.action })), - [{ - channel: subagentChat, - action: { - type: ActionType.ChatToolCallConfirmed, - turnId: 'subagent-turn-1', - toolCallId: 'powershell-call-1', - approved: false, - reason: ToolCallCancellationReason.Denied, - }, - }], - ); + assert.strictEqual(connection.dispatchedActions.some(entry => entry.action.type === ActionType.ChatToolCallConfirmed && entry.action.toolCallId === 'powershell-call-1'), false); })); - test('cancels an unclaimed chat input request after the grace period', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('does not cancel an unclaimed chat input request', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { handler, connection } = createHandlerWithMocks(disposables, []); const sessionResource = URI.parse('agent-host-copilot:/session-1'); const backendSession = AgentSession.uri('copilot', 'session-1').toString(); const subagentChat = buildSubagentChatUri(backendSession, 'task-call-1'); await handler.provideChatSessionContent(sessionResource, CancellationToken.None); - // No turn observer renders this elicitation, so nothing can answer - // it; the watcher cancels it once the grace window expires. connection.applySessionAction(URI.parse(backendSession), { type: ActionType.SessionInputNeededSet, request: { @@ -2078,21 +2060,9 @@ suite('AgentHostClientTools', () => { request: { id: 'elicit-1', message: 'Pick one', questions: [] }, }, }); - await timeout(5001); + await timeout(UNOBSERVED_CLIENT_TOOL_GRACE_MS + 1); - assert.deepStrictEqual( - connection.dispatchedActions - .filter(entry => entry.action.type === ActionType.ChatInputCompleted) - .map(entry => ({ channel: entry.channel, action: entry.action })), - [{ - channel: subagentChat, - action: { - type: ActionType.ChatInputCompleted, - requestId: 'elicit-1', - response: ChatInputResponseKind.Cancel, - }, - }], - ); + assert.strictEqual(connection.dispatchedActions.some(entry => entry.action.type === ActionType.ChatInputCompleted), false); })); test('does not cancel a chat input request a turn observer is rendering', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -2139,16 +2109,13 @@ suite('AgentHostClientTools', () => { await timeout(0); })); - test('cancels an unclaimed MCP authentication tool call after the grace period', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('does not cancel an unclaimed MCP authentication tool call', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { handler, connection } = createHandlerWithMocks(disposables, []); const sessionResource = URI.parse('agent-host-copilot:/session-1'); const backendSession = AgentSession.uri('copilot', 'session-1').toString(); const subagentChat = buildSubagentChatUri(backendSession, 'task-call-1'); await handler.provideChatSessionContent(sessionResource, CancellationToken.None); - // No turn observer renders this auth-required MCP tool call, so - // nobody can drive authentication; the watcher cancels it once the - // grace window expires. connection.applySessionAction(URI.parse(backendSession), { type: ActionType.SessionInputNeededSet, request: { @@ -2168,26 +2135,9 @@ suite('AgentHostClientTools', () => { }, }, }); - await timeout(5001); + await timeout(UNOBSERVED_CLIENT_TOOL_GRACE_MS + 1); - assert.deepStrictEqual( - connection.dispatchedActions - .filter(entry => entry.action.type === ActionType.ChatToolCallComplete && entry.action.toolCallId === 'mcp-call-1') - .map(entry => ({ channel: entry.channel, action: entry.action })), - [{ - channel: subagentChat, - action: { - type: ActionType.ChatToolCallComplete, - turnId: 'subagent-turn-1', - toolCallId: 'mcp-call-1', - result: { - success: false, - pastTenseMessage: 'Cancelled tool call', - error: { message: 'MCP authentication was cancelled', code: 'cancelled' }, - }, - }, - }], - ); + assert.strictEqual(connection.dispatchedActions.some(entry => entry.action.type === ActionType.ChatToolCallComplete && entry.action.toolCallId === 'mcp-call-1'), false); })); test('does not cancel an MCP authentication tool call a turn observer is rendering', () => runWithFakedTimers({ useFakeTimers: true }, async () => {