From 05bf4f5ce25799e2d0daf32bf90cb885ca75a0b9 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 19 Aug 2026 21:40:55 -0700 Subject: [PATCH 1/6] chat: scope agent host request timeout (#331703) Keep the five-second timeout for client tool calls that this client owns. Do not cancel or deny user input, authentication, or confirmation requests that another client can claim. - Return before creating client-tool lifecycle state for requests from other clients. - Update request-timeout tests to verify unassigned requests remain pending. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionHandler.ts | 62 ++--------------- .../agentHostClientTools.test.ts | 66 +++---------------- 2 files changed, 12 insertions(+), 116 deletions(-) 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/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 () => { From 8232b19a9e898b5f90b4b6ea6fccc6aa359a38e2 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 20 Aug 2026 10:07:31 +0200 Subject: [PATCH 2/6] agentHost: Avoid warning for missing remote HEAD (#331611) Probe the optional origin/HEAD symbolic ref in quiet mode so repositories without it do not produce recurring agent host warnings. Add an integration test covering the expected missing-ref case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostGitService.ts | 2 +- .../agentHostGitService.integrationTest.ts | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index cfaeb27d56b67f..31b502b3995a58 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 }; diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 00adf4eb4fda8c..58d13f186737d0 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' }); From 97ed7b57c6d9becb4fe386c59157eda016050d6a Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:33:03 +0200 Subject: [PATCH 3/6] agentHost: cover GraphQL mutation roots and null merge permission (#331719) * agentHost: cover GraphQL mutation roots and null merge permission The mutation fix had no regression coverage: the existing tests only match operation names and variables, so reintroducing the invalid root-level rateLimit selection still passed. That is why the bug survived. ProgrammableGitHubServer now rejects any mutation that selects a Query-root-only field at the mutation root. Placing the check in the fake server rather than in individual assertions means every current and future mutation test enforces it automatically. Verified by reintroducing the bug, which now fails two tests with a direct diagnostic. The permission test also only covered a READ viewer. It now snapshots the whole RepositoryPermission range including null, the GitHub App case that deliberately disables Agent Merge, so the fail-closed path cannot regress on its own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: resolve the selected operation in the GraphQL mutation guard The mutation-root guard only recognized documents whose first token was mutation, so a leading fragment definition or a multi-operation document bypassed it entirely. It also scanned every top-level selection set, so a sibling query selecting rateLimit failed an otherwise valid mutation. The guard now parses the document into operations, resolves the one selected by operationName (or the sole operation when the document is unambiguous), and inspects only that operation's root selection set. Parsing blanks comments and string literals first so braces inside them cannot skew matching, and skips variable definitions and inline fragment headers. ProgrammableGitHubServer tests now cover both regressions plus aliases, nested selections and string arguments. Verified they fail against the previous implementation in both directions: the fragment-prefixed mutation goes undetected, and the valid mutation beside a rateLimit query is wrongly rejected. The permission test folded its discriminant check into the recorded value, so a non-mergeability result collapsed to false and silently matched the TRIAGE, READ and null cases. It now asserts the fragment before snapshotting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/programmableGitHubServer.test.ts | 55 ++++++ .../test/node/programmableGitHubServer.ts | 173 ++++++++++++++++++ .../test/node/pullRequestQueryService.test.ts | 47 +++++ 3 files changed, 275 insertions(+) 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'], From 3aee8ae56d952d7148baf4e16a5e9893c133a482 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:45:15 +0200 Subject: [PATCH 4/6] Fix agent-host branch changeset failure due to Git state issues (#331620) Agent Host changes for benibenj/agents/log-analysis-error-fix-prioritization-60ed07f4 --- .../agentHost/node/agentHostGitService.ts | 9 +++++++-- .../node/agentHostGitService.integrationTest.ts | 17 +++++++++++++++++ .../test/node/agentHostGitService.test.ts | 4 ++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 31b502b3995a58..891246628310fb 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -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/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 58d13f186737d0..bdace033db24e5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -331,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', ]); }); From 8c79b49e5095788a57fe74ccb8eb36316757a8e6 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 11:55:44 +0200 Subject: [PATCH 5/6] agentHost: Address session catalog review feedback (#331758) agentHost: address session catalog review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/AGENTS.md | 2 +- .../agentHost/test/node/agentService.test.ts | 41 +++++++++++-------- .../agentHost/agentHostSessionListStore.ts | 17 ++++++++ .../agentHostChatContribution.test.ts | 34 +++++++++++++++ 4 files changed, 75 insertions(+), 19 deletions(-) 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/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index e5b6a84a3d8a23..fa07c05841424e 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'; @@ -3603,29 +3603,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/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); From be1bc529933d10195c42c48bf17fe0db686112d6 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:00:35 +0200 Subject: [PATCH 6/6] Improve performance of session listing in agent service (#331760) Agent Host changes for benibenj/agents/vscode-insiders-agent-logs-review --- .../platform/agentHost/node/agentService.ts | 41 ++++++++++++++++--- .../agentHost/test/node/agentService.test.ts | 36 ++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) 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/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index fa07c05841424e..9f17a9b43c8a94 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -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();