Files
vscode/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts
T
53ebd210b7 Adopt AHP 0.7.0 workingDirectories + primaryWorkingDirectory in agent host (#326847)
* Adopt AHP 0.6.0 workingDirectories property in agent host

Sync the generated agent host protocol copy to spec version 0.6.0
(agent-host-protocol @ 11f1a65e), which renames the singular
`workingDirectory` on session/chat state to a `workingDirectories`
array in preparation for multiroot session support.

This change is a pure, behaviour-preserving property adoption across all
consumers: sessions continue to use a single working directory. Reads of
a single directory now use `workingDirectories?.[0]`, field-copy sites
pass the array through unchanged, and writes from a single URI produce a
one-element array. No multiroot client support is added here (no
capability advertising, state actions, multi-folder workspaces, or UI);
those land in a follow-up milestone.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback

- Reject the not-yet-supported multiroot working-directory client actions
  (session|chat/workingDirectorySet|Removed) in the handwritten dispatch path
  so a client cannot mutate the synchronized working-directory set without the
  agent actually reconfiguring its directory access. The protocol declarations
  remain; only the operational dispatch is deferred until multiroot lands.
- Compare workingDirectories by array identity (not just the primary entry) in
  the state manager summary-equality check, matching the immutable reducers and
  SessionSummaryNotifier so secondary-directory changes still dirty the summary.
- Update ISessionWithDefaultChat / mergeSessionWithDefaultChat API docs to
  describe a chat's workingDirectories subset overriding or inheriting the
  session's full set, fixing links to the removed singular members.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix agentHost session-filter test for workingDirectories migration

The `sessionAdded notification filters out sessions outside the workspace`
test constructed session summaries with the removed singular `workingDirectory`
field, so the production filter (which now reads `workingDirectories[0]`) saw no
directory and dropped the in-workspace session. Update the two directly-built
summaries to the `workingDirectories` array form.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate test createSession wire calls to workingDirectories

The createSession dispatch handler now reads the working directory from the
renamed `CreateSessionParams.workingDirectories` array (AHP 0.6.0), but several
node integration / e2e test call sites still sent the removed singular
`workingDirectory` field. Since the field is silently ignored, sessions were
created without a working directory and fell back to the default chats folder,
failing the Agent Host E2E workspace/fileOperations/hostFeatures suites across
all providers (wrong cwd cascades into file completions, renames, worktree
resolution, and cd-prefix stripping).

Send `workingDirectories: [dir]` from the shared `createProviderSession` helper
and the remaining direct wire call sites so the requested directory reaches the
session state again. The real VS Code client already sent the array form.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Adopt AHP 0.7.0 primaryWorkingDirectory + requiresPrimary

Re-sync the generated protocol copy to the multiroot spec at 148c716b (spec
version 0.7.0) and adopt the two follow-up changes:

- Capability flag renamed `MultipleWorkingDirectoriesCapability.immutablePrimary`
  -> `requiresPrimary`, with the new "agent needs one directory designated as
  its primary root" semantics. No consumers referenced the old name.
- New optional `primaryWorkingDirectory` field at both the session level
  (CreateSessionParams / SessionMetadata -> SessionState + SessionSummary) and
  the chat level (CreateChatParams / ChatState + ChatSummary). Mirror it through
  the state<->summary projection layer exactly like `workingDirectories`:
  createSessionState / createChatState / chatSummaryFromState /
  mergeSessionWithDefaultChat, plus the state manager's summary projection,
  field-equality check, SessionSummaryNotifier diff, and markSessionPersisted
  propagation. The generated SessionChatUpdated partial-summary merge is
  field-agnostic, so it carries the new field automatically.

Purely additive optional fields; no client multiroot behavior is added
(consumers still read `workingDirectories[0]` as the single effective root).

Also preserve the VS Code-local `CompletionItem.label` field (added in #326807,
ahead of the spec) which the verbatim re-sync would otherwise drop.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reject unsupported working-directory actions via reconciliation path

Address PR review feedback on the multiroot working-directory action gate:

- Instead of silently dropping the four not-yet-supported
  session|chat/workingDirectorySet|Removed client actions (which never echoed
  the origin, leaving the client's optimistic write-ahead action pending until
  reconnect), emit a rejection envelope through the normal reconciliation path.
  Added AgentHostStateManager.rejectClientAction, which emits an ActionEnvelope
  carrying the original ActionOrigin and a rejectionReason without running the
  reducer (no synchronized state change), so the originating client rolls back
  its optimistic action.
- Guard the write-ahead client reconcile (SessionStateSubscription /
  ChatStateSubscription) so a rejected envelope is never applied to confirmed
  state in any branch — this also prevents a broadcast rejection from leaking
  the rejected action into a non-origin client's state.
- Add a table-driven test covering all four action types asserting no dispatch
  and exactly one rejection envelope preserving the original origin.
- Simplify the create-session working-directory read to
  `URI.parse(params.workingDirectories[0])` now the branch proves index 0 exists.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Refine primaryWorkingDirectory to per-chat read-only (AHP ea279d99)

Re-sync the protocol copy to spec ea279d99 (0.7.0) and adopt the refined
primaryWorkingDirectory design:

- Session has NO primary: `primaryWorkingDirectory` is removed from
  SessionState / SessionSummary. The session is just the equal-peer
  `workingDirectories` set. Dropped all session-level primary handling
  (createSessionState, the SessionSummaryNotifier diff, _toSummary,
  _summaryFieldsEqual, and markSessionPersisted propagation).
- Primary is per-chat, read-only, fixed at chat creation: kept the ChatState
  <-> ChatSummary mirroring (createChatState / chatSummaryFromState) and carry
  the chat's own primary through the session+default-chat composite
  (ISessionWithDefaultChat gains its own primaryWorkingDirectory; the merge no
  longer falls back to a session primary). It is not sent via `session/chatUpdated`
  (the state manager only ever puts status/activity/title in those changes), so
  it never mutates post-creation.
- Inputs `CreateSessionParams.primaryWorkingDirectory` (seeds the default chat's
  primary) and `CreateChatParams.primaryWorkingDirectory` are synced; capability
  `requiresPrimary` unchanged.

Also re-preserve the VS Code-local `CompletionItem.label` field (#326807, ahead
of the spec) that the verbatim re-sync drops.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 15:19:47 +00:00

176 lines
6.3 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../base/common/cancellation.js';
import { isCancellationError } from '../../../base/common/errors.js';
import { compareItemsByFuzzyScore, FuzzyScorerCache, IItemAccessor, prepareQuery, scoreItemFuzzy } from '../../../base/common/fuzzyScorer.js';
import { Schemas } from '../../../base/common/network.js';
import { basename, relativePath } from '../../../base/common/resources.js';
import { URI } from '../../../base/common/uri.js';
import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common/state/protocol/commands.js';
import { MessageAttachmentKind } from '../common/state/protocol/state.js';
import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js';
import { AgentHostStateManager } from './agentHostStateManager.js';
import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js';
/** Maximum number of completion items returned per call. */
const MAX_RESULTS = 50;
/**
* Result of {@link extractAtToken}.
*/
interface IAtToken {
readonly token: string;
readonly triggerChar: string;
readonly rangeStart: number;
readonly rangeEnd: number;
}
/**
* Walk back from `offset` to find the most recent `@` that is preceded by
* whitespace (or start-of-string) and not interrupted by whitespace. Returns
* the substring after `@` together with the range to replace, or `undefined`
* if no `@`-token is being typed at `offset`.
*
* Exported for unit testing.
*/
export function extractAtToken(text: string, offset: number): IAtToken | undefined {
if (offset < 0 || offset > text.length) {
return undefined;
}
for (let i = offset - 1; i >= 0; i--) {
const ch = text.charCodeAt(i);
// whitespace terminates the search
if (ch === 0x20 /* space */ || ch === 0x09 /* tab */ || ch === 0x0a /* \n */ || ch === 0x0d /* \r */) {
return undefined;
}
if (text[i] === CompletionTriggerCharacter.File || text[i] === CompletionTriggerCharacter.Hash) {
// The trigger character must be at start-of-input or preceded by whitespace.
if (i > 0) {
const prev = text.charCodeAt(i - 1);
const prevIsWs = prev === 0x20 || prev === 0x09 || prev === 0x0a || prev === 0x0d;
if (!prevIsWs) {
return undefined;
}
}
return { token: text.slice(i + 1, offset), triggerChar: text[i], rangeStart: i, rangeEnd: offset };
}
}
return undefined;
}
/**
* Item-accessor that exposes a {@link URI} as basename / parent-directory /
* relative path for the {@link scoreItemFuzzy} family.
*/
class UriAccessor implements IItemAccessor<URI> {
constructor(private readonly _workingDirectory: URI) { }
getItemLabel(item: URI): string {
return basename(item);
}
getItemDescription(item: URI): string | undefined {
const rel = relativePath(this._workingDirectory, item);
if (!rel) {
return undefined;
}
const idx = rel.lastIndexOf('/');
return idx > 0 ? rel.slice(0, idx) : undefined;
}
getItemPath(item: URI): string | undefined {
const rel = relativePath(this._workingDirectory, item);
return rel ?? item.fsPath;
}
}
/**
* Generic completion provider that contributes workspace file references
* for a {@link CompletionItemKind.UserMessage} input — typically used for
* `@`-mentions in the user message composer.
*
* When the user has typed an `@`-prefixed token at the cursor position,
* this provider enumerates files under the session's working directory
* (via {@link AgentHostWorkspaceFiles}, which uses ripgrep and respects
* `.gitignore`), ranks them with the same fuzzy scorer used by the
* VS Code Quick Open file picker, and returns up to {@link MAX_RESULTS}
* matches.
*/
export class AgentHostFileCompletionProvider implements IAgentHostCompletionItemProvider {
readonly kinds: ReadonlySet<CompletionItemKind> = new Set([CompletionItemKind.UserMessage]);
readonly triggerCharacters: readonly string[] = [CompletionTriggerCharacter.File, CompletionTriggerCharacter.Hash];
constructor(
private readonly _stateManager: AgentHostStateManager,
private readonly _workspaceFiles: AgentHostWorkspaceFiles,
) { }
async provideCompletionItems(params: CompletionsParams, token: CancellationToken): Promise<readonly CompletionItem[]> {
const workingDirectoryStr = this._stateManager.getSessionState(params.channel)?.workingDirectories?.[0];
if (!workingDirectoryStr) {
return [];
}
const workingDirectory = URI.parse(workingDirectoryStr);
if (workingDirectory.scheme !== Schemas.file) {
return [];
}
const at = extractAtToken(params.text, params.offset);
if (!at) {
return [];
}
let files: readonly URI[];
try {
files = await this._workspaceFiles.getFiles(workingDirectory, token);
} catch (err) {
// Cancellation is expected on every keystroke as Monaco cancels
// the previous request. Don't let it surface as a provider failure
// in {@link AgentHostCompletions} — it would log noisy errors on
// normal typing.
if (isCancellationError(err)) {
return [];
}
throw err;
}
if (token.isCancellationRequested || files.length === 0) {
return [];
}
const accessor = new UriAccessor(workingDirectory);
const query = prepareQuery(at.token);
const cache: FuzzyScorerCache = Object.create(null);
let candidates: URI[];
if (!query.normalized) {
// Empty token: return the first MAX_RESULTS files in enumeration order.
candidates = files.slice(0, MAX_RESULTS);
} else {
// Filter out non-matches first to avoid sorting tens of thousands of zeros.
const matching = files.filter(f => scoreItemFuzzy(f, query, true, accessor, cache).score > 0);
matching.sort((a, b) => compareItemsByFuzzyScore(a, b, query, true, accessor, cache));
candidates = matching.slice(0, MAX_RESULTS);
}
return candidates.map((uri): CompletionItem => {
const name = basename(uri);
return {
insertText: at.triggerChar + name,
rangeStart: at.rangeStart,
rangeEnd: at.rangeEnd,
attachment: {
type: MessageAttachmentKind.Resource,
uri: uri.toString(),
label: name,
displayKind: 'document',
},
};
});
}
}