From 37ce7fb8eaee52ced7acce7e6108d99e9879b793 Mon Sep 17 00:00:00 2001 From: bhavyaus Date: Fri, 18 Sep 2026 11:11:07 -0700 Subject: [PATCH] Prototype full-text and semantic search for agent sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6771605f-eb93-4b1e-a38f-98af4592e497 --- src/vs/platform/agentHost/AGENTS.md | 14 + src/vs/platform/agentHost/SESSION_SEARCH.md | 328 ++++++ .../browser/agentHostProtocolClient.ts | 28 +- src/vs/platform/agentHost/common/agent.ts | 4 + .../common/agentHostExtensionProtocol.ts | 24 +- .../common/agentHostSessionSearch.ts | 42 + .../platform/agentHost/common/agentService.ts | 15 + .../common/meta/agentHostSessionSearchMeta.ts | 18 + .../agentHost/common/sessionSemanticSearch.ts | 95 ++ .../electron-browser/localAgentHostService.ts | 18 + .../node/agentHostManagementService.ts | 25 + .../agentHost/node/agentHostServices.ts | 5 + .../agentHost/node/agentHostSessionSearch.ts | 74 ++ .../node/agentHostSessionSearchIndex.ts | 108 ++ .../platform/agentHost/node/agentService.ts | 59 +- .../agentHost/node/copilot/copilotAgent.ts | 28 + .../node/copilot/copilotSessionSearch.ts | 115 ++ .../node/copilot/mapSessionEvents.ts | 4 +- .../agentHost/node/protocolServerHandler.ts | 53 +- .../agentHost/node/sessionSearchDatabase.ts | 587 +++++++++ .../agentHostProtocolClient.test.ts | 36 + .../test/node/agentHostServices.test.ts | 9 + .../node/agentHostSessionSearchIndex.test.ts | 142 +++ .../agentHost/test/node/agentService.test.ts | 217 ++++ .../agentHost/test/node/copilotAgent.test.ts | 76 +- .../test/node/copilotSessionSearch.test.ts | 435 +++++++ .../test/node/protocolServerHandler.test.ts | 136 ++- .../test/node/sessionSearchDatabase.test.ts | 1044 +++++++++++++++++ .../browser/sessionsChatAccessibilityHelp.ts | 3 + .../chat/browser/sessionsOpenerParticipant.ts | 23 +- .../sessionsChatAccessibilityHelp.test.ts | 32 + .../browser/sessionsOpenerParticipant.test.ts | 41 +- .../browser/views/sessionsViewActions.ts | 14 +- .../test/browser/sessionsActions.test.ts | 24 +- .../api/browser/mainThreadEmbeddings.ts | 76 +- .../browser/actions/chatAccessibilityHelp.ts | 3 + .../agentHost/agentHost.contribution.ts | 1 + .../agentHost/agentHostSessionListStore.ts | 75 +- .../agentHost/agentHostSessionSearch.ts | 611 ++++++++++ .../agentHost/semanticSessionSearch.ts | 216 ++++ .../agentSessions/agentSessionsActions.ts | 16 +- .../chatAccessibilityHelp.test.ts | 34 + .../agentHostSessionSearch.test.ts | 802 +++++++++++++ .../agentSessionsActions.test.ts | 23 +- .../semanticSessionSearch.test.ts | 281 +++++ .../editorRemoteAgentHostServiceClient.ts | 18 + .../embeddings/common/embeddingsService.ts | 64 + .../test/common/embeddingsService.test.ts | 60 + 48 files changed, 6002 insertions(+), 154 deletions(-) create mode 100644 src/vs/platform/agentHost/SESSION_SEARCH.md create mode 100644 src/vs/platform/agentHost/common/agentHostSessionSearch.ts create mode 100644 src/vs/platform/agentHost/common/meta/agentHostSessionSearchMeta.ts create mode 100644 src/vs/platform/agentHost/common/sessionSemanticSearch.ts create mode 100644 src/vs/platform/agentHost/node/agentHostSessionSearch.ts create mode 100644 src/vs/platform/agentHost/node/agentHostSessionSearchIndex.ts create mode 100644 src/vs/platform/agentHost/node/copilot/copilotSessionSearch.ts create mode 100644 src/vs/platform/agentHost/node/sessionSearchDatabase.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostSessionSearchIndex.test.ts create mode 100644 src/vs/platform/agentHost/test/node/copilotSessionSearch.test.ts create mode 100644 src/vs/platform/agentHost/test/node/sessionSearchDatabase.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionSearch.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/semanticSessionSearch.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionSearch.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/semanticSessionSearch.test.ts create mode 100644 src/vs/workbench/services/embeddings/common/embeddingsService.ts create mode 100644 src/vs/workbench/services/embeddings/test/common/embeddingsService.test.ts diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 515afbe1035874..704fa2bf41f144 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -415,6 +415,20 @@ Copilot also has no AH-session container: No `CopilotSessionEntry`, `AgentSessionEntry`, default-chat URI helper, or sibling cascade remains. Send/history/model/agent/abort/tool/config/dispose/release operations resolve one leaf. Active-client state remains keyed by the owning SDK session where it is genuinely shared, while each live leaf owns its own SDK and MCP lifecycle. Capabilities remain `multipleChats: { fork: true }`. +#### Persisted conversation search (preview) + +The optional `IAgent.searchChatHistory` seam receives one exact chat, its host-owned persistence context, and opaque provider data. It must not create, resume, or materialize the conversation. `AgentService.searchSessionHistory` enumerates the orchestrator's default and peer-chat catalog and delegates through that seam; it never interprets SDK backing identifiers. Results carry chat and turn locators, an author role, and a bounded snippet. Loaded host turn IDs are reconciled with persisted event IDs before results leave the host. + +Remote clients discover `vscode/searchSessionHistory` through the `vscode.searchSessionHistory` initialize capability. Local utility-process connections keep protocol extensions disabled and use the management channel's `supportsSessionHistorySearch` and `searchSessionHistory` methods instead. Callers consult the connection's transport-aware support query rather than assuming protocol metadata covers the local management channel. The preview is exposed by **Chat: Search Agent Session Content (Preview)**, independently of the existing title-only Find widget. Unsupported hosts and failed reads must remain distinguishable from an empty result. + +Copilot reads the SDK's paginated persisted event journal and supplies normalized documents to `IAgentHostSessionSearchIndex`. That service owns one rebuildable `agent-host-search.db` beside the host's profile storage file. Harness/chat identities, turns, and documents are normalized through integer keys; the FTS rowid is the document ID. Queries constrain the owning chat before limiting results. This provider-neutral storage does not itself enable Claude or Codex search. + +The journal remains authoritative; search never modifies SDK or session-history databases. Index freshness is checked per chat against the persisted journal and backing identity. Rebuild and deletion synchronize metadata and FTS rows atomically. Existing session-data deletion notifications remove the corresponding cache rows. Recognized legacy `session-search.db` sidecars are removed lazily only after successful shared-cache indexing; unrelated files are left alone. Keyword search runs locally on the owning host, including for remote connections, without sending content to an embeddings endpoint. Tool-origin subagent chats, reasoning, attachments, and arbitrary tool output remain outside the preview's search scope. + +Semantic search adds an opt-in client-orchestrated path. The workbench obtains embeddings through registered Copilot extension providers; the host does not import extension services, own endpoint credentials, or make embedding network calls. The `sessionSemanticSearch` operation exposes bounded pending chunks, accepts vectors for unchanged chunk identities, and ranks stored embeddings within the host-authorized chat catalog. Remote transport advertises this separately from lexical search; local clients use management IPC. Vector and chunk rows belong to the same rebuildable search cache, and are invalidated with their source documents. + +The client must obtain explicit permission before sending saved content or queries to an embedding provider. Consent is scoped to the open search picker and its workspace scope, not persisted as general permission for future searches. Keyword search remains available without embeddings, and semantic failures or partial indexing are surfaced rather than appearing as complete empty results. + ### Codex (`node/codex/codexAgent.ts`) Codex supports multiple chats per session. Each conversation — the session's default chat and every additional chat — is a distinct top-level Codex thread, explicitly bound to the concrete chat URI AH supplies: diff --git a/src/vs/platform/agentHost/SESSION_SEARCH.md b/src/vs/platform/agentHost/SESSION_SEARCH.md new file mode 100644 index 00000000000000..ac008a8dcae983 --- /dev/null +++ b/src/vs/platform/agentHost/SESSION_SEARCH.md @@ -0,0 +1,328 @@ + + +# From title filtering to conversation-content search + +**Status:** Prototype in this checkout, not a description of shipped VS Code behavior. +**Scope:** Copilot sessions managed by Agent Host, in the editor window and Agents window. +**Updated:** September 15, 2026. + +## Summary + +Before this change, the search button in the sessions list filters session **titles and other list labels**. It does not search the conversations behind those titles. A session can contain the exact answer a user needs and still be invisible to that search if its title uses different words. + +The prototype makes the existing search button open **local full-text search over saved user messages and assistant responses**. Editor windows scope search to their open workspace; the Agents window remains cross-workspace. Results include an excerpt and open the relevant chat at the matching message. Searching does not require opening or resuming every conversation. + +The default is **local keyword search**, using SQLite FTS5 and BM25 ranking. An optional **semantic mode** adds embedding-based matches for related concepts and combines them with keyword results. Semantic mode requires explicit approval before any query or saved message content is sent to the selected Copilot embeddings provider. + +## 1. How search works without this change + +There are two distinct search experiences: + +| Existing experience | What it searches | What it does not do | +|---|---|---| +| Search/Find in the sessions list | Session titles, child-chat titles where represented, and group/section labels | Search user messages or assistant responses across saved conversations | +| Find inside an open chat | User-message text and supported rendered response content in that conversation | Discover matching content in other saved conversations | + +The sessions list uses the standard tree Find widget, normally in filtering mode. The tree asks its keyboard-navigation label provider for the searchable string: + +- In the editor's Chat panel, session rows return `element.label`, normally the session title. +- In the Agents window, session rows return their title, child-chat rows return the chat title, and group/section rows return their labels. +- The tree performs fuzzy or contiguous matching against those labels and filters the list. + +**Typing into that list filter does not issue a full-text database query or fetch conversation histories.** The list's metadata may already have been loaded from storage, but the search operation itself works on the supplied labels. + +Find within an open chat is separate. It extracts text from the current chat model and supports navigation within that conversation. Its response extraction deliberately excludes reasoning and tool invocations whose rendered placement cannot be determined reliably from the model alone. + +References: [editor label provider](../../workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts), [Agents list](../../sessions/contrib/sessions/browser/views/sessionsList.ts), [tree Find implementation](../../base/browser/ui/tree/abstractTree.ts), and [in-chat Find extraction](../../workbench/contrib/chat/browser/widget/chatFind/chatFindContent.ts). + +## 2. The user problem + +Titles are useful for navigation, but they are a lossy summary of a conversation. + +For example, a session titled **"Fix startup regression"** may contain: + +- A user message describing an `ECONNRESET` error. +- An assistant response explaining token refresh. +- A code block showing a proposed fix. + +A title filter cannot find that session by `ECONNRESET` or `token refresh` unless those terms are also in the title. The desired experience is to search the discussion itself, see why a result matched, and jump to the relevant message instead of manually opening and searching multiple sessions. + +## 3. What changes for users + +The existing magnifying-glass button in both windows now opens **Search Agent Session Content (Preview)**. It is also available from the Command Palette. + +| Window | Search scope | +|---|---| +| Editor with an open folder or populated multi-root workspace | Sessions belonging to the current workspace | +| Empty editor window, with no workspace folders | Eligible sessions across projects on connected hosts | +| Agents window | Eligible sessions across projects on connected hosts, regardless of the selected workspace | + +The picker identifies the active scope. Workspace changes invalidate an in-flight editor search and rerun the query with the new scope. + +Each result displays: + +- Session title. +- Whether the match came from the user or assistant. +- Host and project context, plus an archive label when applicable. +- A short excerpt around the matching content. + +Selecting a result opens its exact chat and reveals the matching message. The picker supports keyboard navigation and reports scan progress, failures, unsupported hosts, and result limits. + +The old title-only filter remains available: + +- **Chat: Find Agent Session by Title** in the editor window. +- **Sessions: Find Session by Title** in the Agents window. + +The prototype does **not yet merge title matches and message-content matches into a single ranking**. Titles identify results in the content picker, but are not themselves indexed by its full-text index. A term appearing only in a title is still best found with title-only Find. + +### Content coverage + +| Content | Current prototype | +|---|---| +| Saved user messages | Included, with known injected prompt scaffolding removed | +| Saved assistant response text, including code contained in it | Included without truncating the indexed text | +| Rendered `task_complete` summaries | Included as assistant content | +| Default chat and persisted peer chats | Included | +| Archived sessions | Included when present in the host's available session catalog | +| Reasoning, synthetic user injections, and transient events | Excluded | +| Tool-origin subagent transcripts | Excluded | +| Attachments and arbitrary tool input/output | Excluded | +| Files elsewhere in the workspace | Not independently indexed | + +The scope starts with eligible Copilot sessions returned by the connected hosts. In a workspace-scoped editor, the client filters that catalog **before requesting or indexing histories**. It reuses the session list's matching rules: recorded workspace-file identity for multi-root sessions, working-directory containment otherwise, and the existing repository-root exception for legacy worktree sessions. URI authorities distinguish remote hosts; matching is not based on project display names or raw path prefixes. + +This is session-level scope, not a per-message file-access boundary: matching sessions contribute their default and peer chats. Search does not crawl arbitrary databases or every conversation directory on the machine. Host catalog visibility, external-session settings, and the active profile still affect which sessions are available. + +## 4. Why a local database alone is not enough + +Persisting conversations and making them searchable are different capabilities. A database may store metadata, event records, or file-edit snapshots without exposing an index suitable for interactive cross-session search. + +There are several storage responsibilities here: + +1. **Agent Host storage:** the host owns the session catalog, chat membership, opaque provider-backing records, turn mappings, and other metadata. Its per-session `session.db` is not simply a table of complete conversation messages. +2. **Copilot persisted history:** the provider accesses durable conversation events through the SDK. Search should use this API instead of depending on a private on-disk transcript schema. +3. **The new search index:** a derived, rebuildable index of selected message content. It is not the authoritative conversation store. + +There is also an existing **Chronicle full-text index in the Copilot extension**. It provides useful prior art: FTS5 indexing of user/assistant turn content and BM25-ranked queries. However, it is separate from the sessions-list UI, and its extension-side ingestion is not a guarantee of coverage for Agent Host conversations. Its ingestion/reindex paths also truncate some content, including assistant responses at 5,000 characters and historical user messages at 1,000 characters. + +The prototype therefore reads the provider's persisted history and indexes full message text rather than treating the extension's index as a complete transcript source. + +References: [host session schema](node/sessionDatabase.ts), [Copilot provider](node/copilot/copilotAgent.ts), [Chronicle search](../../../../extensions/copilot/src/platform/chronicle/node/sessionStore.ts), and [Chronicle truncation limits](../../../../extensions/copilot/src/extension/chronicle/common/sessionStoreTracking.ts). + +## 5. Architecture and request flow + +```text +Editor Chat panel / Agents window search button + | + Shared search picker + | + Agent Host connection abstraction + / \ + Local management IPC Remote AHP extension + \ / + AgentService.searchSessionHistory + | + Host-owned default and peer-chat catalog + | + Copilot provider adapter + | + SDK persisted-event reader -> FTS5 index + | + Chat/turn locators + short snippets +``` + +### Search runs where the history lives + +The UI does not open SQLite files directly. It asks the owning Agent Host to search and receives bounded results. For a remote host, the index and transcript reads remain on that host; result snippets cross the existing connection to the UI. + +The host owns the relationship between a session and its chats. The Copilot adapter owns the relationship between a chat and its SDK conversation. These identifiers are not necessarily identical, especially after forks or imports. The host passes opaque backing data to the provider rather than decoding it itself. + +### Local and remote communication are intentionally different + +Remote listeners that support the feature expose `vscode/searchSessionHistory`, advertised through the `vscode.searchSessionHistory` initialize capability. + +The local utility-process data plane deliberately disables these protocol extension methods. Local search therefore uses the existing **management IPC channel**, through `supportsSessionHistorySearch` and `searchSessionHistory`. The UI consults transport-aware support rather than assuming a protocol capability describes every local operation. + +This distinction matters: using the remote route for the local host produces "search unavailable" even when the search implementation is present. The fix preserves the restriction on unrelated protocol extension methods. + +References: [connection contracts](common/agentService.ts), [local management service](node/agentHostManagementService.ts), [protocol client](browser/agentHostProtocolClient.ts), and [host routing](node/agentHostSessionSearch.ts). + +## 6. How full-text retrieval works + +### Query behavior + +The prototype uses **SQLite FTS5**, an inverted text index: it looks up indexed words rather than scanning every complete transcript for each query. + +Input is converted into literal Unicode word/number terms joined with `AND`: + +| Query | Meaning | +|---|---| +| `document` | Find an indexed content segment containing that term | +| `token refresh` | Find a segment containing both terms | +| `"token refresh"` | Currently the same terms, not an exact-phrase instruction | +| `authentication` | Does not automatically match a discussion using only "signing in" | + +Punctuation is a separator, not SQL or advanced FTS query syntax. Terms must match within the same indexed message/content segment, not merely somewhere across the entire session. + +Unlike the old fuzzy title filter, this is token-based matching. General substring matching, stemming, and typo correction are not provided by the current query implementation. + +FTS5's **BM25** score ranks matching segments within each chat using word occurrence and document-length statistics. This is lexical relevance, not semantic understanding. + +The overall picker currently combines results from concurrent session searches; it does not perform a globally comparable relevance ranking across all chats and hosts. Multiple hits can repeat the same session title. + +### Indexing and freshness + +The host/profile has one derived search database beside its Agent Host storage file, normally: + +```text +User/globalStorage/agent-host-search.db + search_chats: integer chat ID -> harness, chat/session/storage URIs, + backing identity, journal revision, index version + search_turns: integer turn ID -> chat ID and original turn reference + search_documents: integer document ID -> turn ID, author role, + provider source locator + search_fts: full searchable text; rowid = integer document ID +``` + +Long chat and turn identifiers are stored in metadata rows, not repeated as FTS columns. Copilot, Claude, and Codex identities can coexist without collisions in the storage layer; only Copilot currently has a production document reader. The database remains a search-only cache, not a replacement for any provider database. + +For each searched chat: + +1. Read the last persisted event to identify the current journal tail. +2. Reuse the index when its version, backing identity, and tail ID still match. +3. Otherwise rebuild it by reading history forward in pages of 500 events. +4. Stop at the captured tail, so a conversation that keeps growing cannot extend the scan indefinitely. +5. Replace only that chat's metadata and FTS rows atomically in a SQLite transaction. +6. Query with the owning chat constraint before the result limit, then return bounded excerpts. + +The SDK's `sessions.readPersistedEvents` API reads history **without creating, resuming, or activating the conversation**. The provider runtime may still need to initialize to serve that API. + +Append, truncation, and backing changes invalidate the cache. The current implementation rebuilds the affected chat's index rather than incrementally inserting only newly appended events. It assumes durable event IDs identify immutable events; in-place content edits that preserve all relevant IDs are not covered by that freshness scheme. + +Failed or inconsistent reads are surfaced as errors. A failed rebuild is rolled back rather than reported as a successful partial search. + +The existing session-data deletion notification removes the corresponding shared-cache rows, including FTS entries. This cleanup is one-way: deleting cache data never deletes history. The cache checks its own database identity before changing schema, so accidentally pointing it at another database must fail without altering that database. + +Recognized per-chat `session-search.db` files from the first prototype are cleaned up lazily after successful shared-cache indexing. Unsearched chats may retain their old sidecars during the transition, and unrecognized files are preserved with a warning. No canonical provider or session-history database is migrated, compacted, or deleted by this feature. + +### Result identity and navigation + +The result contract is intentionally small: + +```ts +{ + chat: string; + turnId: string; + role: 'user' | 'assistant'; + snippet: string; +} +``` + +The owning connection supplies host identity. The host reconciles persisted SDK event IDs with loaded host turn IDs when necessary. The UI then opens the exact chat and selects the appropriate user or assistant row. + +If the matching turn is missing, the UI reports that it could not reveal it. It does not silently redirect to another message with similar text. + +References: [query/result contracts](common/agentHostSessionSearch.ts), [shared database](node/sessionSearchDatabase.ts), [cache lifecycle service](node/agentHostSessionSearchIndex.ts), [Copilot document reader](node/copilot/copilotSessionSearch.ts), and [picker/navigation](../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionSearch.ts). + +## 7. Responsiveness, privacy, and current tradeoffs + +| Decision | Benefit / tradeoff | +|---|---| +| Search only after a non-empty query; debounce edits by 300 ms | Avoid work while idle and reduce repeated scans while typing | +| At most four session requests in flight per picker, shared across query generations | Bound concurrency; an old in-flight request may finish before a newer query proceeds | +| Discard stale responses and stop queueing work after cancellation | Prevent old results overwriting the current query; does not abort every already-running host request | +| 512-character query limit; 20 results per chat; 100 per host session response and 100 displayed overall | Bound work and response size; broad queries may require refinement | +| Excerpts bounded to 220 characters, but full message content indexed | Keep the UI compact without losing matches deep in long messages | +| Persist a derived local index | Faster reuse, at the cost of additional disk space and duplicate stored conversation text | +| Restrictive POSIX file permissions where supported | Protect the derived index; it is not an encrypted store | +| Keyword mode makes no embeddings or inference requests | Semantic mode adds provider calls only after explicit opt-in | + +"Local search" does not mean the rest of VS Code never uses the network: authentication, normal provider initialization, and remote-host communication remain separate concerns. + +The first search can be slower while indexes are created. This prototype has functional validation, not a production-scale latency or memory benchmark. + +### Measured storage footprint and optimization options + +A sample of the original per-chat sidecar prototype measured on September 15, 2026 contained: + +| Measurement | Value | +|---|---| +| Per-chat search databases | 48 | +| Indexed message/content segments | 1,964 | +| Indexed UTF-8 text | 0.95 MiB | +| Total SQLite search database size | 3.55 MiB | +| Median / largest database | 60 KiB / 392 KiB | + +At this sample's average, 1,000 similarly sized chats would consume roughly 74 MiB. This is an illustration, not a limit or a forecast for long coding conversations. Searchable text volume, vocabulary, SQLite page overhead, and retained free pages affect size. The UI's result limits do not cap index storage. + +Reindexing the same 1,964 segments into the consolidated schema reduced the search cache from **48 files / 3,723,264 bytes (3.55 MiB)** to **one file / 2,121,728 bytes (2.02 MiB)**: **43% less storage**, while retaining the text. The comparison returned the same 34 bounded `document` matches and verified that the original search sidecars' bytes were unchanged. It used normalized documents read from our existing search caches, not a live SDK/network latency benchmark. The temporary comparison database was removed afterwards. + +Only the Copilot adapter currently supplies searchable documents. A Claude session does not receive an index today. A future Claude adapter can use the same shared database rather than creating another database per chat or provider. + +**Consolidation is now implemented:** one derived search index per host/profile, with integer chat, turn, and document IDs. Remaining storage optimizations are separate follow-up work: + +1. **Evaluate contentless FTS5.** The consolidated prototype still keeps the searchable text in its FTS table. Omitting that copy requires fetching original messages for snippets and does not eliminate the inverted index itself. +2. **Update incrementally and reclaim space deliberately.** Persist indexing cursors for append-only history and compact when fragmentation warrants it, rather than rebuilding every changed chat. +3. **Deduplicate repeated content and add a cache budget.** Preserve separate occurrence/turn locators while sharing repeated text where worthwhile. Eviction can bound disk use, at the cost of reindexing evicted history. + +These follow-ups are not implemented. Truncating messages to reduce storage is not recommended: it would reintroduce the missing-deep-content problem. All optimization, migration, and cleanup work is restricted to the derived search database we own; existing SDK, extension, and session-history databases stay untouched. + +**Profile identity is part of the data boundary.** A development build using a separate user-data directory sees that profile's host catalog, not automatically the user's Insiders history. Real-history validation used a separate profile snapshot with consistent SQLite backups; the original Insiders databases were not modified. + +## 8. Opt-in semantic search + +Semantic search addresses a different retrieval problem: finding a discussion about "signing in" when the query is "authentication." + +The prototype uses **hybrid retrieval**: + +1. The user enables semantic mode in the existing search picker and chooses a registered Copilot embeddings provider. +2. A confirmation explains the scope, provider, content transfer, local vector storage, and first-time indexing cost. +3. The ordinary keyword request refreshes the canonical-text search cache for each eligible session. +4. The host returns bounded batches of unembedded text chunks, independently of keyword matches. +5. The workbench computes chunk embeddings through the extension provider, then sends vectors back to the host-owned search cache. +6. A query embedding retrieves candidates by cosine similarity. Reciprocal-rank fusion combines semantic and keyword rankings, deduplicating by chat, turn, and author role. + +Independent semantic retrieval matters. Merely reranking the lexical results would still miss conversations that share no query terms. + +### Extension and host boundary + +The existing extension registers providers through the proposed embeddings API. The shared [workbench embeddings service](../../workbench/services/embeddings/common/embeddingsService.ts) now exposes that registration to search without importing Copilot extension internals into Agent Host. + +The host exposes three typed operations through `sessionSemanticSearch`: retrieve pending chunks, store vectors, and search cached vectors. Local utility-process clients use management IPC; remote hosts negotiate a separate semantic-search capability. The host remains responsible for the eligible chat catalog and query isolation. It never receives embedding-endpoint credentials. + +### Consent, failures, and scope + +Keyword mode remains the default. Consent applies only to the current picker and scope, not all future searches. Closing the picker, switching back to keyword mode, or changing workspace stops queued embedding work. Cancellation is passed to the provider for requests already in flight; it cannot undo content already sent. + +Editor workspace filtering happens before message histories or embedding chunks are requested. Agents-window searches remain cross-workspace and say so in the confirmation. Semantic mode follows the existing registered provider's authentication and policy behavior; it does not invent a separate token or endpoint. + +Missing providers, unsupported hosts, indexing budgets, and endpoint failures retain keyword results with an explicit fallback/incomplete indication. An index that covers only part of the eligible content must not be presented as complete semantic search. + +### Persistence and limits + +Chunk rows contain offsets and hashes referencing the existing FTS text, rather than a second chunk-text copy. Normalized Float32 vectors are stored as blobs in the same owned search database, keyed by chunk, model identity, and dimension count. Source-document deletion or replacement removes the related chunks and vectors. Stale vector writes are rejected rather than being attached to a replacement document that reused an ID. + +A 512-dimensional Float32 embedding requires 2,048 raw bytes per chunk, plus database/index metadata. The earlier 43% consolidation measurement covers the lexical cache only; semantic vectors add storage. This prototype uses exact cosine comparison within the requested session, not an approximate nearest-neighbor index. + +Provider model identifiers must change when their embedding semantics change; dimension checks alone cannot detect an incompatible model that emits the same vector length. Semantic quality, similarity thresholds, and large-corpus performance still require real-model evaluation. Deterministic test vectors verify retrieval mechanics but are not a relevance benchmark. + +Further improvements include unified title/body retrieval, global cross-session reranking, incremental preservation of unchanged chunks, and a measured retrieval-quality/performance baseline. + +## 9. Validation and review focus + +Automated coverage includes long-message matches, prompt-scaffolding exclusion, peer-chat routing, stable navigation IDs, pagination, cache reuse, append/truncate invalidation, rollback on errors, query validation, result bounds, cancellation, local-management support, both toolbar registrations, and workspace-scoped filtering before history requests. + +Live OSS checks verified real-history results, opening a result at its matching message, and launching the picker from the existing search button in both windows. + +Reviewers should distinguish three claims: + +- **Implemented:** searchable saved message content, bounded snippets, navigation, local persistent indexing, and opt-in embedding-based hybrid retrieval. +- **Not yet implemented:** a calibrated global hybrid ranking, a unified title/body query, and arbitrary tool-output search. +- **Requires further measurement:** large-corpus latency, index growth, rebuild cost, and retrieval quality. + +### Short explanation for sharing + +> The existing session search filters titles. This change searches the saved conversation itself: user messages and assistant responses. It builds one search cache per Agent Host/profile, returns contextual snippets, and opens the matching message without loading every chat during search. Keyword search stays local; an optional, explicitly approved semantic mode adds concept-based matches using the registered Copilot embeddings provider. Search text, chunk references, and vectors live in our derived cache; provider/history databases remain untouched. diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 6566e7253d0f1a..7740e4309e46ae 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -19,7 +19,10 @@ import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../. import { ConfigurationTarget, ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; -import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostChatStateFile, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap } from '../common/agentHostExtensionProtocol.js'; +import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SearchSessionHistoryExtensionMethod, SessionSemanticSearchExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostChatStateFile, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap } from '../common/agentHostExtensionProtocol.js'; +import type { IAgentSessionSearchResult } from '../common/agentHostSessionSearch.js'; +import { supportsAgentHostSessionSemanticSearch, supportsAgentHostSessionSearch } from '../common/meta/agentHostSessionSearchMeta.js'; +import { validateSessionSemanticRequest, type ISessionSemanticRequest, type ISessionSemanticResult } from '../common/sessionSemanticSearch.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -1665,6 +1668,29 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect })); } + async searchSessionHistory(session: URI, query: string): Promise { + if (!supportsAgentHostSessionSearch(this._initializeResult.get())) { + throw new Error('This Agent Host does not support conversation content search. Update the host and reconnect.'); + } + return this._sendExtensionRequest(SearchSessionHistoryExtensionMethod, { session: session.toString(), query }); + } + + async supportsSessionHistorySearch(): Promise { + return supportsAgentHostSessionSearch(this._initializeResult.get()); + } + + async supportsSessionSemanticSearch(): Promise { + return supportsAgentHostSessionSemanticSearch(this._initializeResult.get()); + } + + async sessionSemanticSearch(session: URI, request: ISessionSemanticRequest): Promise { + if (!supportsAgentHostSessionSemanticSearch(this._initializeResult.get())) { + throw new Error('This Agent Host does not support semantic search'); + } + validateSessionSemanticRequest(request); + return this._sendExtensionRequest(SessionSemanticSearchExtensionMethod, { session: session.toString(), request }); + } + private _toClientUri(uri: URI): URI { return uri.scheme === Schemas.file ? toAgentHostUri(uri, this._connectionAuthority) : uri; } diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 85e3e8f3b680fe..0ac553506b440f 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -14,6 +14,7 @@ import { isEqual } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import type { IAgentServerToolHost } from './agentServerTools.js'; import type { AgentHostClientType } from './agentHostClientInfo.js'; +import type { IAgentChatSearchResult } from './agentHostSessionSearch.js'; import type { IAgentHostClientTelemetryContext } from './agentHostTelemetry.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import { ProtectedResourceMetadata, type Changeset, type ChatOrigin, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js'; @@ -1201,6 +1202,9 @@ export interface IAgent { /** Exact-chat operations: create, send, abort, mutate, restore history, release, and dispose. */ readonly chats: IAgentChats; + /** Search the persisted, user-visible history of an exact chat without materializing it. */ + searchChatHistory?(chat: URI, context: IAgentChatContext, providerData: string | undefined, query: string): Promise; + /** Re-attach an exact chat from opaque provider data without inferring its role. */ materializeChat(chat: URI, context: URI | IAgentChatContext, providerData: string | undefined): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts index da19f3ed570141..b3dcd2d13394f6 100644 --- a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts +++ b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts @@ -7,6 +7,9 @@ import { vEnum, vObj, vOptionalProp, vString, type ValidatorType } from '../../. import type { AgentHostDebugLogsArtifactKind, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult } from './agentService.js'; import type { InitializeResult } from './state/protocol/common/commands.js'; import { AgentHostArtifactRemovalCapabilityMetaKey } from './meta/agentHostArtifactRemovalMeta.js'; +import { AgentHostSessionSearchCapabilityMetaKey, AgentHostSessionSemanticSearchCapabilityMetaKey } from './meta/agentHostSessionSearchMeta.js'; +import type { ISessionSemanticRequest, ISessionSemanticResult } from './sessionSemanticSearch.js'; +import type { IAgentSessionSearchResult } from './agentHostSessionSearch.js'; export { supportsAgentHostArtifactRemoval } from './meta/agentHostArtifactRemovalMeta.js'; @@ -20,6 +23,8 @@ export const ReadAgentHostDebugLogsChunkExtensionMethod = 'vscode/readAgentHostD export const SetAgentHostDetachedWorktreeArchivedExtensionMethod = 'vscode/setAgentHostDetachedWorktreeArchived'; export const RequestAgentHostWorkspaceTrustExtensionMethod = 'vscode/requestWorkspaceTrust'; export const RemoveSessionArtifactExtensionMethod = 'vscode/removeSessionArtifact'; +export const SearchSessionHistoryExtensionMethod = 'vscode/searchSessionHistory'; +export const SessionSemanticSearchExtensionMethod = 'vscode/sessionSemanticSearch'; const AgentHostChatStateFileCapabilityMetaKey = 'vscode.getAgentHostSessionStateFile.chat'; const AgentHostDetachedWorktreeCapabilityMetaKey = 'vscode.detachedWorktrees'; @@ -28,17 +33,21 @@ export interface IAgentHostExtensionInitializeResultMeta extends Record; + result: IAgentSessionSearchResult; + }; [RemoveSessionArtifactExtensionMethod]: { params: ValidatorType; result: void; diff --git a/src/vs/platform/agentHost/common/agentHostSessionSearch.ts b/src/vs/platform/agentHost/common/agentHostSessionSearch.ts new file mode 100644 index 00000000000000..5eca817c9891fd --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostSessionSearch.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const MAX_SESSION_SEARCH_QUERY_LENGTH = 512; +export const AGENT_CHAT_SEARCH_MAX_RESULTS = 20; + +export interface IAgentChatSearchMatch { + readonly turnId: string; + readonly role: 'user' | 'assistant'; + readonly snippet: string; +} + +export interface IAgentSessionSearchMatch extends IAgentChatSearchMatch { + readonly chat: string; +} + +export interface IAgentChatSearchResult { + readonly matches: IAgentChatSearchMatch[]; + readonly hasMore: boolean; +} + +export interface IAgentSessionSearchResult { + readonly matches: IAgentSessionSearchMatch[]; + readonly hasMore: boolean; +} + +export function validateSessionSearchQuery(query: string): void { + if (query.length > MAX_SESSION_SEARCH_QUERY_LENGTH) { + throw new Error('Conversation search query exceeds the maximum length'); + } + if (!query.trim()) { + throw new Error('Conversation search query must not be empty'); + } +} + +/** Search uses literal Unicode words joined with AND; punctuation and quotes are separators, not query syntax. */ +export function getAgentSessionSearchTerms(query: string): string[] { + validateSessionSearchQuery(query); + return query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu) ?? []; +} diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index e8b50ec7f934e1..7e6391776408cc 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -16,6 +16,8 @@ import { AgentSandboxSettingId } from '../../sandbox/common/settings.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from './state/agentSubscription.js'; import type { IRemoteWatchHandle } from './agentHostFileSystemProvider.js'; import type { IAgentHostResourceUriMapper } from './agentHostUri.js'; +import type { IAgentSessionSearchResult } from './agentHostSessionSearch.js'; +import type { ISessionSemanticRequest, ISessionSemanticResult } from './sessionSemanticSearch.js'; import type { IAgentHostClientTelemetryContext } from './agentHostTelemetry.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import type { AutomationCapabilities, InitializeResult } from './state/protocol/common/commands.js'; @@ -785,6 +787,10 @@ export interface IAgentHostManagementService { getNetworkDiagnosticsInfo(): Promise; getManagedSettingsDiagnostics(): Promise; diagnosticsFetch(url: string): Promise; + supportsSessionHistorySearch(): Promise; + supportsSessionSemanticSearch(): Promise; + sessionSemanticSearch(session: URI, request: ISessionSemanticRequest): Promise; + searchSessionHistory(session: URI, query: string): Promise; getSessionStateFile(session: URI, chat?: URI): Promise; collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind, chat?: URI): Promise; readDebugLogsChunk(resource: URI, position: number): Promise; @@ -805,6 +811,9 @@ export const IAgentService = createDecorator('agentService'); * and mutate state by dispatching actions (e.g. session/turnStarted, session/turnCancelled). */ export interface IAgentService { + sessionSemanticSearch?(session: URI, request: ISessionSemanticRequest): Promise; + /** Search persisted user and assistant messages without materializing any chat. */ + searchSessionHistory?(session: URI, query: string): Promise; readonly _serviceBrand: undefined; /** @@ -1126,6 +1135,12 @@ export interface IAgentConnection { // ---- Session lifecycle -------------------------------------------------- authenticate(params: AuthenticateParams): Promise; listSessions(): Promise; + /** Resolves search support on this connection's protocol or local management transport. */ + supportsSessionHistorySearch?(): Promise; + supportsSessionSemanticSearch?(): Promise; + sessionSemanticSearch?(session: URI, request: ISessionSemanticRequest): Promise; + /** Searches over the transport selected by this connection. */ + searchSessionHistory?(session: URI, query: string): Promise; createSession(config?: IAgentCreateSessionConfig): Promise; /** Requires the VS Code artifact removal capability advertised by initialize. */ removeSessionArtifact?(session: URI, artifactId: string): Promise; diff --git a/src/vs/platform/agentHost/common/meta/agentHostSessionSearchMeta.ts b/src/vs/platform/agentHost/common/meta/agentHostSessionSearchMeta.ts new file mode 100644 index 00000000000000..907f5570b7fd54 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentHostSessionSearchMeta.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { InitializeResult } from '../state/protocol/common/commands.js'; + +export const AgentHostSessionSearchCapabilityMetaKey = 'vscode.searchSessionHistory'; +export const AgentHostSessionSemanticSearchCapabilityMetaKey = 'vscode.sessionSemanticSearch'; + +/** Whether the host supports searching persisted conversation content without restoring chats. */ +export function supportsAgentHostSessionSearch(result: InitializeResult | undefined): boolean { + return result?._meta?.[AgentHostSessionSearchCapabilityMetaKey] === true; +} + +export function supportsAgentHostSessionSemanticSearch(result: InitializeResult | undefined): boolean { + return result?._meta?.[AgentHostSessionSemanticSearchCapabilityMetaKey] === true; +} diff --git a/src/vs/platform/agentHost/common/sessionSemanticSearch.ts b/src/vs/platform/agentHost/common/sessionSemanticSearch.ts new file mode 100644 index 00000000000000..b09b9a77f1a96d --- /dev/null +++ b/src/vs/platform/agentHost/common/sessionSemanticSearch.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IAgentSessionSearchMatch } from './agentHostSessionSearch.js'; + +export const MAX_SESSION_EMBEDDING_BATCH = 16; +export const MAX_SESSION_EMBEDDING_DIMENSIONS = 2048; + +/** The caller supplies a versioned model identity; dimensions form a separate cache namespace. */ +export interface ISessionEmbeddingModel { + readonly id: string; + readonly dimensions: number; +} + +export interface ISessionEmbeddingChunk { + readonly id: number; + readonly contentHash: string; + readonly text: string; +} + +export interface ISessionEmbeddingValue { + readonly id: number; + readonly contentHash: string; + readonly vector: readonly number[]; +} + +export interface ISessionSemanticMatch extends IAgentSessionSearchMatch { + readonly score: number; +} + +export type ISessionSemanticRequest = + | { kind: 'pending'; model: ISessionEmbeddingModel } + | { kind: 'store'; model: ISessionEmbeddingModel; values: readonly ISessionEmbeddingValue[] } + | { kind: 'search'; model: ISessionEmbeddingModel; vector: readonly number[] }; + +export type ISessionSemanticResult = + | { kind: 'pending'; chunks: readonly ISessionEmbeddingChunk[]; hasMore: boolean } + | { kind: 'store' } + | { kind: 'search'; matches: readonly ISessionSemanticMatch[]; hasMore: boolean; incomplete: boolean }; + +export function validateSessionSemanticRequest(value: unknown): asserts value is ISessionSemanticRequest { + if (!isRecord(value) || !isRecord(value.model) + || typeof value.model.id !== 'string' || !value.model.id.trim() || value.model.id.length > 128 + || typeof value.model.dimensions !== 'number' || !Number.isInteger(value.model.dimensions) + || value.model.dimensions < 1 || value.model.dimensions > MAX_SESSION_EMBEDDING_DIMENSIONS) { + throw new Error('Invalid session embedding model'); + } + switch (value.kind) { + case 'pending': + return; + case 'search': + validateVector(value.vector, value.model.dimensions); + return; + case 'store': { + if (!Array.isArray(value.values) || value.values.length > 32) { + throw new Error('Invalid session embedding batch'); + } + const ids = new Set(); + for (const entry of value.values) { + if (!isRecord(entry) + || typeof entry.id !== 'number' || !Number.isSafeInteger(entry.id) || entry.id <= 0 || ids.has(entry.id) + || typeof entry.contentHash !== 'string' || !/^[a-fA-F0-9]{64}$/.test(entry.contentHash)) { + throw new Error('Invalid session embedding chunk'); + } + ids.add(entry.id); + validateVector(entry.vector, value.model.dimensions); + } + return; + } + default: + throw new Error('Invalid session semantic search operation'); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateVector(value: unknown, dimensions: number): void { + if (!Array.isArray(value) || value.length !== dimensions) { + throw new Error('Invalid session embedding dimensions'); + } + let nonzero = false; + for (const component of value) { + if (typeof component !== 'number' || !Number.isFinite(component)) { + throw new Error('Session embedding components must be finite numbers'); + } + nonzero ||= component !== 0; + } + if (!nonzero) { + throw new Error('Session embedding vector must have a nonzero norm'); + } +} diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index fb22bb9b79f037..a14862db6284b9 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -30,6 +30,8 @@ import { AGENT_HOST_CLIENT_PROXY_CHANNEL, AgentHostClientProxyChannel } from '.. import { LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../common/agentHostResourceService.js'; import { identityAgentHostResourceUriMapper } from '../common/agentHostUri.js'; import { AgentHostStartupTelemetry } from '../common/agentHostStartupTelemetry.js'; +import type { IAgentSessionSearchResult } from '../common/agentHostSessionSearch.js'; +import type { ISessionSemanticRequest, ISessionSemanticResult } from '../common/sessionSemanticSearch.js'; import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; import { AgentHostAhpJsonlLoggingSettingId, @@ -420,6 +422,22 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._requireClient().createSession(config); } + searchSessionHistory(session: URI, query: string): Promise { + return this._getManagementService().searchSessionHistory(session, query); + } + + supportsSessionHistorySearch(): Promise { + return this._getManagementService().supportsSessionHistorySearch(); + } + + supportsSessionSemanticSearch(): Promise { + return this._getManagementService().supportsSessionSemanticSearch(); + } + + sessionSemanticSearch(session: URI, request: ISessionSemanticRequest): Promise { + return this._getManagementService().sessionSemanticSearch(session, request); + } + createDetachedWorktree(session: URI, prompt: string): Promise<{ handle: string; worktree: URI }> { return this._getManagementService().createDetachedWorktree(session, prompt); } diff --git a/src/vs/platform/agentHost/node/agentHostManagementService.ts b/src/vs/platform/agentHost/node/agentHostManagementService.ts index 599cfa88a7bc15..28298ae17f925b 100644 --- a/src/vs/platform/agentHost/node/agentHostManagementService.ts +++ b/src/vs/platform/agentHost/node/agentHostManagementService.ts @@ -9,6 +9,8 @@ import { ILogService } from '../../log/common/log.js'; import { IAgentCreateChatRequestOptions, IAgentCreateSessionConfig } from '../common/agent.js'; import { IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostManagementService, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; +import type { IAgentSessionSearchResult } from '../common/agentHostSessionSearch.js'; +import type { ISessionSemanticRequest, ISessionSemanticResult } from '../common/sessionSemanticSearch.js'; const SHUTDOWN_DRAIN_TIMEOUT_MS = 1000; const PROVIDER_SHUTDOWN_TIMEOUT_MS = 1500; @@ -123,6 +125,29 @@ export class AgentHostManagementService implements IAgentHostManagementService { return this._agentService.diagnosticsFetch(url); } + async supportsSessionHistorySearch(): Promise { + return !!this._agentService.searchSessionHistory; + } + + async supportsSessionSemanticSearch(): Promise { + return !!this._agentService.sessionSemanticSearch; + } + + sessionSemanticSearch(session: URI, request: ISessionSemanticRequest): Promise { + if (!this._agentService.sessionSemanticSearch) { + throw new Error('Agent Host semantic search is unavailable'); + } + return this._runMutation(() => this._agentService.sessionSemanticSearch!(session, request)); + } + + searchSessionHistory(session: URI, query: string): Promise { + const search = this._agentService.searchSessionHistory; + if (!search) { + throw new Error('Agent Host conversation search is unavailable'); + } + return this._runMutation(() => this._agentService.searchSessionHistory!(session, query)); + } + getSessionStateFile(session: URI, chat?: URI): Promise { if (!this._agentService.getSessionStateFile) { throw new Error('Agent Host session state files are unavailable'); diff --git a/src/vs/platform/agentHost/node/agentHostServices.ts b/src/vs/platform/agentHost/node/agentHostServices.ts index ccf4eb789f7e52..8ec23cda872236 100644 --- a/src/vs/platform/agentHost/node/agentHostServices.ts +++ b/src/vs/platform/agentHost/node/agentHostServices.ts @@ -11,6 +11,7 @@ import { ISandboxHelperService } from '../../sandbox/common/sandboxHelperService import { SandboxHelperService } from '../../sandbox/node/sandboxHelper.js'; import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../sandbox/common/terminalSandboxMxcRuntime.js'; import { URI } from '../../../base/common/uri.js'; +import { dirname, joinPath } from '../../../base/common/resources.js'; import { IAgentPluginManager } from '../common/agentPluginManager.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { IAgentEditAttributionService } from '../common/fileEditAttribution.js'; @@ -65,6 +66,7 @@ import { EditSurvivalReporterFactory, IEditSurvivalReporterFactory } from './sha import { IAgentHostWorktreeIsolation, WorktreeIsolation } from './shared/worktreeIsolation.js'; import { AgentBranchNameGenerator, IAgentBranchNameGenerator } from './shared/agentBranchNameGenerator.js'; import { AgentHostTurnService, IAgentHostTurnService } from './agentHostTurnService.js'; +import { AgentHostSessionSearchIndex, IAgentHostSessionSearchIndex } from './agentHostSessionSearchIndex.js'; export interface IAgentHostCoreServiceInputs { readonly storageResource: URI | undefined; @@ -81,6 +83,9 @@ export function registerAgentHostCoreServices(services: ServiceCollection, input services.set(IEditSurvivalReporterFactory, new SyncDescriptor(EditSurvivalReporterFactory)); services.set(IEditArcReporterService, new SyncDescriptor(EditArcReporterService, [undefined])); services.set(IAgentHostStorageService, new SyncDescriptor(AgentHostStorageService, [inputs.storageResource])); + services.set(IAgentHostSessionSearchIndex, new SyncDescriptor(AgentHostSessionSearchIndex, [ + inputs.storageResource ? joinPath(dirname(inputs.storageResource), 'agent-host-search.db').fsPath : undefined, + ])); services.set(IAgentHostManagedSettingsService, new SyncDescriptor(AgentHostManagedSettingsService)); services.set(IAgentHostOctoKitService, new SyncDescriptor(AgentHostOctoKitService, [inputs.fetchFn])); services.set(IGitHubService, new SyncDescriptor(GitHubService, [inputs.gitHubServiceOptions])); diff --git a/src/vs/platform/agentHost/node/agentHostSessionSearch.ts b/src/vs/platform/agentHost/node/agentHostSessionSearch.ts new file mode 100644 index 00000000000000..d952f373a92ae1 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostSessionSearch.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import type { IAgent } from '../common/agent.js'; +import type { IAgentSessionSearchMatch, IAgentSessionSearchResult } from '../common/agentHostSessionSearch.js'; +import type { ISessionDataService } from '../common/sessionDataService.js'; +import { ChatOriginKind, isSubagentChatUri, parseRequiredSessionUriFromChatUri, type ChatOrigin } from '../common/state/sessionState.js'; +import { createAgentChatContext } from './agentChatContext.js'; +import type { AgentHostStateManager } from './agentHostStateManager.js'; + +interface ISearchableChat { + readonly uri: string; + readonly providerData?: string; + readonly origin?: ChatOrigin; +} + +/** Routes a persisted catalog to its provider without subscribing to or materializing chats. */ +export async function searchSessionChats(session: URI, chats: readonly ISearchableChat[], query: string, provider: IAgent, stateManager: AgentHostStateManager, sessionDataService: ISessionDataService): Promise { + if (!provider.searchChatHistory) { + throw new Error('This provider does not support persisted conversation search'); + } + const matches: IAgentSessionSearchResult['matches'] = []; + let hasMore = false; + for (const entry of chats) { + if (entry.origin?.kind === ChatOriginKind.Tool || isSubagentChatUri(entry.uri)) { + continue; + } + const chat = URI.parse(entry.uri); + if (parseRequiredSessionUriFromChatUri(chat) !== session.toString()) { + throw new Error('Persisted search chat does not belong to its session'); + } + const context = { ...createAgentChatContext(stateManager, session, chat), ...(entry.origin ? { origin: entry.origin } : {}) }; + const result = await provider.searchChatHistory(chat, context, entry.providerData, query); + const remaining = 100 - matches.length; + matches.push(...result.matches.slice(0, remaining).map(match => ({ ...match, chat: entry.uri }))); + // Semantic retrieval needs every eligible chat refreshed, even after the lexical result cap. + hasMore ||= result.hasMore || result.matches.length > remaining; + } + return { matches: await remapSessionSearchMatches(session, matches, stateManager, sessionDataService), hasMore }; +} + +export async function remapSessionSearchMatches(session: URI, matches: readonly T[], stateManager: AgentHostStateManager, sessionDataService: ISessionDataService): Promise { + const mappings = new Map>(); + for (const chatUri of new Set(matches.map(match => match.chat))) { + const chat = URI.parse(chatUri); + const context = createAgentChatContext(stateManager, session, chat); + const turnIds = new Map(); + const liveChat = stateManager.getChatState(chatUri); + const liveTurnIds = liveChat?.turns.map(turn => turn.id) ?? []; + if (liveChat?.activeTurn) { + liveTurnIds.push(liveChat.activeTurn.id); + } + if (liveTurnIds.length) { + const ref = await sessionDataService.tryOpenDatabase(context.resource); + if (ref) { + try { + for (const turnId of liveTurnIds) { + const eventId = await ref.object.getTurnEventId(turnId); + if (eventId) { + turnIds.set(eventId, turnId); + } + } + } finally { + ref.dispose(); + } + } + } + mappings.set(chatUri, turnIds); + } + return matches.map(match => ({ ...match, turnId: mappings.get(match.chat)?.get(match.turnId) ?? match.turnId })); +} diff --git a/src/vs/platform/agentHost/node/agentHostSessionSearchIndex.ts b/src/vs/platform/agentHost/node/agentHostSessionSearchIndex.ts new file mode 100644 index 00000000000000..036d14205ac67f --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostSessionSearchIndex.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Database } from '@vscode/sqlite3'; +import type { Stats } from 'fs'; +import { lstat, unlink } from 'fs/promises'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { hasKey } from '../../../base/common/types.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; +import { getAgentSessionSearchTerms, type IAgentChatSearchResult } from '../common/agentHostSessionSearch.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { SessionSearchDatabase, type ISessionSearchChat, type ISessionSearchSource } from './sessionSearchDatabase.js'; +import type { ISessionSemanticRequest, ISessionSemanticResult } from '../common/sessionSemanticSearch.js'; + +export const IAgentHostSessionSearchIndex = createDecorator('agentHostSessionSearchIndex'); + +export interface IAgentHostSessionSearchIndex { + readonly _serviceBrand: undefined; + searchChat(chat: ISessionSearchChat, query: string, readSource: () => Promise): Promise; + semanticSearch(sessionUri: string, chatUris: readonly string[], request: ISessionSemanticRequest): Promise; +} + +/** Owns the derived search cache, independently of provider and session-history databases. */ +export class AgentHostSessionSearchIndex extends Disposable implements IAgentHostSessionSearchIndex { + declare readonly _serviceBrand: undefined; + private readonly database: SessionSearchDatabase | undefined; + + constructor( + databasePath: string | undefined, + @ISessionDataService private readonly sessionDataService: ISessionDataService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.database = databasePath ? new SessionSearchDatabase(databasePath) : undefined; + this._register(sessionDataService.onWillDeleteSessionData(event => { + if (this.database) { + event.waitUntil(this.database.deleteScope(event.session.toString())); + } + })); + } + + async searchChat(chat: ISessionSearchChat, query: string, readSource: () => Promise): Promise { + if (this._store.isDisposed) { + throw new Error('Agent Host search cache has been disposed'); + } + if (getAgentSessionSearchTerms(query).length === 0) { + return { matches: [], hasMore: false }; + } + if (!this.database) { + throw new Error('Agent Host search cache storage is unavailable'); + } + const result = await this.database.searchChat(chat, query, readSource); + const legacy = URI.joinPath(this.sessionDataService.getSessionDataDir(URI.parse(chat.storageUri)), 'session-search.db'); + try { + await removeLegacySearchIndex(legacy.fsPath); + } catch (error) { + this.logService.warn('[AgentHostSessionSearchIndex] Could not remove legacy search cache', error); + } + return result; + } + + async semanticSearch(sessionUri: string, chatUris: readonly string[], request: ISessionSemanticRequest): Promise { + if (this._store.isDisposed || !this.database) { + throw new Error('Agent Host search cache storage is unavailable'); + } + return this.database.semanticSearch(sessionUri, chatUris, request); + } +} + +async function removeLegacySearchIndex(path: string): Promise { + let info: Stats; + try { + info = await lstat(path); + } catch (error) { + if (hasKey(error, { code: true }) && error.code === 'ENOENT') { + return; + } + throw error; + } + if (!info.isFile()) { + throw new Error('Legacy search cache is not a regular file'); + } + const sqlite3 = await import('@vscode/sqlite3'); + const db = await new Promise((resolve, reject) => { + const database = new sqlite3.default.Database(path, sqlite3.default.OPEN_READONLY, error => error ? reject(error) : resolve(database)); + }); + try { + const metadata = await new Promise<{ version: number; source: string; messageSchema: string } | undefined>((resolve, reject) => { + db.get(`SELECT version, source, (SELECT sql FROM sqlite_master WHERE name = 'messages' AND type = 'table') AS messageSchema FROM search_metadata LIMIT 1`, + (error: Error | null, row: { version: number; source: string; messageSchema: string } | undefined) => error ? reject(error) : resolve(row)); + }); + if (metadata?.version !== 1 || typeof metadata.source !== 'string' + || typeof metadata.messageSchema !== 'string' || !/USING\s+fts5\s*\(\s*turn_id\s+UNINDEXED,\s*role\s+UNINDEXED,\s*content\s*\)/i.test(metadata.messageSchema)) { + throw new Error('File is not the expected legacy search cache'); + } + } finally { + await new Promise((resolve, reject) => db.close(error => error ? reject(error) : resolve())); + } + const current = await lstat(path); + if (current.ino !== info.ino || current.size !== info.size || current.mtimeMs !== info.mtimeMs) { + throw new Error('Legacy search cache changed during migration'); + } + await unlink(path); +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 2fc4c7717346e5..d19591b48d1ef9 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -21,6 +21,10 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { ILogService } from '../../log/common/log.js'; import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; +import { MAX_SESSION_SEARCH_QUERY_LENGTH, type IAgentSessionSearchResult } from '../common/agentHostSessionSearch.js'; +import { remapSessionSearchMatches, searchSessionChats } from './agentHostSessionSearch.js'; +import { IAgentHostSessionSearchIndex } from './agentHostSessionSearchIndex.js'; +import { validateSessionSemanticRequest, type ISessionSemanticRequest, type ISessionSemanticResult } from '../common/sessionSemanticSearch.js'; import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; import { omitTransientSessionConfigValues, SessionConfigKey } from '../common/sessionConfigKeys.js'; @@ -621,6 +625,7 @@ export class AgentService extends Disposable implements IAgentService { @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, @IAgentHostStorageService private readonly _storageService: IAgentHostStorageService, + @IAgentHostSessionSearchIndex private readonly _sessionSearchIndex: IAgentHostSessionSearchIndex, ) { super(); this._authService = core.authenticationService; @@ -2105,6 +2110,49 @@ export class AgentService extends Disposable implements IAgentService { return [...await inFlight.trailing]; } + async searchSessionHistory(session: URI, query: string): Promise { + if (!query.trim() || query.length > MAX_SESSION_SEARCH_QUERY_LENGTH) { + throw new Error(`Search query must contain 1 to ${MAX_SESSION_SEARCH_QUERY_LENGTH} characters`); + } + const { provider, chats } = await this._getSearchSessionChats(session); + return searchSessionChats(session, chats, query, provider, this._stateManager, this._sessionDataService); + } + + async sessionSemanticSearch(session: URI, request: ISessionSemanticRequest): Promise { + validateSessionSemanticRequest(request); + const { chats } = await this._getSearchSessionChats(session); + const chatUris = chats.filter(chat => chat.origin?.kind !== ChatOriginKind.Tool && !isSubagentChatUri(chat.uri)).map(chat => { + if (parseRequiredSessionUriFromChatUri(chat.uri) !== session.toString()) { + throw new Error('Persisted search chat does not belong to its session'); + } + return chat.uri; + }); + const result = await this._sessionSearchIndex.semanticSearch(session.toString(), chatUris, request); + return result.kind === 'search' + ? { ...result, matches: await remapSessionSearchMatches(session, result.matches, this._stateManager, this._sessionDataService) } + : result; + } + + private async _getSearchSessionChats(session: URI): Promise<{ provider: IAgent; chats: IPersistedPeerChat[] }> { + if (!(await this._sessionRegistry.listSessionKeys()).has(session.toString())) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, 'Session no longer exists'); + } + const provider = this._providerService.getProviderForSession(session); + if (!provider?.searchChatHistory) { + throw new Error('This provider does not support persisted conversation search'); + } + await this._peerChatCatalogWrites.get(session.toString()); + await this._defaultChatBackingWrites.get(session.toString()); + const peers = await this._readPersistedPeerChatCatalog(session, true) + ?? (await provider.listLegacyChatBackings?.(session))?.map(entry => ({ uri: entry.uri.toString(), providerData: entry.providerData })) + ?? []; + const chats: IPersistedPeerChat[] = [ + { uri: buildDefaultChatUri(session), providerData: await this._readDefaultChatProviderData(session) }, + ...peers, + ]; + return { provider, chats }; + } + /** * Enumerates only the persisted facts needed by merged-session cleanup. * This deliberately avoids provider metadata, transcript materialization, @@ -6301,7 +6349,7 @@ export class AgentService extends Disposable implements IAgentService { * An empty array means the session is known to have no peer chats, so * migration is skipped. */ - private async _readPersistedPeerChatCatalog(session: URI): Promise { + private async _readPersistedPeerChatCatalog(session: URI, strict = false): Promise { const ref = await this._sessionDataService.tryOpenDatabase?.(session); if (!ref) { return undefined; @@ -6313,9 +6361,15 @@ export class AgentService extends Disposable implements IAgentService { } const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) { + if (strict) { + throw new Error('Malformed persisted peer-chat catalog'); + } this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}`); return undefined; } + if (strict && parsed.some(entry => !isRecord(entry) || typeof entry.uri !== 'string' || (entry.providerData !== undefined && typeof entry.providerData !== 'string'))) { + throw new Error('Malformed persisted peer-chat catalog entry'); + } return parsed .filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string') .map(entry => ({ @@ -6325,6 +6379,9 @@ export class AgentService extends Disposable implements IAgentService { ...(typeof entry.inheritedTurnId === 'string' ? { inheritedTurnId: entry.inheritedTurnId } : {}), })); } catch (err) { + if (strict) { + throw err; + } this._logService.warn(`[AgentService] Failed to read peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); return undefined; } finally { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 2563b9a0d7fc13..26474ba7cd8e0d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -44,6 +44,9 @@ import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliCo import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, agentHostProxyConfigSchema, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; +import type { IAgentChatSearchResult } from '../../common/agentHostSessionSearch.js'; +import { searchCopilotSessionHistory } from './copilotSessionSearch.js'; +import { IAgentHostSessionSearchIndex } from '../agentHostSessionSearchIndex.js'; import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel } from '../../common/autoModeTiers.js'; @@ -944,6 +947,7 @@ export class CopilotAgent extends Disposable implements IAgent { @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @IAgentHostSessionSearchIndex private readonly _sessionSearchIndex: IAgentHostSessionSearchIndex, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentHostSessionTitleSignal sessionTitleSignal: IAgentHostSessionTitleSignal, @@ -3256,6 +3260,30 @@ export class CopilotAgent extends Disposable implements IAgent { getMessages: (chat: URI, context: URI | IAgentChatContext): Promise => this._getChatMessages(chat, context), }; + async searchChatHistory(chat: URI, context: IAgentChatContext, providerData: string | undefined, query: string): Promise { + const persisted = providerData === undefined ? undefined : decodeProviderData(providerData); + if (providerData !== undefined && !persisted) { + throw new Error('Cannot search a chat with invalid Copilot backing data'); + } + const sdkSessionId = this._chatBackings.get(chat.toString())?.sdkSessionId + ?? persisted?.sdkSessionId + ?? (isEqual(context.resource, context.configurationResource) ? AgentSession.id(context.resource) : undefined); + if (!sdkSessionId) { + throw new Error('Cannot search a chat without a persisted Copilot backing'); + } + if (this._provisionalSessions.get(AgentSession.id(context.configurationResource))?.chat.toString() === chat.toString()) { + return { matches: [], hasMore: false }; + } + const client = await this._ensureClient(); + return searchCopilotSessionHistory(this._sessionSearchIndex, { + harness: this.id, + sessionUri: context.configurationResource.toString(), + chatUri: chat.toString(), + storageUri: context.resource.toString(), + sourceKey: sdkSessionId, + }, query, options => client.rpc.sessions.readPersistedEvents({ sessionId: sdkSessionId, ...options })); + } + getTurnDiagnosticSnapshot(chat: URI, turnId: string): IAgentTurnDiagnosticSnapshot { const session = this._findChatByUri(chat); if (!session) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionSearch.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionSearch.ts new file mode 100644 index 00000000000000..a98912144685ea --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionSearch.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { JsonValue, SessionEvent } from '@github/copilot-sdk'; +import type { IAgentChatSearchResult } from '../../common/agentHostSessionSearch.js'; +import type { IAgentHostSessionSearchIndex } from '../agentHostSessionSearchIndex.js'; +import type { ISessionSearchChat, ISessionSearchDocument } from '../sessionSearchDatabase.js'; +import { getTaskCompleteMarkdown, isTaskCompleteTool } from './copilotToolDisplay.js'; +import { isSyntheticUserMessage, stripPromptScaffolding } from './mapSessionEvents.js'; + +// EventsReadResult is not exported from the SDK's public entry point. +interface IEventsReadResult { + readonly events: readonly SessionEvent[]; + readonly cursor: string; + readonly hasMore: boolean; + readonly cursorStatus: 'ok' | 'expired'; +} + +interface IEventsReadOptions { + readonly cursor?: string; + readonly direction: 'forward' | 'backward'; + readonly max: number; +} + +type ReadPage = (options: IEventsReadOptions) => Promise; +const pageSize = 500; + +/** Adapts persisted Copilot events to the shared search cache without resuming a conversation. */ +export function searchCopilotSessionHistory(index: IAgentHostSessionSearchIndex, chat: ISessionSearchChat, query: string, readPage: ReadPage): Promise { + return index.searchChat(chat, query, async () => { + const tail = await readPage({ direction: 'backward', max: 1 }); + if (tail.cursorStatus === 'expired') { + throw new Error('Persisted conversation changed during search indexing'); + } + const revision = tail.events.at(-1)?.id ?? ''; + return { revision, documents: readDocuments(revision, readPage) }; + }); +} + +async function* readDocuments(revision: string, readPage: ReadPage): AsyncIterable { + if (!revision) { + return; + } + let turnId: string | undefined; + let cursor: string | undefined; + const completedTools = new Set(); + const document = (role: ISessionSearchDocument['role'], text: string | undefined, sourceLocator: string): ISessionSearchDocument | undefined => + turnId && text?.trim() ? { turnId, role, text, sourceLocator } : undefined; + const completion = (toolCallId: string, name: string, args: JsonValue | undefined): ISessionSearchDocument | undefined => { + if (!isTaskCompleteTool(name) || completedTools.has(toolCallId)) { + return undefined; + } + const parameters = args && typeof args === 'object' && !Array.isArray(args) && typeof args.summary === 'string' ? { summary: args.summary } : undefined; + const result = document('assistant', getTaskCompleteMarkdown(parameters, undefined), `tool:${toolCallId}`); + if (result) { + completedTools.add(toolCallId); + } + return result; + }; + while (true) { + const page = await readPage({ cursor, direction: 'forward', max: pageSize }); + if (page.cursorStatus === 'expired') { + throw new Error('Persisted conversation changed during search indexing'); + } + for (const event of page.events) { + if (!event.agentId && !event.ephemeral) { + switch (event.type) { + case 'user.message': + if (!isSyntheticUserMessage(event)) { + turnId = event.id ?? event.data.interactionId; + completedTools.clear(); + const message = document('user', stripPromptScaffolding(event.data.content ?? ''), `message:${event.id}`); + if (message) { + yield message; + } + } + break; + case 'assistant.message': + if (!event.data.parentToolCallId) { + turnId ??= event.id ?? event.data.messageId; + const message = document('assistant', event.data.content, `message:${event.id}`); + if (message) { + yield message; + } + for (const request of event.data.toolRequests ?? []) { + const summary = completion(request.toolCallId, request.name, request.arguments); + if (summary) { + yield summary; + } + } + } + break; + case 'tool.execution_start': + if (!event.data.parentToolCallId) { + const summary = completion(event.data.toolCallId, event.data.toolName, event.data.arguments); + if (summary) { + yield summary; + } + } + break; + } + } + // Capture a finite snapshot even when new events keep arriving. + if (event.id === revision) { + return; + } + } + if (!page.hasMore || !page.cursor || page.cursor === cursor) { + throw new Error('Persisted conversation tail was not found during search indexing'); + } + cursor = page.cursor; + } +} diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 01c1b56ad66d6f..7a6d079a4a73bc 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -47,7 +47,7 @@ function resolveToolDisplayPath(path: string, workingDirectory: URI | undefined) * persisted before `source` existed will not be filtered; that is accepted * leakage rather than guessed-at content sniffing. */ -function isSyntheticUserMessage(event: SessionEvent): boolean { +export function isSyntheticUserMessage(event: SessionEvent): boolean { if (event.type !== 'user.message') { return false; } @@ -64,7 +64,7 @@ function isSyntheticUserMessage(event: SessionEvent): boolean { * leaves nothing — content that is only a `` wrapper — we fall * back to the wrapper's inner text so the message is not lost. */ -function stripPromptScaffolding(text: string): string { +export function stripPromptScaffolding(text: string): string { const withoutAux = text .replace(/[\s\S]*?<\/reminder>\s*/g, '') .replace(/[\s\S]*?<\/system[-_]reminder>\s*/g, '') diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index f575aa78411ea9..b839de2604758e 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -21,7 +21,9 @@ import { AgentSession, type IAgentCreateChatRequestOptions, type IMcpNotificatio import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { isAnnotationsUri } from '../common/annotationsUri.js'; import { type IAgentService } from '../common/agentService.js'; -import { ClaimAgentHostDetachedWorktreeExtensionMethod, collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, getAgentHostExtensionInitializeResultMeta, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, removeSessionArtifactParamsValidator, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; +import { ClaimAgentHostDetachedWorktreeExtensionMethod, collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, getAgentHostExtensionInitializeResultMeta, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, removeSessionArtifactParamsValidator, RequestAgentHostWorkspaceTrustExtensionMethod, SearchSessionHistoryExtensionMethod, searchSessionHistoryParamsValidator, SessionSemanticSearchExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; +import { MAX_SESSION_SEARCH_QUERY_LENGTH } from '../common/agentHostSessionSearch.js'; +import { validateSessionSemanticRequest } from '../common/sessionSemanticSearch.js'; import { isAgentDevContainerWorktreeHandle } from '../common/meta/agentDevContainerWorktreeMeta.js'; import { isActionEnvelopeRelevantToSubscriptionUris } from '../common/state/agentSubscription.js'; import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js'; @@ -679,7 +681,11 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien const response: IAgentHostExtensionInitializeResult = { protocolVersion: negotiated, serverSeq: this._stateManager.serverSeq, - _meta: getAgentHostExtensionInitializeResultMeta(this._config.allowExtensionMethods !== false && !!this._agentService.removeSessionArtifact), + _meta: getAgentHostExtensionInitializeResultMeta( + this._config.allowExtensionMethods !== false && !!this._agentService.removeSessionArtifact, + this._config.allowExtensionMethods !== false && !!this._agentService.searchSessionHistory, + this._config.allowExtensionMethods !== false && !!this._agentService.sessionSemanticSearch, + ), snapshots, defaultDirectory: this._config.defaultDirectory, completionTriggerCharacters: this._config.completionTriggerCharacters ? [...this._config.completionTriggerCharacters] : undefined, @@ -1867,6 +1873,49 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } switch (method) { + case SessionSemanticSearchExtensionMethod: { + if (!this._agentService.sessionSemanticSearch) { + return undefined; + } + if (!isParamsObject(params) || typeof params.session !== 'string') { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a URI string')); + } + try { + const session = URI.parse(params.session, true); + if (!AgentSession.provider(session) || !session.path.startsWith('/') || session.path.length < 2 + || session.authority || session.query || session.fragment || parseChatUri(session)) { + throw new Error('session must be an Agent Session URI'); + } + validateSessionSemanticRequest(params.request); + return this._agentService.sessionSemanticSearch(session, params.request); + } catch { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Invalid semantic search request')); + } + } + case SearchSessionHistoryExtensionMethod: { + if (!this._agentService.searchSessionHistory) { + return undefined; + } + const validated = searchSessionHistoryParamsValidator.validate(params); + if (validated.error) { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, validated.error.message)); + } + const { session: resource, query } = validated.content; + if (!query.trim() || query.length > MAX_SESSION_SEARCH_QUERY_LENGTH) { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, `query must contain 1 to ${MAX_SESSION_SEARCH_QUERY_LENGTH} characters`)); + } + let session: URI; + try { + session = URI.parse(resource, true); + } catch { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a valid URI string')); + } + if (!AgentSession.provider(session) || !session.path.startsWith('/') || session.path.length < 2 + || session.authority || session.query || session.fragment || parseChatUri(session)) { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be an Agent Session URI')); + } + return this._agentService.searchSessionHistory(session, query); + } case 'shutdown': return this._agentService.shutdown(); case 'getNetworkDiagnosticsInfo': diff --git a/src/vs/platform/agentHost/node/sessionSearchDatabase.ts b/src/vs/platform/agentHost/node/sessionSearchDatabase.ts new file mode 100644 index 00000000000000..7fad0eeff0a157 --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionSearchDatabase.ts @@ -0,0 +1,587 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Database } from '@vscode/sqlite3'; +import { createHash } from 'crypto'; +import { chmod, lstat, mkdir, open } from 'fs/promises'; +import { SequencerByKey } from '../../../base/common/async.js'; +import { dirname, resolve } from '../../../base/common/path.js'; +import { isWindows } from '../../../base/common/platform.js'; +import { hasKey } from '../../../base/common/types.js'; +import { AGENT_CHAT_SEARCH_MAX_RESULTS, getAgentSessionSearchTerms, type IAgentChatSearchResult } from '../common/agentHostSessionSearch.js'; +import { MAX_SESSION_EMBEDDING_BATCH, MAX_SESSION_EMBEDDING_DIMENSIONS, validateSessionSemanticRequest, type ISessionEmbeddingModel, type ISessionEmbeddingValue, type ISessionSemanticMatch, type ISessionSemanticRequest, type ISessionSemanticResult } from '../common/sessionSemanticSearch.js'; + +export interface ISessionSearchChat { + readonly harness: string; + readonly sessionUri: string; + readonly chatUri: string; + readonly storageUri: string; + readonly sourceKey: string; +} + +export interface ISessionSearchDocument { + readonly turnId: string; + readonly role: 'user' | 'assistant'; + readonly sourceLocator: string; + readonly text: string; +} + +export interface ISessionSearchSource { + readonly revision: string; + readonly documents: AsyncIterable; +} + +const operations = new SequencerByKey(); +const applicationId = 0x56535343; // VSSC: VS Code session search cache. +const schemaVersion = 2; +const indexVersion = 1; +const snippetLength = 220; +const chunkLength = 1500; +const chunkOverlap = 200; +const minimumSemanticScore = 0.25; +const matchStart = '\uFDD0'; +const matchEnd = '\uFDD1'; +const semanticScope = 'c.session_uri = ? AND c.chat_uri IN (SELECT value FROM json_each(?))'; + +interface IChatRow { + readonly id: number; + readonly session_uri: string; + readonly storage_uri: string; + readonly source_key: string; + readonly revision: string; + readonly index_version: number; +} + +interface IMatchRow { + readonly turnId: string; + readonly role: number; + readonly snippet: string; +} + +interface IChunkRow { + readonly id: number; + readonly document_id: number; + readonly start_offset: number; + readonly end_offset: number; + readonly content_hash: string; +} + +interface IVectorRow extends IChunkRow { + readonly chat: string; + readonly turnId: string; + readonly role: number; + readonly vector: Buffer | null; +} + +interface IRankedChunk { + readonly row: IVectorRow; + readonly score: number; +} + +/** A rebuildable, provider-neutral cache; connections live only for the duration of queued operations. */ +export class SessionSearchDatabase { + private readonly databasePath: string; + + constructor(databasePath: string) { + this.databasePath = resolve(databasePath); + } + + async searchChat(chat: ISessionSearchChat, query: string, readSource: () => Promise): Promise { + const terms = getAgentSessionSearchTerms(query); + if (!terms.length) { + return { matches: [], hasMore: false }; + } + return operations.queue(this.databasePath, async () => { + const source = await readSource(); + const db = await this.openDatabase(true); + try { + const [cached] = await all(db, 'SELECT * FROM search_chats WHERE harness = ? AND chat_uri = ?', [chat.harness, chat.chatUri]); + const chatId = cached && cached.source_key === chat.sourceKey && cached.revision === source.revision + && cached.index_version === indexVersion && cached.session_uri === chat.sessionUri && cached.storage_uri === chat.storageUri + ? cached.id + : await rebuild(db, chat, source); + const expression = terms.map(term => `"${term.replace(/"/g, '""')}"`).join(' AND '); + const rows = await all(db, ` + SELECT t.turn_ref AS turnId, d.role, snippet(search_fts, 0, ?, ?, '…', 32) AS snippet + FROM search_fts + JOIN search_documents d ON d.id = search_fts.rowid + JOIN search_turns t ON t.id = d.turn_id + WHERE t.chat_id = ? AND search_fts MATCH ? + ORDER BY bm25(search_fts), search_fts.rowid + LIMIT ? + `, [matchStart, matchEnd, chatId, expression, AGENT_CHAT_SEARCH_MAX_RESULTS + 1]); + return { + matches: rows.slice(0, AGENT_CHAT_SEARCH_MAX_RESULTS).map(row => ({ + turnId: row.turnId, + role: row.role === 0 ? 'user' : 'assistant', + snippet: trimSnippet(row.snippet), + })), + hasMore: rows.length > AGENT_CHAT_SEARCH_MAX_RESULTS, + }; + } finally { + await close(db); + } + }); + } + + async deleteScope(resource: string): Promise { + return operations.queue(this.databasePath, async () => { + const db = await this.openDatabase(false); + if (!db) { + return; + } + try { + await run(db, 'DELETE FROM search_chats WHERE storage_uri = ? OR session_uri = ?', [resource, resource]); + } finally { + await close(db); + } + }); + } + + /** Embeddings are supplied by the caller; this cache never invokes a model or reads provider storage. */ + async semanticSearch(sessionUri: string, chatUris: readonly string[], request: ISessionSemanticRequest): Promise { + validateSessionSemanticRequest(request); + const model = { ...request.model }; + const snapshot: ISessionSemanticRequest = request.kind === 'store' + ? { kind: 'store', model, values: request.values.map(value => ({ ...value, vector: [...value.vector] })) } + : request.kind === 'search' ? { kind: 'search', model, vector: [...request.vector] } : { kind: 'pending', model }; + const scope = [sessionUri, JSON.stringify(chatUris)]; + const hasAllowedChats = chatUris.length > 0; + return operations.queue(this.databasePath, async () => { + const db = hasAllowedChats ? await this.openDatabase(false) : undefined; + if (!db) { + switch (snapshot.kind) { + case 'pending': return { kind: 'pending', chunks: [], hasMore: false }; + case 'search': return { kind: 'search', matches: [], hasMore: false, incomplete: hasAllowedChats }; + case 'store': + if (snapshot.values.length) { + throw new Error('Session embedding chunk is stale or outside the allowed chats'); + } + return { kind: 'store' }; + } + } + try { + switch (snapshot.kind) { + case 'pending': return await getPendingChunks(db, scope, snapshot.model); + case 'store': + await storeEmbeddings(db, scope, snapshot.model, snapshot.values); + return { kind: 'store' }; + case 'search': return await searchEmbeddings(db, scope, snapshot.model, snapshot.vector); + } + } finally { + await close(db); + } + }); + } + + async whenIdle(): Promise { + await operations.queue(this.databasePath, async () => { }); + } + + private async openDatabase(create: true): Promise; + private async openDatabase(create: false): Promise; + private async openDatabase(create: boolean): Promise { + try { + await lstat(this.databasePath); + } catch (error) { + if (!isMissing(error)) { + throw error; + } + if (!create) { + return undefined; + } + await mkdir(dirname(this.databasePath), { recursive: true, mode: 0o700 }); + try { + const file = await open(this.databasePath, 'wx', 0o600); + await file.close(); + } catch (error) { + if (!(hasKey(error, { code: true }) && error.code === 'EEXIST')) { + throw error; + } + } + } + if (!(await lstat(this.databasePath)).isFile()) { + throw new Error('Session search cache must be a regular file'); + } + + // Inspect read-only first, so even recovery of a foreign database's journal cannot write to it. + const inspection = await connect(this.databasePath, true); + try { + await checkOwnership(inspection); + } finally { + await close(inspection); + } + const db = await connect(this.databasePath, false); + try { + db.configure('busyTimeout', 5000); + await initialize(db); + if (!isWindows) { + await chmod(this.databasePath, 0o600); + } + return db; + } catch (error) { + await close(db); + throw error; + } + } +} + +async function checkOwnership(db: Database): Promise { + const [{ application_id }] = await all<{ application_id: number }>(db, 'PRAGMA application_id'); + const [{ user_version }] = await all<{ user_version: number }>(db, 'PRAGMA user_version'); + if (application_id === applicationId && (user_version === 1 || user_version === schemaVersion)) { + return user_version; + } + const [{ count }] = await all<{ count: number }>(db, 'SELECT count(*) AS count FROM sqlite_master'); + if ((application_id === 0 || application_id === applicationId) && user_version === 0 && count === 0) { + return 0; + } + throw new Error('Refusing to modify an unrecognized or unsupported session search database'); +} + +async function initialize(db: Database): Promise { + await exec(db, 'PRAGMA foreign_keys = ON'); + if (await checkOwnership(db) === schemaVersion) { + return; + } + await exec(db, 'BEGIN IMMEDIATE'); + try { + const version = await checkOwnership(db); + if (version === 0) { + await exec(db, ` + CREATE TABLE search_chats ( + id INTEGER PRIMARY KEY, + harness TEXT NOT NULL, + session_uri TEXT NOT NULL, + chat_uri TEXT NOT NULL, + storage_uri TEXT NOT NULL, + source_key TEXT NOT NULL, + revision TEXT NOT NULL, + index_version INTEGER NOT NULL, + UNIQUE(harness, chat_uri) + ); + CREATE INDEX search_chats_session ON search_chats(session_uri); + CREATE INDEX search_chats_storage ON search_chats(storage_uri); + CREATE TABLE search_turns ( + id INTEGER PRIMARY KEY, + chat_id INTEGER NOT NULL REFERENCES search_chats(id) ON DELETE CASCADE, + turn_ref TEXT NOT NULL, + UNIQUE(chat_id, turn_ref) + ); + CREATE TABLE search_documents ( + id INTEGER PRIMARY KEY, + turn_id INTEGER NOT NULL REFERENCES search_turns(id) ON DELETE CASCADE, + role INTEGER NOT NULL CHECK(role IN (0, 1)), + source_locator TEXT NOT NULL + ); + CREATE INDEX search_documents_turn ON search_documents(turn_id); + CREATE VIRTUAL TABLE search_fts USING fts5(text); + CREATE TRIGGER search_documents_delete AFTER DELETE ON search_documents BEGIN + DELETE FROM search_fts WHERE rowid = old.id; + END; + PRAGMA application_id = ${applicationId}; + `); + } + if (version < 2) { + await exec(db, ` + CREATE TABLE search_chunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id INTEGER NOT NULL REFERENCES search_documents(id) ON DELETE CASCADE, + start_offset INTEGER NOT NULL CHECK(start_offset >= 0), + end_offset INTEGER NOT NULL CHECK(end_offset > start_offset), + content_hash TEXT NOT NULL, + UNIQUE(document_id, start_offset) + ); + CREATE TABLE search_embeddings ( + model_id TEXT NOT NULL, + dimensions INTEGER NOT NULL CHECK(dimensions BETWEEN 1 AND ${MAX_SESSION_EMBEDDING_DIMENSIONS}), + chunk_id INTEGER NOT NULL REFERENCES search_chunks(id) ON DELETE CASCADE, + vector BLOB NOT NULL CHECK(length(vector) = dimensions * 4), + PRIMARY KEY(model_id, dimensions, chunk_id) + ); + CREATE INDEX search_embeddings_chunk ON search_embeddings(chunk_id); + PRAGMA user_version = ${schemaVersion}; + `); + } + await exec(db, 'COMMIT'); + } catch (error) { + await exec(db, 'ROLLBACK'); + throw error; + } +} + +async function getPendingChunks(db: Database, scope: string[], model: ISessionEmbeddingModel): Promise { + await exec(db, 'BEGIN IMMEDIATE'); + try { + const documents = await all<{ id: number; text: string }>(db, ` + SELECT d.id, f.text FROM search_documents d + JOIN search_turns t ON t.id = d.turn_id JOIN search_chats c ON c.id = t.chat_id + JOIN search_fts f ON f.rowid = d.id + WHERE ${semanticScope} AND NOT EXISTS (SELECT 1 FROM search_chunks k WHERE k.document_id = d.id) + ORDER BY d.id + `, scope); + for (const document of documents) { + for (const [start, end] of chunkOffsets(document.text)) { + const hash = createHash('sha256').update(document.text.slice(start, end)).digest('hex'); + await run(db, 'INSERT INTO search_chunks (document_id, start_offset, end_offset, content_hash) VALUES (?, ?, ?, ?)', [document.id, start, end, hash]); + } + } + const rows = await all(db, ` + SELECT k.* FROM search_chunks k + JOIN search_documents d ON d.id = k.document_id + JOIN search_turns t ON t.id = d.turn_id JOIN search_chats c ON c.id = t.chat_id + WHERE ${semanticScope} AND NOT EXISTS ( + SELECT 1 FROM search_embeddings e WHERE e.chunk_id = k.id AND e.model_id = ? AND e.dimensions = ? + ) + ORDER BY k.id LIMIT ? + `, [...scope, model.id, model.dimensions, MAX_SESSION_EMBEDDING_BATCH + 1]); + const selected = rows.slice(0, MAX_SESSION_EMBEDDING_BATCH); + const texts = await readChunkDocuments(db, selected); + await exec(db, 'COMMIT'); + return { + kind: 'pending', + chunks: selected.map(row => ({ + id: row.id, contentHash: row.content_hash, + text: texts.get(row.document_id)!.slice(row.start_offset, row.end_offset), + })), + hasMore: rows.length > MAX_SESSION_EMBEDDING_BATCH, + }; + } catch (error) { + await exec(db, 'ROLLBACK'); + throw error; + } +} + +/** Offsets use JavaScript UTF-16 indexing; bounded windows keep splitting linear even for huge tokens. */ +function* chunkOffsets(text: string): Iterable { + let start = 0; + while (start < text.length) { + let end = Math.min(start + chunkLength, text.length); + if (end < text.length) { + const boundaryStart = start + Math.floor(chunkLength / 2); + const window = text.slice(boundaryStart, end); + const paragraph = window.lastIndexOf('\n\n'); + if (paragraph >= 0) { + end = boundaryStart + paragraph + 2; + } else { + for (let index = window.length - 1; index >= 0; index--) { + if (/\s/.test(window[index])) { + end = boundaryStart + index + 1; + break; + } + } + } + if (isLowSurrogate(text.charCodeAt(end))) { + end--; + } + } + yield [start, end]; + if (end === text.length) { + return; + } + start = end - chunkOverlap; + if (isLowSurrogate(text.charCodeAt(start))) { + start--; + } + } +} + +function isLowSurrogate(code: number): boolean { + return code >= 0xDC00 && code <= 0xDFFF; +} + +async function storeEmbeddings(db: Database, scope: string[], model: ISessionEmbeddingModel, values: readonly ISessionEmbeddingValue[]): Promise { + await exec(db, 'BEGIN IMMEDIATE'); + try { + for (const value of values) { + const rows = await all<{ id: number }>(db, ` + SELECT k.id FROM search_chunks k JOIN search_documents d ON d.id = k.document_id + JOIN search_turns t ON t.id = d.turn_id JOIN search_chats c ON c.id = t.chat_id + WHERE ${semanticScope} AND k.id = ? AND k.content_hash = ? + `, [...scope, value.id, value.contentHash]); + if (!rows.length) { + throw new Error('Session embedding chunk is stale or outside the allowed chats'); + } + await run(db, ` + INSERT INTO search_embeddings (model_id, dimensions, chunk_id, vector) VALUES (?, ?, ?, ?) + ON CONFLICT(model_id, dimensions, chunk_id) DO UPDATE SET vector = excluded.vector + `, [model.id, model.dimensions, value.id, normalizeVector(value.vector)]); + } + await exec(db, 'COMMIT'); + } catch (error) { + await exec(db, 'ROLLBACK'); + throw error; + } +} + +function normalizeVector(vector: readonly number[]): Buffer { + let maximum = 0; + for (const value of vector) { + maximum = Math.max(maximum, Math.abs(value)); + } + let squares = 0; + for (const value of vector) { + squares += (value / maximum) ** 2; + } + const norm = Math.sqrt(squares); + const result = Buffer.alloc(vector.length * 4); + for (let index = 0; index < vector.length; index++) { + result.writeFloatLE((vector[index] / maximum) / norm, index * 4); + } + return result; +} + +async function searchEmbeddings(db: Database, scope: string[], model: ISessionEmbeddingModel, vector: readonly number[]): Promise { + const queryVector = normalizeVector(vector); + const [{ missing }] = await all<{ missing: number }>(db, ` + SELECT EXISTS ( + SELECT 1 FROM json_each(?) allowed + WHERE NOT EXISTS (SELECT 1 FROM search_chats c WHERE c.chat_uri = allowed.value AND c.session_uri = ?) + ) OR EXISTS ( + SELECT 1 FROM search_documents d + JOIN search_turns t ON t.id = d.turn_id JOIN search_chats c ON c.id = t.chat_id + WHERE ${semanticScope} AND NOT EXISTS (SELECT 1 FROM search_chunks k WHERE k.document_id = d.id) + ) AS missing + `, [scope[1], scope[0], ...scope]); + let incomplete = !!missing; + const rows = await all(db, ` + SELECT k.*, c.chat_uri AS chat, t.turn_ref AS turnId, d.role, e.vector + FROM search_chunks k JOIN search_documents d ON d.id = k.document_id + JOIN search_turns t ON t.id = d.turn_id JOIN search_chats c ON c.id = t.chat_id + LEFT JOIN search_embeddings e ON e.chunk_id = k.id AND e.model_id = ? AND e.dimensions = ? + WHERE ${semanticScope} + ORDER BY k.id + `, [model.id, model.dimensions, ...scope]); + const ranked: IRankedChunk[] = []; + for (const row of rows) { + if (!row.vector || row.vector.length !== queryVector.length) { + incomplete = true; + continue; + } + let score = 0; + for (let index = 0; index < queryVector.length; index += 4) { + score += row.vector.readFloatLE(index) * queryVector.readFloatLE(index); + } + if (!Number.isFinite(score)) { + incomplete = true; + continue; + } + if (score < minimumSemanticScore) { + continue; + } + score = Math.min(1, score); + const duplicate = ranked.findIndex(candidate => candidate.row.chat === row.chat && candidate.row.turnId === row.turnId && candidate.row.role === row.role); + if (duplicate >= 0) { + if (ranked[duplicate].score >= score) { + continue; + } + ranked.splice(duplicate, 1); + } + const position = ranked.findIndex(candidate => candidate.score < score); + ranked.splice(position < 0 ? ranked.length : position, 0, { row, score }); + if (ranked.length > AGENT_CHAT_SEARCH_MAX_RESULTS + 1) { + ranked.pop(); + } + } + const selected = ranked.slice(0, AGENT_CHAT_SEARCH_MAX_RESULTS); + const texts = await readChunkDocuments(db, selected.map(candidate => candidate.row)); + const matches: ISessionSemanticMatch[] = selected.map(({ row, score }) => ({ + chat: row.chat, + turnId: row.turnId, + role: row.role === 0 ? 'user' : 'assistant', + snippet: trimSnippet(texts.get(row.document_id)!.slice(row.start_offset, row.end_offset)), + score, + })); + return { kind: 'search', matches, hasMore: ranked.length > AGENT_CHAT_SEARCH_MAX_RESULTS, incomplete }; +} + +/** Read each full FTS document once rather than repeating it for every overlapping chunk. */ +async function readChunkDocuments(db: Database, chunks: readonly IChunkRow[]): Promise> { + if (!chunks.length) { + return new Map(); + } + const rows = await all<{ id: number; text: string }>(db, ` + SELECT rowid AS id, text FROM search_fts WHERE rowid IN (SELECT value FROM json_each(?)) + `, [JSON.stringify([...new Set(chunks.map(chunk => chunk.document_id))])]); + return new Map(rows.map(row => [row.id, row.text])); +} + +async function rebuild(db: Database, chat: ISessionSearchChat, source: ISessionSearchSource): Promise { + await exec(db, 'BEGIN IMMEDIATE'); + try { + const [{ id: chatId }] = await all<{ id: number }>(db, ` + INSERT INTO search_chats (harness, session_uri, chat_uri, storage_uri, source_key, revision, index_version) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(harness, chat_uri) DO UPDATE SET + session_uri = excluded.session_uri, storage_uri = excluded.storage_uri, source_key = excluded.source_key, + revision = excluded.revision, index_version = excluded.index_version + RETURNING id + `, [chat.harness, chat.sessionUri, chat.chatUri, chat.storageUri, chat.sourceKey, source.revision, indexVersion]); + await run(db, 'DELETE FROM search_turns WHERE chat_id = ?', [chatId]); + if (source.revision) { + for await (const document of source.documents) { + if (!document.text.trim()) { + continue; + } + const [{ id: turnId }] = await all<{ id: number }>(db, ` + INSERT INTO search_turns (chat_id, turn_ref) VALUES (?, ?) + ON CONFLICT(chat_id, turn_ref) DO UPDATE SET turn_ref = excluded.turn_ref RETURNING id + `, [chatId, document.turnId]); + const [{ id: documentId }] = await all<{ id: number }>(db, ` + INSERT INTO search_documents (turn_id, role, source_locator) VALUES (?, ?, ?) RETURNING id + `, [turnId, document.role === 'user' ? 0 : 1, document.sourceLocator]); + await run(db, 'INSERT INTO search_fts (rowid, text) VALUES (?, ?)', [documentId, document.text]); + } + } + await exec(db, 'COMMIT'); + return chatId; + } catch (error) { + await exec(db, 'ROLLBACK'); + throw error; + } +} + +function trimSnippet(marked: string): string { + const normalized = marked.replace(/\s+/g, ' ').trim(); + const firstMatch = normalized.indexOf(matchStart); + const text = normalized.split(matchStart).join('').split(matchEnd).join(''); + if (text.length <= snippetLength) { + return text; + } + const start = Math.max(0, firstMatch - 60); + const content = text.slice(start, start + snippetLength - 2); + return `${start ? '…' : ''}${content}${start + content.length < text.length ? '…' : ''}`; +} + +function isMissing(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const filesystemError: NodeJS.ErrnoException = error; + return filesystemError.code === 'ENOENT'; +} + +async function connect(path: string, readonly: boolean): Promise { + const sqlite3 = await import('@vscode/sqlite3'); + return new Promise((resolve, reject) => { + const db = new sqlite3.default.Database(path, readonly ? sqlite3.default.OPEN_READONLY : sqlite3.default.OPEN_READWRITE, error => error ? reject(error) : resolve(db)); + }); +} + +function close(db: Database): Promise { + return new Promise((resolve, reject) => db.close(error => error ? reject(error) : resolve())); +} + +function exec(db: Database, sql: string): Promise { + return new Promise((resolve, reject) => db.exec(sql, error => error ? reject(error) : resolve())); +} + +function run(db: Database, sql: string, parameters: (string | number | Buffer)[]): Promise { + return new Promise((resolve, reject) => db.run(sql, parameters, error => error ? reject(error) : resolve())); +} + +function all(db: Database, sql: string, parameters: (string | number)[] = []): Promise { + return new Promise((resolve, reject) => db.all(sql, parameters, (error: Error | null, rows: T[]) => error ? reject(error) : resolve(rows))); +} diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index ffdd813ae47fcc..d7b04e17a7e3d1 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -1682,6 +1682,42 @@ suite('AgentHostProtocolClient', () => { await resultPromise; }); + test('searchSessionHistory requires the capability and returns chat and turn locators unchanged', async () => { + const { client, transport } = createClient(); + const session = URI.parse('copilotcli:/session-1'); + await assert.rejects(() => client.searchSessionHistory(session, 'authentication'), /does not support conversation content search/); + await connectClient(client, transport, getAgentHostExtensionInitializeResultMeta(true, true)); + transport.sentMessages.length = 0; + const pending = client.searchSessionHistory(session, 'authentication'); + const result = { + matches: [{ chat: buildChatUri(session, 'peer'), turnId: 'turn-1', role: 'assistant', snippet: 'Authentication details' }], + hasMore: false, + }; + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 2, + method: 'vscode/searchSessionHistory', + params: { session: session.toString(), query: 'authentication' }, + }); + transport.fireMessage({ jsonrpc: '2.0', id: 2, result }); + assert.deepStrictEqual(await pending, result); + }); + + test('semantic search negotiates support and sends typed operations unchanged', async () => { + const { client, transport } = createClient(); + const session = URI.parse('copilotcli:/session'); + assert.strictEqual(await client.supportsSessionSemanticSearch(), false); + await connectClient(client, transport, getAgentHostExtensionInitializeResultMeta(true, true, true)); + transport.sentMessages.length = 0; + const request = { kind: 'search' as const, model: { id: 'copilot.test', dimensions: 2 }, vector: [1, 0] }; + const pending = client.sessionSemanticSearch(session, request); + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', id: 2, method: 'vscode/sessionSemanticSearch', params: { session: session.toString(), request }, + }); + const result = { kind: 'search', matches: [], hasMore: false, incomplete: true }; + transport.fireMessage({ jsonrpc: '2.0', id: 2, result }); + assert.deepStrictEqual({ supported: await client.supportsSessionSemanticSearch(), result: await pending }, { supported: true, result }); + }); test('removeSessionArtifact propagates unsupported host errors', async () => { const { client, transport } = createClient(); const resultPromise = client.removeSessionArtifact(URI.parse('copilotcli:/session-1'), 'artifact-1'); diff --git a/src/vs/platform/agentHost/test/node/agentHostServices.test.ts b/src/vs/platform/agentHost/test/node/agentHostServices.test.ts index 50f733500dcd9b..8dc64649ed64e5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostServices.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostServices.test.ts @@ -27,6 +27,7 @@ import { IAgentHostClientConnectionService } from '../../node/agentHostClientCon import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { IAgentHostSessionSearchIndex } from '../../node/agentHostSessionSearchIndex.js'; import { NullByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; import { registerAgentHostCoreServices, registerAgentHostHostServices } from '../../node/agentHostServices.js'; import { IAgentHostWorktreeIsolation, NullAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; @@ -146,6 +147,14 @@ function assertCompleteAcyclicGraph(services: RecordingServiceCollection, extern suite('Agent Host service registrations', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('registers one profile-level search cache independently of chat storage', () => { + const services = new ServiceCollection(); + registerCoreServices(services); + const descriptor = services.get(IAgentHostSessionSearchIndex); + assert.ok(descriptor instanceof SyncDescriptor); + assert.deepStrictEqual(descriptor.staticArguments, [URI.file('/agent-host-search.db').fsPath]); + }); + test('resolves descriptors lazily and caches the instance', () => { let createCount = 0; const services = new StrictServiceCollection( diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionSearchIndex.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionSearchIndex.test.ts new file mode 100644 index 00000000000000..7439bccb1ce0fd --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostSessionSearchIndex.test.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import type { Database } from '@vscode/sqlite3'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { Emitter } from '../../../../base/common/event.js'; +import { join } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { IWillDeleteSessionDataEvent } from '../../common/sessionDataService.js'; +import { AgentHostSessionSearchIndex } from '../../node/agentHostSessionSearchIndex.js'; +import type { ISessionSearchChat, ISessionSearchSource } from '../../node/sessionSearchDatabase.js'; +import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; + +async function withDatabase(path: string, operation: (db: Database) => Promise): Promise { + const sqlite3 = await import('@vscode/sqlite3'); + const db = await new Promise((resolve, reject) => { + const database = new sqlite3.default.Database(path, error => error ? reject(error) : resolve(database)); + }); + try { + return await operation(db); + } finally { + await new Promise((resolve, reject) => db.close(error => error ? reject(error) : resolve())); + } +} + +class SearchLogService extends NullLogService { + warnings = 0; + override warn(): void { this.warnings++; } +} + +suite('Agent Host shared search index lifecycle', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + let directory: string; + const chat: ISessionSearchChat = { + harness: 'copilotcli', sessionUri: 'copilotcli:/session', chatUri: 'chat:default', + storageUri: 'copilotcli:/session', sourceKey: 'sdk-session', + }; + + setup(async () => { + directory = await mkdtemp(join(tmpdir(), 'agent-host-search-index-')); + }); + + teardown(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + function source(): ISessionSearchSource { + return { + revision: 'tail', + documents: (async function* () { + yield { turnId: 'turn', role: 'assistant' as const, sourceLocator: 'message:1', text: 'Indexed searchable content' }; + })(), + }; + } + + function createIndex() { + const deletion = disposables.add(new Emitter()); + const data = { + ...createNullSessionDataService(), + getSessionDataDir: () => URI.file(join(directory, 'history')), + onWillDeleteSessionData: deletion.event, + }; + const log = new SearchLogService(); + const databasePath = join(directory, 'search', 'search.db'); + const index = disposables.add(new AgentHostSessionSearchIndex(databasePath, data, log)); + return { index, deletion, log, databasePath, legacy: join(directory, 'history', 'session-search.db') }; + } + + async function createLegacy(path: string): Promise { + await mkdir(join(directory, 'history'), { recursive: true }); + await withDatabase(path, db => new Promise((resolve, reject) => db.exec(` + CREATE TABLE search_metadata (version INTEGER, revision TEXT, source TEXT); + CREATE VIRTUAL TABLE messages USING fts5(turn_id UNINDEXED, role UNINDEXED, content); + INSERT INTO search_metadata VALUES (1, 'old-tail', 'previous-backing'); + INSERT INTO messages VALUES ('old-turn', 'user', 'old searchable content'); + `, error => error ? reject(error) : resolve()))); + } + + test('rebuilds into the shared cache and removes only the recognized legacy search sidecar', async () => { + const { index, legacy, log } = createIndex(); + await createLegacy(legacy); + const canonical = join(directory, 'history', 'session.db'); + const provider = join(directory, 'provider.db'); + await writeFile(canonical, 'canonical session data'); + await writeFile(provider, 'provider-owned data'); + const result = await index.searchChat(chat, 'searchable', async () => source()); + assert.deepStrictEqual({ + results: result.matches.length, + legacyExists: await stat(legacy).then(() => true, error => { + if (error.code === 'ENOENT') { return false; } + throw error; + }), + canonical: await readFile(canonical, 'utf8'), + provider: await readFile(provider, 'utf8'), + warnings: log.warnings, + }, { results: 1, legacyExists: false, canonical: 'canonical session data', provider: 'provider-owned data', warnings: 0 }); + }); + + test('keeps the legacy cache if the source read or shared rebuild fails', async () => { + const { index, legacy } = createIndex(); + await createLegacy(legacy); + const before = await readFile(legacy); + await assert.rejects(index.searchChat(chat, 'searchable', async () => { throw new Error('history unavailable'); }), /history unavailable/); + assert.deepStrictEqual(await readFile(legacy), before); + }); + + test('does not remove an unrecognized database at the old cache path', async () => { + const { index, legacy, log } = createIndex(); + await mkdir(join(directory, 'history'), { recursive: true }); + await withDatabase(legacy, db => new Promise((resolve, reject) => + db.exec('CREATE TABLE foreign_history (body TEXT); INSERT INTO foreign_history VALUES (\'keep me\')', error => error ? reject(error) : resolve()))); + const before = await readFile(legacy); + await index.searchChat(chat, 'searchable', async () => source()); + assert.deepStrictEqual({ unchanged: (await readFile(legacy)).equals(before), warnings: log.warnings }, { unchanged: true, warnings: 1 }); + }); + + test('punctuation-only input neither reads history nor migrates caches', async () => { + const { index, legacy } = createIndex(); + await createLegacy(legacy); + const before = await readFile(legacy); + const result = await index.searchChat(chat, '---', async () => { throw new Error('must not read'); }); + assert.deepStrictEqual({ result, unchanged: (await readFile(legacy)).equals(before) }, { result: { matches: [], hasMore: false }, unchanged: true }); + }); + + test('session-data deletion clears shared metadata and FTS rows without touching other histories', async () => { + const { index, deletion, databasePath } = createIndex(); + await index.searchChat(chat, 'searchable', async () => source()); + const pending: Promise[] = []; + deletion.fire({ session: URI.parse(chat.sessionUri), workingDirectories: undefined, waitUntil: promise => { pending.push(promise); } }); + await Promise.all(pending); + const rows = await withDatabase(databasePath, db => new Promise<{ chats: number; documents: number; fts: number }>((resolve, reject) => + db.get('SELECT (SELECT count(*) FROM search_chats) AS chats, (SELECT count(*) FROM search_documents) AS documents, (SELECT count(*) FROM search_fts) AS fts', + (error: Error | null, row: { chats: number; documents: number; fts: number }) => error ? reject(error) : resolve(row)))); + assert.deepStrictEqual(rows, { chats: 0, documents: 0, fts: 0 }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 11dd9e3c433b02..6a0822e6cd16d4 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -29,6 +29,9 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { AgentChatMigrationDeferred, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, SubagentChatSignal, resolveAgentChatContext, type IAgent, type IAgentChatAdoptionResult, type IAgentChatContext, type IAgentChatDataChange, type IAgentChatMetadata, type IAgentChatMetadataOptions, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentLegacyChat, type IAgentMaterializeChatEvent, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agent.js'; import { IConnectionTrackerService } from '../../common/agentService.js'; +import type { IAgentChatSearchResult } from '../../common/agentHostSessionSearch.js'; +import { SessionSearchDatabase } from '../../node/sessionSearchDatabase.js'; +import type { ISessionSemanticRequest } from '../../common/sessionSemanticSearch.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostAutoArchiveMergedSessionsAfterDaysConfigKey, AgentHostAutoDeleteArchivedMergedSessionsAfterDaysConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey } from '../../common/agentHostSchema.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; @@ -3426,6 +3429,176 @@ suite('AgentService (node dispatcher)', () => { // ---- listSessions / listModels -------------------------------------- + suite('persisted conversation search', () => { + class SearchAgent extends MockAgent { + readonly searches: { chat: string; scope: string; providerData: string | undefined; query: string }[] = []; + + async searchChatHistory(chat: URI, context: IAgentChatContext, providerData: string | undefined, query: string): Promise { + this.searches.push({ chat: chat.toString(), scope: context.resource.toString(), providerData, query }); + return { matches: [{ turnId: 'sdk-user-event', role: 'assistant', snippet: 'Full response content' }], hasMore: false }; + } + + override async materializeChat(): Promise { + throw new Error('Search must not materialize a chat'); + } + + override async getSessionMessages(): Promise { + throw new Error('Search must not restore a transcript'); + } + } + + async function createColdSearchService(session: URI, data: ISessionDataService): Promise { + const registry = new TransientRegistryWriteDatabase(); + await registry.registerSession(session.toString(), { provider: AgentSession.provider(session)!, startTime: Date.now(), source: 'explicit' }, { checkTombstone: false }); + return disposables.add(createTestAgentService(new NullLogService(), fileService, data, { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, registry)); + } + + test('searches cold default and peer chats using their persisted backing and storage scope', async () => { + const data = createPerSessionDataService(); + const agent = disposables.add(new SearchAgent('copilot')); + const session = AgentSession.uri('copilot', 'search-cold'); + const svc = await createColdSearchService(session, data.service); + await createAgentSession(agent, { session }); + const defaultChat = buildDefaultChatUri(session); + const peer = buildChatUri(session, 'peer'); + const subagent = buildSubagentChatUri(session.toString(), 'tool'); + await data.database(session).setMetadata('defaultChatProviderData', 'default-backing'); + await data.database(session).setMetadata('peerChats', JSON.stringify([ + { uri: peer, providerData: 'peer-backing' }, + { uri: subagent, origin: { kind: ChatOriginKind.Tool, chat: defaultChat, toolCallId: 'tool' } }, + ])); + registerTestAgentProvider(svc, agent); + await svc.listSessions(); + const result = await svc.searchSessionHistory(session, 'content'); + assert.deepStrictEqual({ + searches: agent.searches, + result, + hydrated: !!getStateManager(svc).getSessionState(session.toString()), + sent: agent.sendMessageCalls.length, + }, { + searches: [ + { chat: defaultChat, scope: session.toString(), providerData: 'default-backing', query: 'content' }, + { chat: peer, scope: peer, providerData: 'peer-backing', query: 'content' }, + ], + result: { + matches: [ + { chat: defaultChat, turnId: 'sdk-user-event', role: 'assistant', snippet: 'Full response content' }, + { chat: peer, turnId: 'sdk-user-event', role: 'assistant', snippet: 'Full response content' }, + ], + hasMore: false, + }, + hydrated: false, + sent: 0, + }); + }); + + test('rejects missing sessions and invalid queries rather than returning no matches', async () => { + const session = AgentSession.uri('copilot', 'missing'); + await assert.rejects(() => service.searchSessionHistory(session, 'content'), /Session no longer exists/); + await assert.rejects(() => service.searchSessionHistory(session, ' '), /Search query/); + await assert.rejects(() => service.searchSessionHistory(session, 'a'.repeat(513)), /Search query/); + }); + + test('maps persisted event IDs back to loaded host turn IDs', async () => { + const data = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, data.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new SearchAgent('copilot')); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: 'copilot' }); + const chat = buildDefaultChatUri(session); + const state = getStateManager(svc); + state.dispatchServerAction(chat, { type: ActionType.ChatTurnStarted, turnId: 'live-turn', startedAt: '2026-09-15T00:00:00Z', message: { text: 'Question', origin: { kind: MessageKind.User } } }); + state.dispatchServerAction(chat, { type: ActionType.ChatTurnComplete, turnId: 'live-turn', duration: 0 }); + await data.database(session).setTurnEventId('live-turn', 'sdk-user-event'); + const result = await svc.searchSessionHistory(session, 'content'); + assert.deepStrictEqual(result.matches.map(match => ({ chat: match.chat, turnId: match.turnId })), [{ chat, turnId: 'live-turn' }]); + }); + + test('refreshes every peer before semantic retrieval even after the keyword result cap', async () => { + const data = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'search-many-peers'); + const svc = await createColdSearchService(session, data.service); + const agent = disposables.add(new class extends SearchAgent { + override async searchChatHistory(chat: URI, context: IAgentChatContext, providerData: string | undefined, query: string): Promise { + const result = await super.searchChatHistory(chat, context, providerData, query); + return { matches: Array.from({ length: 20 }, () => result.matches[0]), hasMore: false }; + } + }('copilot')); + await createAgentSession(agent, { session }); + await data.database(session).setMetadata('peerChats', JSON.stringify(Array.from({ length: 6 }, (_, index) => ({ uri: buildChatUri(session, `peer-${index}`) })))); + registerTestAgentProvider(svc, agent); + const result = await svc.searchSessionHistory(session, 'content'); + assert.deepStrictEqual({ refreshed: agent.searches.length, matches: result.matches.length, hasMore: result.hasMore }, { refreshed: 7, matches: 100, hasMore: true }); + }); + + test('semantic operations exclude stale peer chats and reconcile live turn identities', async () => { + const directory = mkdtempSync(join(tmpdir(), 'semantic-host-test-')); + const data = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, data.service, { _serviceBrand: undefined } as IProductService, + createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, URI.file(join(directory, 'storage.json')))); + const agent = disposables.add(new SearchAgent('copilot')); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: 'copilot' }); + const chat = buildDefaultChatUri(session); + const removedPeer = buildChatUri(session, 'removed-peer'); + const database = new SessionSearchDatabase(join(directory, 'agent-host-search.db')); + try { + for (const chatUri of [chat, removedPeer]) { + await database.searchChat({ + harness: 'copilot', sessionUri: session.toString(), chatUri, storageUri: session.toString(), sourceKey: chatUri, + }, 'authentication', async () => ({ + revision: 'one', + documents: (async function* () { + yield { turnId: 'sdk-user-event', role: 'assistant' as const, sourceLocator: 'source', text: 'Signing in requires a fresh access token.' }; + })(), + })); + } + const model = { id: 'copilot.test', dimensions: 2 }; + const pending = await svc.sessionSemanticSearch(session, { kind: 'pending', model }); + assert.strictEqual(pending.kind, 'pending'); + if (pending.kind !== 'pending') { throw new Error('Expected embedding chunks'); } + assert.strictEqual(pending.chunks.length, 1); + await svc.sessionSemanticSearch(session, { + kind: 'store', model, + values: pending.chunks.map(chunk => ({ id: chunk.id, contentHash: chunk.contentHash, vector: [1, 0] })), + }); + const state = getStateManager(svc); + state.dispatchServerAction(chat, { type: ActionType.ChatTurnStarted, turnId: 'live-turn', startedAt: '2026-09-15T00:00:00Z', message: { text: 'Question', origin: { kind: MessageKind.User } } }); + state.dispatchServerAction(chat, { type: ActionType.ChatTurnComplete, turnId: 'live-turn', duration: 0 }); + await data.database(session).setTurnEventId('live-turn', 'sdk-user-event'); + const result = await svc.sessionSemanticSearch(session, { kind: 'search', model, vector: [1, 0] }); + assert.strictEqual(result.kind, 'search'); + if (result.kind !== 'search') { throw new Error('Expected semantic matches'); } + assert.deepStrictEqual({ + matches: result.matches.map(match => ({ chat: match.chat, turnId: match.turnId, role: match.role })), + incomplete: result.incomplete, providerHistoryReads: agent.searches.length, + }, { matches: [{ chat, turnId: 'live-turn', role: 'assistant' }], incomplete: false, providerHistoryReads: 0 }); + } finally { + await database.whenIdle(); + await rm(directory, { recursive: true, force: true }); + } + }); + + test('semantic operations reject unregistered sessions before accessing the cache', async () => { + await assert.rejects(service.sessionSemanticSearch(AgentSession.uri('copilot', 'missing'), { + kind: 'pending', model: { id: 'copilot.test', dimensions: 2 }, + }), /Session no longer exists/); + }); + + test('surfaces corrupt peer catalogs instead of silently omitting their history', async () => { + const data = createPerSessionDataService(); + const agent = disposables.add(new SearchAgent('copilot')); + const session = AgentSession.uri('copilot', 'search-corrupt'); + const svc = await createColdSearchService(session, data.service); + await createAgentSession(agent, { session }); + await data.database(session).setMetadata('peerChats', '{}'); + registerTestAgentProvider(svc, agent); + await svc.listSessions(); + await assert.rejects(() => svc.searchSessionHistory(session, 'content'), /Malformed persisted peer-chat catalog/); + assert.deepStrictEqual(agent.searches, []); + }); + }); + suite('aggregation', () => { class TimedExternalAgent extends MockAgent { @@ -7836,6 +8009,50 @@ suite('AgentService (node dispatcher)', () => { suite('management', () => { + test('routes semantic operations through the local management channel', async () => { + const session = AgentSession.uri('copilotcli', 'semantic-session'); + const operation: ISessionSemanticRequest = { kind: 'pending', model: { id: 'copilot.test', dimensions: 2 } }; + const calls: { session: string; request: ISessionSemanticRequest }[] = []; + service.sessionSemanticSearch = async (resource, request) => { + calls.push({ session: resource.toString(), request }); + return { kind: 'pending', chunks: [], hasMore: false }; + }; + const management = new AgentHostManagementService(service, {} as IConnectionTrackerService, async () => { }, nullSessionDataService, new NullLogService()); + assert.deepStrictEqual({ + supported: await management.supportsSessionSemanticSearch(), + result: await management.sessionSemanticSearch(session, operation), calls, + }, { supported: true, result: { kind: 'pending', chunks: [], hasMore: false }, calls: [{ session: session.toString(), request: operation }] }); + }); + + test('routes conversation search through management independently of protocol extensions', async () => { + const session = AgentSession.uri('copilotcli', 'local-search'); + const result = { matches: [{ chat: buildDefaultChatUri(session), turnId: 'turn', role: 'assistant' as const, snippet: 'A saved document response' }], hasMore: false }; + const calls: { session: string; query: string }[] = []; + service.searchSessionHistory = async (resource, query) => { + calls.push({ session: resource.toString(), query }); + return result; + }; + const management = new AgentHostManagementService(service, {} as IConnectionTrackerService, async () => { }, nullSessionDataService, new NullLogService()); + assert.deepStrictEqual({ + supported: await management.supportsSessionHistorySearch(), + result: await management.searchSessionHistory(session, 'document'), + calls, + }, { + supported: true, + result, + calls: [{ session: session.toString(), query: 'document' }], + }); + }); + + test('rejects management conversation search after shutdown begins', async () => { + let searches = 0; + service.searchSessionHistory = async () => { searches++; return { matches: [], hasMore: false }; }; + const management = new AgentHostManagementService(service, {} as IConnectionTrackerService, async () => { }, nullSessionDataService, new NullLogService()); + await management.shutdown(); + await assert.rejects(() => management.searchSessionHistory(AgentSession.uri('copilotcli', 'session'), 'document'), /shutting down/); + assert.strictEqual(searches, 0); + }); + test('routes detached worktree lifecycle operations outside the local data-plane protocol', async () => { const session = AgentSession.uri('copilot', 'detached-worktree'); const worktree = URI.file('/workspace.worktrees/detached-worktree'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 6b383775bc0a7d..9a1ebbff14100d 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -58,6 +58,7 @@ import { AgentHostAuthenticationService, IAgentHostAuthenticationService } from import { IAgentHostWorktreeIsolation, NullAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentHostSessionSearchIndex, IAgentHostSessionSearchIndex } from '../../node/agentHostSessionSearchIndex.js'; import { AgentHostPromptCache, IAgentHostPromptCache } from '../../node/agentHostPromptCache.js'; import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from '../../node/agentHostSessionTitleSignal.js'; import { IAgentHostGitService, type IAddWorktreeOptions, type IBranch, type IDefaultBranch } from '../../common/agentHostGitService.js'; @@ -533,6 +534,7 @@ interface ITestCopilotClient extends Pick { throw new Error('Persisted event reader not configured'); }; discoverAgents: CopilotAgentDiscovery['discover'] = async () => ({ agents: [] }); getAgentDiscoveryPaths: CopilotAgentDiscovery['getDiscoveryPaths'] = async () => ({ paths: [] }); discoverInstructions: CopilotInstructionDiscovery['discover'] = async () => ({ sources: [] }); @@ -607,6 +610,7 @@ class TestCopilotClient implements ITestCopilotClient { }, sessions: { fork: async () => ({ sessionId: 'forked-session' }), + readPersistedEvents: params => this.readPersistedEvents(params), list: async () => { this.sessionListStarted?.complete(); await this.sessionListGate; @@ -999,6 +1003,7 @@ class ResumePathCopilotAgent extends CopilotAgent { @ILogService logService: ILogService, @IInstantiationService instantiationService: IInstantiationService, @ISessionDataService sessionDataService: ISessionDataService, + @IAgentHostSessionSearchIndex sessionSearchIndex: IAgentHostSessionSearchIndex, @IAgentHostGitService gitService: IAgentHostGitService, @IAgentConfigurationService configurationService: IAgentConfigurationService, @IAgentHostSessionTitleSignal sessionTitleSignal: IAgentHostSessionTitleSignal, @@ -1016,7 +1021,7 @@ class ResumePathCopilotAgent extends CopilotAgent { @IFileService fileService: IFileService, @IAgentHostWorktreeIsolation worktreeIsolation: IAgentHostWorktreeIsolation, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, productService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver, fileService, worktreeIsolation); + super(logService, instantiationService, sessionDataService, sessionSearchIndex, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, productService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver, fileService, worktreeIsolation); } protected override _createCopilotClient(): CopilotClient { @@ -1041,6 +1046,7 @@ class TestableCopilotAgent extends CopilotAgent { @ILogService logService: ILogService, @IInstantiationService instantiationService: IInstantiationService, @ISessionDataService sessionDataService: ISessionDataService, + @IAgentHostSessionSearchIndex sessionSearchIndex: IAgentHostSessionSearchIndex, @IAgentHostGitService gitService: IAgentHostGitService, @IAgentConfigurationService configurationService: IAgentConfigurationService, @IAgentHostSessionTitleSignal sessionTitleSignal: IAgentHostSessionTitleSignal, @@ -1058,7 +1064,7 @@ class TestableCopilotAgent extends CopilotAgent { @IFileService fileService: IFileService, @IAgentHostWorktreeIsolation worktreeIsolation: IAgentHostWorktreeIsolation, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, productService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver, fileService, worktreeIsolation); + super(logService, instantiationService, sessionDataService, sessionSearchIndex, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, productService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver, fileService, worktreeIsolation); this._now = now; } @@ -1113,7 +1119,7 @@ function getCreatedClientOptions(agent: CopilotAgent): readonly CopilotClientOpt return agent.createdClientOptions; } -function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; otelService?: IAgentHostOTelService; customizationEnablementService?: ICustomizationEnablementService; worktreeIsolation?: IAgentHostWorktreeIsolation; rootConfig?: Record; now?: () => number }): { agent: CopilotAgent; instantiationService: IInstantiationService; authenticationService: AgentHostAuthenticationService; configurationService: IAgentConfigurationService; worktreeIsolation: IAgentHostWorktreeIsolation; managedSettingsService: IAgentHostManagedSettingsService; fileService: FileService; stateManager: AgentHostStateManager } { +function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; sessionSearchIndex?: IAgentHostSessionSearchIndex; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; otelService?: IAgentHostOTelService; customizationEnablementService?: ICustomizationEnablementService; worktreeIsolation?: IAgentHostWorktreeIsolation; rootConfig?: Record; now?: () => number }): { agent: CopilotAgent; instantiationService: IInstantiationService; authenticationService: AgentHostAuthenticationService; configurationService: IAgentConfigurationService; worktreeIsolation: IAgentHostWorktreeIsolation; managedSettingsService: IAgentHostManagedSettingsService; fileService: FileService; stateManager: AgentHostStateManager } { const services = new ServiceCollection(); const logService = options?.logService ?? new NullLogService(); const authenticationService = disposables.add(new AgentHostAuthenticationService(logService)); @@ -1140,6 +1146,7 @@ function createTestAgentContext(disposables: Pick, optio services.set(IAgentHostSessionTitleSignal, disposables.add(new AgentHostSessionTitleSignal(stateManager))); services.set(IAgentHostGitHubEndpointService, options?.gitHubEndpointService ?? createTestGitHubEndpointService()); services.set(ISessionDataService, options?.sessionDataService ?? createNullSessionDataService()); + services.set(IAgentHostSessionSearchIndex, options?.sessionSearchIndex ?? createUnavailableSearchIndex()); services.set(IAgentPluginManager, options?.pluginManager ?? new TestAgentPluginManager()); services.set(IAgentHostGitService, options?.gitService ?? new TestAgentHostGitService()); services.set(IAgentHostReviewService, NULL_REVIEW_SERVICE); @@ -1196,10 +1203,18 @@ function createTestAgentContext(disposables: Pick, optio return { agent, instantiationService, authenticationService, configurationService: configService, worktreeIsolation, managedSettingsService, fileService, stateManager }; } -function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; otelService?: IAgentHostOTelService }): CopilotAgent { +function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; sessionSearchIndex?: IAgentHostSessionSearchIndex; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; otelService?: IAgentHostOTelService }): CopilotAgent { return createTestAgentContext(disposables, options).agent; } +function createUnavailableSearchIndex(): IAgentHostSessionSearchIndex { + return { + _serviceBrand: undefined, + searchChat: async () => { throw new Error('Search index is not configured for this test'); }, + semanticSearch: async () => { throw new Error('Semantic search index is not configured for this test'); }, + }; +} + type CopilotCreateSessionOptions = Parameters[0]; function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot; readonly workingDirectory?: URI; readonly additionalDirectories?: readonly URI[] }): { readonly session: CopilotAgentSession; readonly activeClient: unknown; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { @@ -10725,6 +10740,7 @@ suite('CopilotAgent', () => { services.set(IAgentHostManagedSettingsService, disposables.add(new AgentHostManagedSettingsService())); services.set(IAgentHostGitHubEndpointService, createTestGitHubEndpointService()); services.set(ISessionDataService, createNullSessionDataService()); + services.set(IAgentHostSessionSearchIndex, createUnavailableSearchIndex()); services.set(IAgentPluginManager, new TestAgentPluginManager()); services.set(IAgentHostGitService, new TestAgentHostGitService()); services.set(IAgentHostReviewService, NULL_REVIEW_SERVICE); @@ -10855,6 +10871,7 @@ suite('CopilotAgent', () => { services.set(IAgentHostStateManager, stateManager); services.set(IAgentHostGitHubEndpointService, createTestGitHubEndpointService()); services.set(ISessionDataService, createNullSessionDataService()); + services.set(IAgentHostSessionSearchIndex, createUnavailableSearchIndex()); services.set(IAgentPluginManager, new TestAgentPluginManager()); services.set(IAgentHostGitService, new TestAgentHostGitService()); services.set(IAgentHostReviewService, NULL_REVIEW_SERVICE); @@ -11250,6 +11267,57 @@ suite('CopilotAgent', () => { } }); + test('searchChatHistory reads the exact persisted SDK backing without creating or resuming chats', async () => { + const directory = await fs.mkdtemp(join(os.tmpdir(), 'copilot-history-search-')); + const data = disposables.add(new class extends TestSessionDataService { + override getSessionDataDir(session: URI): URI { + return URI.file(join(directory, session.authority || AgentSession.id(session))); + } + }()); + const client = new TestCopilotClient([]); + const reads: string[] = []; + let creates = 0; + let resumes = 0; + client.createSession = async () => { creates++; throw new Error('Search must not create'); }; + client.resumeSession = async () => { resumes++; throw new Error('Search must not resume'); }; + const events: SessionEvent[] = [ + { type: 'user.message', id: 'request-event', parentId: null, timestamp: '2026-09-15T00:00:00Z', data: { content: 'Explain the implementation' } }, + { type: 'assistant.message', id: 'response-event', parentId: 'request-event', timestamp: '2026-09-15T00:00:01Z', data: { messageId: 'message-1', content: 'The searchable response discusses authentication.' } }, + ]; + client.readPersistedEvents = async params => { + reads.push(params.sessionId); + return { events: params.direction === 'backward' ? events.slice(-1) : events, cursor: 'tail', cursorStatus: 'ok', hasMore: false }; + }; + const searchIndex = disposables.add(new AgentHostSessionSearchIndex(join(directory, 'search.db'), data, new NullLogService())); + const agent = createTestAgent(disposables, { copilotClient: client, sessionDataService: data, sessionSearchIndex: searchIndex }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'host-session'); + const chat = defaultChatUri(session); + const peer = URI.parse(buildChatUri(session, 'peer')); + const defaultResult = await agent.searchChatHistory(chat, exactChatContext(session, chat, session), JSON.stringify({ sdkSessionId: 'different-sdk-id' }), 'authentication'); + const peerResult = await agent.searchChatHistory(peer, exactChatContext(session, peer, peer), JSON.stringify({ sdkSessionId: 'peer-sdk-id' }), 'authentication'); + assert.deepStrictEqual({ + backings: [...new Set(reads)], + defaultMatches: defaultResult.matches.map(match => ({ turnId: match.turnId, role: match.role, contains: match.snippet.includes('authentication') })), + peerMatches: peerResult.matches.length, + creates, + resumes, + live: hasLiveChat(agent, chat) || hasLiveChat(agent, peer), + }, { + backings: ['different-sdk-id', 'peer-sdk-id'], + defaultMatches: [{ turnId: 'request-event', role: 'assistant', contains: true }], + peerMatches: 1, + creates: 0, + resumes: 0, + live: false, + }); + } finally { + await disposeAgent(agent); + await fs.rm(directory, { recursive: true, force: true }); + } + }); + test('materializeChat does not assign the default backing to a peer without providerData', async () => { const agent = createTestAgent(disposables); try { diff --git a/src/vs/platform/agentHost/test/node/copilotSessionSearch.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionSearch.test.ts new file mode 100644 index 00000000000000..29ec934b567068 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotSessionSearch.test.ts @@ -0,0 +1,435 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import type { SessionEvent } from '@github/copilot-sdk'; +import { mkdtemp, rm, stat } from 'fs/promises'; +import { tmpdir } from 'os'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { join } from '../../../../base/common/path.js'; +import { isWindows } from '../../../../base/common/platform.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AGENT_CHAT_SEARCH_MAX_RESULTS, MAX_SESSION_SEARCH_QUERY_LENGTH, getAgentSessionSearchTerms, validateSessionSearchQuery } from '../../common/agentHostSessionSearch.js'; +import { buildDefaultChatUri } from '../../common/state/sessionState.js'; +import { searchCopilotSessionHistory as searchHistory } from '../../node/copilot/copilotSessionSearch.js'; +import { SessionSearchDatabase } from '../../node/sessionSearchDatabase.js'; +import { type ISessionEvent, toSessionEvents } from './copilotTestEvents.js'; + +type ReadPage = Parameters[3]; +type ReadOptions = Parameters[0]; + +function searchCopilotSessionHistory(databasePath: string, query: string, readPage: ReadPage, sourceKey = 'test-source') { + const database = new SessionSearchDatabase(databasePath); + return searchHistory({ + _serviceBrand: undefined, + searchChat: (chat, text, readSource) => database.searchChat(chat, text, readSource), + semanticSearch: (sessionUri, chatUris, request) => database.semanticSearch(sessionUri, chatUris, request), + }, { + harness: 'copilotcli', + sessionUri: 'copilotcli:/session', + chatUri: buildDefaultChatUri('copilotcli:/session'), + storageUri: 'copilotcli:/session', + sourceKey, + }, query, readPage); +} + +class TestHistory { + readonly calls: ReadOptions[] = []; + events: SessionEvent[]; + + constructor(events: ISessionEvent[], private readonly pageSize = 500) { + this.events = toSessionEvents(events).map((event, index) => ({ ...event, id: event.id ?? `event-${index}` })); + } + + readonly readPage: ReadPage = async options => { + this.calls.push(options); + if (options.direction === 'backward') { + return { events: this.events.slice(-options.max), cursor: `${this.events.length}`, hasMore: this.events.length > options.max, cursorStatus: 'ok' }; + } + const start = Number(options.cursor ?? 0); + const end = Math.min(this.events.length, start + Math.min(options.max, this.pageSize)); + return { events: this.events.slice(start, end), cursor: `${end}`, hasMore: end < this.events.length, cursorStatus: 'ok' }; + }; +} + +suite('Copilot persisted session search', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + let directory: string; + let databasePath: string; + + setup(async () => { + directory = await mkdtemp(join(tmpdir(), 'copilot-session-search-')); + databasePath = join(directory, 'index', 'search.db'); + }); + + teardown(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + if (!isWindows) { + test('keeps the derived transcript index private to its owner', async () => { + const history = new TestHistory([{ type: 'user.message', id: 'turn', data: { content: 'private content' } }]); + await searchCopilotSessionHistory(databasePath, 'private', history.readPage); + const [file, folder] = await Promise.all([stat(databasePath), stat(join(directory, 'index'))]); + assert.deepStrictEqual({ file: file.mode & 0o777, folder: folder.mode & 0o777 }, { file: 0o600, folder: 0o700 }); + }); + } + + test('indexes full user and assistant messages with the user envelope as the turn id', async () => { + const history = new TestHistory([ + { type: 'user.message', id: 'user-envelope', data: { interactionId: 'not-the-turn-id', content: 'Find the cobalt problem' } }, + { type: 'assistant.message', id: 'assistant-envelope', data: { content: 'Resolved the cobalt problem' } }, + { type: 'user.message', id: 'next-turn', data: { content: 'Another request' } }, + { type: 'assistant.message', data: { content: 'cobalt' } }, + ]); + const result = await searchCopilotSessionHistory(databasePath, 'cobalt', history.readPage); + assert.deepStrictEqual({ + ...result, + matches: result.matches.sort((a, b) => a.snippet.localeCompare(b.snippet)), + }, { + matches: [ + { turnId: 'next-turn', role: 'assistant', snippet: 'cobalt' }, + { turnId: 'user-envelope', role: 'user', snippet: 'Find the cobalt problem' }, + { turnId: 'user-envelope', role: 'assistant', snippet: 'Resolved the cobalt problem' }, + ], + hasMore: false, + }); + }); + + test('finds text beyond 5000 characters and returns bounded snippets around each match', async () => { + const history = new TestHistory([ + { type: 'user.message', id: 'long-turn', data: { content: `${'prefix '.repeat(900)}nearby userneedle context ${'after '.repeat(900)}` } }, + { type: 'assistant.message', data: { content: `${'prefix '.repeat(900)}nearby assistantneedle context ${'after '.repeat(900)}` } }, + ]); + const user = await searchCopilotSessionHistory(databasePath, 'userneedle', history.readPage); + const assistant = await searchCopilotSessionHistory(databasePath, 'assistantneedle', history.readPage); + assert.deepStrictEqual([user, assistant].map(result => ({ + turnId: result.matches[0]?.turnId, + role: result.matches[0]?.role, + hasContext: result.matches[0]?.snippet.includes(`nearby ${result.matches[0].role}needle context`), + bounded: result.matches[0]?.snippet.length <= 220, + hasMarkers: /[\uFDD0\uFDD1]/u.test(result.matches[0]?.snippet ?? ''), + })), [ + { turnId: 'long-turn', role: 'user', hasContext: true, bounded: true, hasMarkers: false }, + { turnId: 'long-turn', role: 'assistant', hasContext: true, bounded: true, hasMarkers: false }, + ]); + }); + + test('uses the assistant envelope as the turn id when persisted history begins with an assistant', async () => { + const history = new TestHistory([ + { type: 'assistant.message', id: 'assistant-turn', data: { content: 'orphanword' } }, + { type: 'assistant.message', data: { content: 'orphanword continued' } }, + ]); + assert.deepStrictEqual((await searchCopilotSessionHistory(databasePath, 'orphanword', history.readPage)).matches, [ + { turnId: 'assistant-turn', role: 'assistant', snippet: 'orphanword' }, + { turnId: 'assistant-turn', role: 'assistant', snippet: 'orphanword continued' }, + ]); + }); + + test('bounds snippets by characters even when a nearby token is very long', async () => { + const history = new TestHistory([ + { type: 'user.message', data: { content: `${'x'.repeat(7000)} needlematch ${'y'.repeat(7000)}` } }, + ]); + const result = await searchCopilotSessionHistory(databasePath, 'needlematch', history.readPage); + assert.deepStrictEqual({ + matches: result.matches.length, + hasMatch: result.matches[0]?.snippet.includes('needlematch'), + bounded: result.matches[0]?.snippet.length <= 220, + }, { matches: 1, hasMatch: true, bounded: true }); + }); + + test('uses canonical synthetic-message and prompt-scaffolding sanitization', async () => { + const history = new TestHistory([ + { type: 'user.message', id: 'visible', data: { source: 'USER', content: 'visibleword\nhiddenwordhiddenwordhiddenwordhiddenwordhiddenwordhiddenwordhiddenwordhiddenword' } }, + { type: 'user.message', data: { source: 'skill', content: 'hiddenword' } }, + { type: 'assistant.message', data: { content: 'visibleword response' } }, + { type: 'user.message', id: 'wrapped', data: { content: 'wrappedwordhiddenword' } }, + { type: 'user.message', id: 'wrapped-query', data: { content: 'wrappedword' } }, + ]); + const visible = await searchCopilotSessionHistory(databasePath, 'visibleword', history.readPage); + const wrapped = await searchCopilotSessionHistory(databasePath, 'wrappedword', history.readPage); + const hidden = await searchCopilotSessionHistory(databasePath, 'hiddenword', history.readPage); + assert.deepStrictEqual({ visible: visible.matches, wrapped: wrapped.matches, hidden }, { + visible: [ + { turnId: 'visible', role: 'user', snippet: 'visibleword' }, + { turnId: 'visible', role: 'assistant', snippet: 'visibleword response' }, + ], + wrapped: [ + { turnId: 'wrapped', role: 'user', snippet: 'wrappedword' }, + { turnId: 'wrapped-query', role: 'user', snippet: 'wrappedword' }, + ], + hidden: { matches: [], hasMore: false }, + }); + }); + + test('excludes subagents, reasoning, attachments, and arbitrary tool payloads', async () => { + const history = new TestHistory([ + { type: 'user.message', id: 'parent', data: { content: 'parentword', attachments: [{ type: 'file', path: '/hiddenword.txt' }] } }, + { type: 'user.message', agentId: 'child', data: { content: 'hiddenword' } }, + { type: 'assistant.message', agentId: 'child', data: { content: 'hiddenword' } }, + { type: 'assistant.message', data: { parentToolCallId: 'legacy-child', content: 'hiddenword' } }, + { type: 'assistant.message', data: { content: 'parentword response', reasoningText: 'hiddenword', reasoningOpaque: 'hiddenword', encryptedContent: 'hiddenword', toolRequests: [{ toolCallId: 'shell', name: 'bash', arguments: { command: 'hiddenword' } }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'shell', toolName: 'bash', arguments: { command: 'hiddenword' } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'shell', success: true, result: { content: 'hiddenword' } } }, + ]); + assert.deepStrictEqual({ + hidden: await searchCopilotSessionHistory(databasePath, 'hiddenword', history.readPage), + parent: (await searchCopilotSessionHistory(databasePath, 'parentword', history.readPage)).matches.map(match => match.turnId), + }, { + hidden: { matches: [], hasMore: false }, + parent: ['parent', 'parent'], + }); + }); + + test('indexes rendered task_complete summaries once, including execution-start-only summaries', async () => { + const history = new TestHistory([ + { type: 'user.message', id: 'task-turn', data: { content: 'Do the work' } }, + { type: 'assistant.message', data: { content: '', toolRequests: [{ toolCallId: 'done', name: 'task_complete', arguments: { summary: 'finishedword in the final answer', privateField: 'hiddenword' } }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'done', toolName: 'task_complete', arguments: { summary: 'finishedword in the final answer' } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'done', success: true, result: { content: 'hiddenword' } } }, + { type: 'user.message', id: 'start-only-turn', data: { content: 'More work' } }, + { type: 'assistant.message', data: { content: '', toolRequests: [{ toolCallId: 'start-only', name: 'task_complete' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'start-only', toolName: 'task_complete', arguments: { summary: 'finishedword in the final answer' } } }, + ], 1); + assert.deepStrictEqual({ + summaries: await searchCopilotSessionHistory(databasePath, 'finishedword', history.readPage), + toolPayload: await searchCopilotSessionHistory(databasePath, 'hiddenword', history.readPage), + }, { + summaries: { + matches: [ + { turnId: 'task-turn', role: 'assistant', snippet: '**Task completed:** finishedword in the final answer' }, + { turnId: 'start-only-turn', role: 'assistant', snippet: '**Task completed:** finishedword in the final answer' }, + ], + hasMore: false, + }, + toolPayload: { matches: [], hasMore: false }, + }); + }); + + test('excludes subagent task summaries and does not fall back to raw completion output', async () => { + const history = new TestHistory([ + { type: 'user.message', data: { content: 'Visible prompt' } }, + { type: 'assistant.message', agentId: 'child', data: { toolRequests: [{ toolCallId: 'child', name: 'task_complete', arguments: { summary: 'hiddenword' } }] } }, + { type: 'tool.execution_start', agentId: 'child', data: { toolCallId: 'child', toolName: 'task_complete', arguments: { summary: 'hiddenword' } } }, + { type: 'tool.execution_start', data: { parentToolCallId: 'legacy-child', toolCallId: 'legacy', toolName: 'task_complete', arguments: { summary: 'hiddenword' } } }, + { type: 'tool.execution_start', data: { toolCallId: 'empty', toolName: 'task_complete', arguments: { summary: { hiddenword: true } } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'empty', success: true, result: { content: 'hiddenword' } } }, + ]); + assert.deepStrictEqual(await searchCopilotSessionHistory(databasePath, 'hiddenword', history.readPage), { matches: [], hasMore: false }); + }); + + test('streams forward pages and preserves turn ids across page boundaries', async () => { + const history = new TestHistory([ + { type: 'user.message', id: 'first', data: { content: 'First prompt' } }, + { type: 'assistant.message', data: { content: 'First pagedword' } }, + { type: 'user.message', id: 'second', data: { content: 'Second prompt' } }, + { type: 'assistant.message', data: { content: 'Second pagedword' } }, + ], 1); + const result = await searchCopilotSessionHistory(databasePath, 'pagedword', history.readPage); + assert.deepStrictEqual({ + turns: result.matches.map(match => match.turnId), + calls: history.calls, + }, { + turns: ['first', 'second'], + calls: [ + { direction: 'backward', max: 1 }, + { cursor: undefined, direction: 'forward', max: 500 }, + { cursor: '1', direction: 'forward', max: 500 }, + { cursor: '2', direction: 'forward', max: 500 }, + { cursor: '3', direction: 'forward', max: 500 }, + ], + }); + }); + + test('reuses the closed and reopened persisted index when the tail is unchanged', async () => { + const history = new TestHistory([{ type: 'user.message', data: { content: 'cachedword' } }]); + const first = await searchCopilotSessionHistory(databasePath, 'cachedword', history.readPage); + history.calls.length = 0; + const second = await searchCopilotSessionHistory(databasePath, 'cachedword', history.readPage); + assert.deepStrictEqual({ result: second, calls: history.calls }, { + result: first, + calls: [{ direction: 'backward', max: 1 }], + }); + }); + + test('invalidates on append, truncate, and truncation to empty', async () => { + const history = new TestHistory([ + { type: 'user.message', id: 'base', data: { content: 'baseword' } }, + { type: 'assistant.message', id: 'truncated', data: { content: 'removedword' } }, + ]); + await searchCopilotSessionHistory(databasePath, 'baseword', history.readPage); + history.events.push(...new TestHistory([{ type: 'assistant.message', id: 'appended', data: { content: 'addedword' } }]).events); + const appended = await searchCopilotSessionHistory(databasePath, 'addedword', history.readPage); + history.events.splice(1); + const removed = await searchCopilotSessionHistory(databasePath, 'removedword', history.readPage); + const added = await searchCopilotSessionHistory(databasePath, 'addedword', history.readPage); + history.events.length = 0; + const empty = await searchCopilotSessionHistory(databasePath, 'baseword', history.readPage); + assert.deepStrictEqual({ appended, removed, added, empty }, { + appended: { matches: [{ turnId: 'base', role: 'assistant', snippet: 'addedword' }], hasMore: false }, + removed: { matches: [], hasMore: false }, + added: { matches: [], hasMore: false }, + empty: { matches: [], hasMore: false }, + }); + }); + + test('invalidates when the provider backing changes even if its tail id is reused', async () => { + const first = new TestHistory([{ type: 'user.message', id: 'same-tail', data: { content: 'firstword' } }]); + const second = new TestHistory([{ type: 'user.message', id: 'same-tail', data: { content: 'secondword' } }]); + await searchCopilotSessionHistory(databasePath, 'firstword', first.readPage, 'first-backing'); + assert.deepStrictEqual(await searchCopilotSessionHistory(databasePath, 'secondword', second.readPage, 'second-backing'), { + matches: [{ turnId: 'same-tail', role: 'user', snippet: 'secondword' }], + hasMore: false, + }); + }); + + test('propagates tail-read errors rather than returning cached success', async () => { + const history = new TestHistory([{ type: 'user.message', data: { content: 'cachedword' } }]); + await searchCopilotSessionHistory(databasePath, 'cachedword', history.readPage); + await assert.rejects(searchCopilotSessionHistory(databasePath, 'cachedword', async () => { + throw new Error('tail read failed'); + }), /tail read failed/); + }); + + test('rolls back partial rebuilds and preserves the previous index after a read error', async () => { + const history = new TestHistory([{ type: 'user.message', id: 'old-tail', data: { content: 'oldword' } }], 1); + const previous = await searchCopilotSessionHistory(databasePath, 'oldword', history.readPage); + const original = history.events; + history.events = new TestHistory([ + { type: 'user.message', id: 'new-first', data: { content: 'newword' } }, + { type: 'assistant.message', id: 'new-tail', data: { content: 'newword' } }, + ]).events; + await assert.rejects(searchCopilotSessionHistory(databasePath, 'newword', async options => { + if (options.direction === 'forward' && options.cursor) { + throw new Error('page read failed'); + } + return history.readPage(options); + }), /page read failed/); + history.events = original; + history.calls.length = 0; + assert.deepStrictEqual({ + result: await searchCopilotSessionHistory(databasePath, 'oldword', history.readPage), + calls: history.calls, + }, { result: previous, calls: [{ direction: 'backward', max: 1 }] }); + }); + + for (const failure of ['expired', 'stalled', 'missing-tail'] as const) { + test(`rejects ${failure} pagination without returning a partial search result`, async () => { + const history = new TestHistory([ + { type: 'user.message', id: 'first', data: { content: 'word' } }, + { type: 'assistant.message', id: 'tail', data: { content: 'word' } }, + ], 1); + await assert.rejects(searchCopilotSessionHistory(databasePath, 'word', async options => { + const page = await history.readPage(options); + if (options.direction === 'backward') { + return page; + } + return { + ...page, + events: history.events.slice(0, 1), + cursor: failure === 'stalled' ? '1' : page.cursor, + cursorStatus: failure === 'expired' ? 'expired' : 'ok', + hasMore: failure !== 'missing-tail', + }; + }), /Persisted conversation/); + }); + } + + test('serializes concurrent searches on the same database and releases the queue after failure', async () => { + const history = new TestHistory([{ type: 'user.message', data: { content: 'queuedword' } }]); + const entered = new DeferredPromise(); + const release = new DeferredPromise(); + let secondEntered = false; + const first = searchCopilotSessionHistory(databasePath, 'queuedword', async () => { + await entered.complete(); + await release.p; + throw new Error('first search failed'); + }); + const rejected = assert.rejects(first, /first search failed/); + await entered.p; + const second = searchCopilotSessionHistory(databasePath, 'queuedword', async options => { + secondEntered = true; + return history.readPage(options); + }); + await new Promise(resolve => setImmediate(resolve)); + const overlapped = secondEntered; + await release.complete(); + await rejected; + const result = await second; + assert.deepStrictEqual({ overlapped, result }, { + overlapped: false, + result: { matches: [{ turnId: 'event-0', role: 'user', snippet: 'queuedword' }], hasMore: false }, + }); + }); + + test('treats punctuation and malformed query syntax as literal words, never SQL or FTS operators', async () => { + const history = new TestHistory([ + { type: 'user.message', id: 'literal', data: { content: 'alpha anything beta AND OR NOT NEAR' } }, + { type: 'assistant.message', data: { content: 'alpha only' } }, + ]); + const results = []; + for (const query of ['"alpha beta', 'alpha-beta', 'AND OR NOT NEAR', `x' OR '1'='1'; DROP TABLE messages; --`, '() +-*:"', 'alpha']) { + results.push((await searchCopilotSessionHistory(databasePath, query, history.readPage)).matches.map(match => match.role).sort()); + } + assert.deepStrictEqual(results, [['user'], ['user'], ['user'], [], [], ['assistant', 'user']]); + }); + + test('matches Unicode words with case and diacritic folding', async () => { + const history = new TestHistory([{ type: 'user.message', data: { content: 'CAFÉ naïve 日本語' } }]); + assert.deepStrictEqual(await searchCopilotSessionHistory(databasePath, 'cafe naive 日本語', history.readPage), { + matches: [{ turnId: 'event-0', role: 'user', snippet: 'CAFÉ naïve 日本語' }], + hasMore: false, + }); + }); + + test('rejects overlong and empty queries before reading persisted history', async () => { + const history = new TestHistory([]); + await assert.rejects(searchCopilotSessionHistory(databasePath, 'x'.repeat(MAX_SESSION_SEARCH_QUERY_LENGTH + 1), history.readPage), /maximum length/); + await assert.rejects(searchCopilotSessionHistory(databasePath, ' \n\t', history.readPage), /must not be empty/); + assert.throws(() => validateSessionSearchQuery(''), /must not be empty/); + assert.deepStrictEqual({ + calls: history.calls, + terms: getAgentSessionSearchTerms('literal "AND" punctuation:word'), + maxLengthAccepted: getAgentSessionSearchTerms('x'.repeat(MAX_SESSION_SEARCH_QUERY_LENGTH)).length, + }, { calls: [], terms: ['literal', 'AND', 'punctuation', 'word'], maxLengthAccepted: 1 }); + }); + + for (const count of [AGENT_CHAT_SEARCH_MAX_RESULTS, AGENT_CHAT_SEARCH_MAX_RESULTS + 1]) { + test(`returns ranked matches and accurate hasMore for ${count} results`, async () => { + const events: ISessionEvent[] = Array.from({ length: count - 1 }, (_, index) => ({ + type: 'user.message', id: `long-${index}`, data: { content: `rankedword ${'unrelated '.repeat(40)}` }, + })); + events.push({ type: 'user.message', id: 'most-relevant', data: { content: 'rankedword' } }); + const history = new TestHistory(events); + const result = await searchCopilotSessionHistory(databasePath, 'rankedword', history.readPage); + assert.deepStrictEqual({ + count: result.matches.length, + first: result.matches[0], + hasMore: result.hasMore, + }, { + count: AGENT_CHAT_SEARCH_MAX_RESULTS, + first: { turnId: 'most-relevant', role: 'user', snippet: 'rankedword' }, + hasMore: count > AGENT_CHAT_SEARCH_MAX_RESULTS, + }); + }); + } + + test('stops at the captured tail while a session keeps appending', async () => { + const history = new TestHistory([{ type: 'user.message', id: 'snapshot-tail', data: { content: 'originalword' } }]); + const appended = new TestHistory([{ type: 'assistant.message', id: 'later-tail', data: { content: 'laterword' } }]).events; + const snapshot = await searchCopilotSessionHistory(databasePath, 'laterword', async options => { + if (options.direction === 'forward') { + history.events.push(...appended); + } + return history.readPage(options); + }); + const next = await searchCopilotSessionHistory(databasePath, 'laterword', history.readPage); + assert.deepStrictEqual({ snapshot, next }, { + snapshot: { matches: [], hasMore: false }, + next: { matches: [{ turnId: 'snapshot-tail', role: 'assistant', snippet: 'laterword' }], hasMore: false }, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 025b7e9b9a8f95..3261c94710d3e4 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -17,7 +17,9 @@ import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.j import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agent.js'; import { type IAgentHostManagedSettingsDiagnostics, type IAgentHostNetworkDiagnosticsInfo, type IAgentHostNetworkFetchResult, type IAgentService } from '../../common/agentService.js'; -import { RemoveSessionArtifactExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, supportsAgentHostArtifactRemoval } from '../../common/agentHostExtensionProtocol.js'; +import { RemoveSessionArtifactExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SearchSessionHistoryExtensionMethod, SessionSemanticSearchExtensionMethod, supportsAgentHostArtifactRemoval } from '../../common/agentHostExtensionProtocol.js'; +import { supportsAgentHostSessionSearch, supportsAgentHostSessionSemanticSearch } from '../../common/meta/agentHostSessionSearchMeta.js'; +import type { ISessionSemanticRequest } from '../../common/sessionSemanticSearch.js'; import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; import type { AutomationCapabilities, Implementation } from '../../common/state/protocol/common/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../common/state/protocol/channels-automation/commands.js'; @@ -156,6 +158,8 @@ class MockAgentService implements IAgentService { managedSettingsDiagnostics: readonly IAgentHostManagedSettingsDiagnostics[] = []; readonly getSessionStateFileCalls: { session: string; chat: string | undefined }[] = []; readonly removeSessionArtifactCalls: { session: string; artifactId: string }[] = []; + searchSessionHistory: IAgentService['searchSessionHistory']; + sessionSemanticSearch: IAgentService['sessionSemanticSearch']; readonly createDetachedWorktreeCalls: { session: string; prompt: string }[] = []; readonly setDetachedWorktreeArchivedCalls: { handle: string; archived: boolean }[] = []; readonly deleteDetachedWorktreeCalls: string[] = []; @@ -936,6 +940,129 @@ suite('ProtocolServerHandler', () => { }); }); + test('advertises and routes persisted history search through the extension request', async () => { + const calls: { session: string; query: string }[] = []; + const result = { matches: [{ chat: buildDefaultChatUri('copilotcli:/session-1'), turnId: 'turn-1', role: 'assistant' as const, snippet: 'An old response mentioning authentication' }], hasMore: false }; + agentService.searchSessionHistory = async (session, query) => { + calls.push({ session: session.toString(), query }); + return result; + }; + const transport = connectClient('client-search-history'); + const initialize = findResponse(transport.sent, 1); + assert.ok(initialize && hasKey(initialize, { result: true })); + const initialized = initialize.result as InitializeResult; + const response = waitForResponse(transport, 20); + transport.simulateMessage(request(20, SearchSessionHistoryExtensionMethod, { session: 'copilotcli:/session-1', query: 'authentication' })); + assert.deepStrictEqual({ + supported: supportsAgentHostSessionSearch(initialized), + legacy: supportsAgentHostSessionSearch({ ...initialized, _meta: undefined }), + malformed: supportsAgentHostSessionSearch({ ...initialized, _meta: { 'vscode.searchSessionHistory': 'true' } }), + uninitialized: supportsAgentHostSessionSearch(undefined), + response: await response, + calls, + }, { + supported: true, + legacy: false, + malformed: false, + uninitialized: false, + response: { jsonrpc: '2.0', id: 20, result }, + calls: [{ session: 'copilotcli:/session-1', query: 'authentication' }], + }); + }); + + test('rejects invalid history search requests before routing', async () => { + let calls = 0; + agentService.searchSessionHistory = async () => { + calls++; + return { matches: [], hasMore: false }; + }; + const transport = connectClient('client-search-history-invalid'); + const invalidParams = [ + undefined, null, [], {}, + { session: 1, query: 'term' }, + { session: 'session-1', query: 'term' }, + { session: 'copilotcli:/', query: 'term' }, + { session: 'copilotcli://authority/session-1', query: 'term' }, + { session: 'copilotcli:/session-1?query', query: 'term' }, + { session: buildChatUri('copilotcli:/session-1', 'peer'), query: 'term' }, + { session: 'copilotcli:/session-1', query: 1 }, + { session: 'copilotcli:/session-1', query: '' }, + { session: 'copilotcli:/session-1', query: ' ' }, + { session: 'copilotcli:/session-1', query: 'a'.repeat(513) }, + ]; + for (const [index, params] of invalidParams.entries()) { + const id = index + 20; + const pending = waitForResponse(transport, id); + transport.simulateMessage(request(id, SearchSessionHistoryExtensionMethod, params)); + const response = await pending; + assert.ok(isJsonRpcResponse(response) && hasKey(response, { error: true }) && response.error?.code === JsonRpcErrorCodes.InvalidParams, JSON.stringify(params)); + } + assert.strictEqual(calls, 0); + }); + + test('advertises and routes bounded semantic search operations', async () => { + const calls: { session: string; request: ISessionSemanticRequest }[] = []; + agentService.sessionSemanticSearch = async (session, request) => { + calls.push({ session: session.toString(), request }); + return { kind: 'pending', chunks: [], hasMore: false }; + }; + const transport = connectClient('semantic'); + const initialized = findResponse(transport.sent, 1); + assert.ok(initialized && hasKey(initialized, { result: true })); + const operation: ISessionSemanticRequest = { kind: 'pending', model: { id: 'copilot.test', dimensions: 2 } }; + const response = waitForResponse(transport, 20); + transport.simulateMessage(request(20, SessionSemanticSearchExtensionMethod, { session: 'copilotcli:/session', request: operation })); + assert.deepStrictEqual({ + supported: supportsAgentHostSessionSemanticSearch(initialized.result as InitializeResult), + legacy: supportsAgentHostSessionSemanticSearch(undefined), + result: await response, + calls, + }, { + supported: true, legacy: false, + result: { jsonrpc: '2.0', id: 20, result: { kind: 'pending', chunks: [], hasMore: false } }, + calls: [{ session: 'copilotcli:/session', request: operation }], + }); + }); + + test('rejects invalid semantic vectors and resource envelopes before routing', async () => { + let calls = 0; + agentService.sessionSemanticSearch = async () => { calls++; return { kind: 'store' }; }; + const transport = connectClient('invalid-semantic'); + const model = { id: 'copilot.test', dimensions: 2 }; + const operations = [ + undefined, { kind: 'pending', model: { id: '', dimensions: 2 } }, + { kind: 'search', model, vector: [1] }, + { kind: 'search', model, vector: [0, 0] }, + { kind: 'search', model, vector: [1, Number.NaN] }, + { kind: 'store', model, values: [{ id: -1, contentHash: 'a'.repeat(64), vector: [1, 0] }] }, + ]; + for (const [index, operation] of operations.entries()) { + const response = waitForResponse(transport, 20 + index); + transport.simulateMessage(request(20 + index, SessionSemanticSearchExtensionMethod, { session: 'copilotcli:/session', request: operation })); + const result = await response; + assert.ok(hasKey(result, { error: true }) && result.error?.code === JsonRpcErrorCodes.InvalidParams); + } + assert.strictEqual(calls, 0); + }); + + test('reports unsupported history search and propagates search errors', async () => { + const unsupported = connectClient('client-search-history-unsupported'); + const initialize = findResponse(unsupported.sent, 1); + assert.ok(initialize && hasKey(initialize, { result: true })); + assert.strictEqual(supportsAgentHostSessionSearch(initialize.result as InitializeResult), false); + const missing = waitForResponse(unsupported, 20); + unsupported.simulateMessage(request(20, SearchSessionHistoryExtensionMethod, { session: 'copilotcli:/session-1', query: 'term' })); + const missingResponse = await missing; + assert.ok(isJsonRpcResponse(missingResponse) && hasKey(missingResponse, { error: true }) && missingResponse.error?.code === JsonRpcErrorCodes.MethodNotFound); + + const error = new Error('Persisted history is unreadable'); + agentService.searchSessionHistory = async () => { throw error; }; + const transport = connectClient('client-search-history-error'); + const response = waitForResponse(transport, 20); + transport.simulateMessage(request(20, SearchSessionHistoryExtensionMethod, { session: 'copilotcli:/session-1', query: 'term' })); + assert.deepStrictEqual(await response, { jsonrpc: '2.0', id: 20, error: { code: JSON_RPC_INTERNAL_ERROR, message: error.stack } }); + }); + test('advertises and routes artifact removal through the extension request', async () => { const transport = connectClient('client-remove-artifact'); const initializeResponse = findResponse(transport.sent, 1); @@ -1207,6 +1334,8 @@ suite('ProtocolServerHandler', () => { }); test('extension methods can be disabled without blocking managed settings contributions', () => { + agentService.searchSessionHistory = async () => { throw new Error('Search must use the local management channel'); }; + agentService.sessionSemanticSearch = async () => { throw new Error('Semantic search must use management locally'); }; const localDisposables = disposables.add(new DisposableStore()); const localServer = localDisposables.add(new MockProtocolServer()); localDisposables.add(new ProtocolServerHandler( @@ -1232,18 +1361,23 @@ suite('ProtocolServerHandler', () => { const initializeResponse = findResponse(transport.sent, 1); assert.ok(initializeResponse && hasKey(initializeResponse, { result: true })); assert.strictEqual(supportsAgentHostArtifactRemoval(initializeResponse.result as InitializeResult), false); + assert.strictEqual(supportsAgentHostSessionSearch(initializeResponse.result as InitializeResult), false); + assert.strictEqual(supportsAgentHostSessionSemanticSearch(initializeResponse.result as InitializeResult), false); transport.sent.length = 0; transport.simulateMessage(request(2, 'shutdown', {})); + transport.simulateMessage(request(3, SearchSessionHistoryExtensionMethod, { session: 'copilotcli:/session-1', query: 'document' })); transport.simulateMessage(notification('setClientManagedSettingsPermissions', { permissions: { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }, })); assert.deepStrictEqual({ response: findResponse(transport.sent, 2), + searchResponse: findResponse(transport.sent, 3), shutdownCalls: agentService.shutdownCalls, managedSettingsPermissions: managedSettingsService.permissions, }, { response: { jsonrpc: '2.0', id: 2, error: { code: JsonRpcErrorCodes.MethodNotFound, message: 'Method not found: shutdown' } }, + searchResponse: { jsonrpc: '2.0', id: 3, error: { code: JsonRpcErrorCodes.MethodNotFound, message: `Method not found: ${SearchSessionHistoryExtensionMethod}` } }, shutdownCalls: 0, managedSettingsPermissions: { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }, }); diff --git a/src/vs/platform/agentHost/test/node/sessionSearchDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionSearchDatabase.test.ts new file mode 100644 index 00000000000000..e23eeabc363d02 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionSearchDatabase.test.ts @@ -0,0 +1,1044 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import type { Database } from '@vscode/sqlite3'; +import { createHash } from 'crypto'; +import { chmod, mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { join } from '../../../../base/common/path.js'; +import { isWindows } from '../../../../base/common/platform.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AGENT_CHAT_SEARCH_MAX_RESULTS, MAX_SESSION_SEARCH_QUERY_LENGTH } from '../../common/agentHostSessionSearch.js'; +import { MAX_SESSION_EMBEDDING_BATCH, MAX_SESSION_EMBEDDING_DIMENSIONS, validateSessionSemanticRequest, type ISessionEmbeddingChunk, type ISessionEmbeddingModel, type ISessionSemanticRequest } from '../../common/sessionSemanticSearch.js'; +import { SessionSearchDatabase, type ISessionSearchChat, type ISessionSearchDocument, type ISessionSearchSource } from '../../node/sessionSearchDatabase.js'; + +function chat(harness = 'copilot', sessionUri = 'session:/parent', chatUri = `${sessionUri}/default`, storageUri = sessionUri): ISessionSearchChat { + return { harness, sessionUri, chatUri, storageUri, sourceKey: 'backing-session' }; +} + +function document(text: string, turnId = 'turn', role: ISessionSearchDocument['role'] = 'user', sourceLocator = 'message'): ISessionSearchDocument { + return { turnId, role, sourceLocator, text }; +} + +class TestSource { + reads = 0; + iterations = 0; + + constructor(public documents: ISessionSearchDocument[], public revision = 'revision-1') { } + + readonly read = async (): Promise => { + this.reads++; + return { revision: this.revision, documents: this.iterate() }; + }; + + private async *iterate(): AsyncIterable { + this.iterations++; + yield* this.documents; + } +} + +async function withDatabase(path: string, writable: boolean, task: (db: Database) => Promise): Promise { + const sqlite3 = await import('@vscode/sqlite3'); + const db = await new Promise((resolve, reject) => { + const database = new sqlite3.default.Database(path, writable ? sqlite3.default.OPEN_READWRITE | sqlite3.default.OPEN_CREATE : sqlite3.default.OPEN_READONLY, error => error ? reject(error) : resolve(database)); + }); + try { + return await task(db); + } finally { + await new Promise((resolve, reject) => db.close(error => error ? reject(error) : resolve())); + } +} + +function query(db: Database, sql: string): Promise { + return new Promise((resolve, reject) => db.all(sql, (error: Error | null, rows: T[]) => error ? reject(error) : resolve(rows))); +} + +function exec(db: Database, sql: string): Promise { + return new Promise((resolve, reject) => db.exec(sql, error => error ? reject(error) : resolve())); +} + +function counts(db: Database) { + return query<{ chats: number; turns: number; documents: number; fts: number }>(db, ` + SELECT + (SELECT count(*) FROM search_chats) AS chats, + (SELECT count(*) FROM search_turns) AS turns, + (SELECT count(*) FROM search_documents) AS documents, + (SELECT count(*) FROM search_fts) AS fts + `); +} + +function semanticCounts(db: Database) { + return query<{ chunks: number; embeddings: number }>(db, ` + SELECT (SELECT count(*) FROM search_chunks) AS chunks, (SELECT count(*) FROM search_embeddings) AS embeddings + `); +} + +suite('SessionSearchDatabase', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + let directory: string; + let databasePath: string; + let database: SessionSearchDatabase; + + setup(async () => { + directory = join(process.cwd(), `.session-search-test-${generateUuid()}`); + await mkdir(directory, { mode: 0o700 }); + databasePath = join(directory, 'index', 'search.db'); + database = new SessionSearchDatabase(databasePath); + }); + + teardown(async () => { + await database.whenIdle(); + await rm(directory, { recursive: true, force: true }); + }); + + test('shares a persistent cache across harnesses without colliding identical chat, turn, or source identifiers', async () => { + const harnesses = ['copilot', 'claude', 'codex']; + const sources = harnesses.map(harness => new TestSource([ + document(`shared ${harness} request`), + document(`shared ${harness} response`, 'turn', 'assistant'), + ])); + await Promise.all(harnesses.map((harness, index) => database.searchChat(chat(harness), 'shared', sources[index].read))); + const reopened = new SessionSearchDatabase(databasePath); + const results = await Promise.all(harnesses.map((harness, index) => reopened.searchChat(chat(harness), 'shared', sources[index].read))); + assert.deepStrictEqual({ + results, + reads: sources.map(source => source.reads), + iterations: sources.map(source => source.iterations), + counts: await withDatabase(databasePath, false, counts), + }, { + results: harnesses.map(harness => ({ + matches: [ + { turnId: 'turn', role: 'user', snippet: `shared ${harness} request` }, + { turnId: 'turn', role: 'assistant', snippet: `shared ${harness} response` }, + ], + hasMore: false, + })), + reads: [2, 2, 2], + iterations: [1, 1, 1], + counts: [{ chats: 3, turns: 3, documents: 6, fts: 6 }], + }); + }); + + test('invalidates only the target chat on source revision, backing identity, or index version changes', async () => { + const target = new TestSource([document('original')]); + const other = new TestSource([document('unrelated')]); + await database.searchChat(chat(), 'original', target.read); + await database.searchChat(chat('claude'), 'unrelated', other.read); + const originalOther = await withDatabase(databasePath, false, db => query(db, ` + SELECT c.*, t.id AS turn_id, d.id AS document_id, f.text + FROM search_chats c JOIN search_turns t ON t.chat_id = c.id + JOIN search_documents d ON d.turn_id = t.id JOIN search_fts f ON f.rowid = d.id + WHERE c.harness = 'claude' + `)); + + target.revision = 'revision-2'; + target.documents = [document('revised')]; + const revised = await database.searchChat(chat(), 'revised', target.read); + const oldRevision = await database.searchChat(chat(), 'original', target.read); + target.documents = [document('replacement')]; + const changedBacking = { ...chat(), sourceKey: 'new-backing' }; + const backing = await database.searchChat(changedBacking, 'replacement', target.read); + const oldBacking = await database.searchChat(changedBacking, 'revised', target.read); + await withDatabase(databasePath, true, db => exec(db, `UPDATE search_chats SET index_version = 0 WHERE harness = 'copilot'`)); + target.documents = [document('reindexed')]; + const reindexed = await database.searchChat(changedBacking, 'reindexed', target.read); + await database.searchChat(chat('claude'), 'unrelated', other.read); + const currentOther = await withDatabase(databasePath, false, db => query(db, ` + SELECT c.*, t.id AS turn_id, d.id AS document_id, f.text + FROM search_chats c JOIN search_turns t ON t.chat_id = c.id + JOIN search_documents d ON d.turn_id = t.id JOIN search_fts f ON f.rowid = d.id + WHERE c.harness = 'claude' + `)); + assert.deepStrictEqual({ + matches: [revised, backing, reindexed].map(result => result.matches.map(match => match.snippet)), + oldMatches: [oldRevision.matches, oldBacking.matches], + iterations: [target.iterations, other.iterations], + otherUnchanged: currentOther, + metadata: await withDatabase(databasePath, false, db => query(db, `SELECT source_key, revision, index_version FROM search_chats WHERE harness = 'copilot'`)), + }, { + matches: [['revised'], ['replacement'], ['reindexed']], + oldMatches: [[], []], + iterations: [4, 1], + otherUnchanged: originalOther, + metadata: [{ source_key: 'new-backing', revision: 'revision-2', index_version: 1 }], + }); + }); + + test('empty revision and empty history clear only the target documents', async () => { + const target = new TestSource([document('target')]); + const other = new TestSource([document('unrelated')]); + await database.searchChat(chat('claude'), 'unrelated', other.read); + await database.searchChat(chat(), 'target', target.read); + target.revision = ''; + const emptyRevision = await database.searchChat(chat(), 'target', target.read); + const afterEmptyRevision = await withDatabase(databasePath, false, counts); + const iterationsAfterEmptyRevision = target.iterations; + target.revision = 'restored'; + await database.searchChat(chat(), 'target', target.read); + target.revision = 'empty-history'; + target.documents = []; + const emptyHistory = await database.searchChat(chat(), 'target', target.read); + assert.deepStrictEqual({ + results: [emptyRevision, emptyHistory], + iterationsAfterEmptyRevision, + counts: [afterEmptyRevision, await withDatabase(databasePath, false, counts)], + other: (await database.searchChat(chat('claude'), 'unrelated', other.read)).matches, + }, { + results: [{ matches: [], hasMore: false }, { matches: [], hasMore: false }], + iterationsAfterEmptyRevision: 1, + counts: [ + [{ chats: 2, turns: 1, documents: 1, fts: 1 }], + [{ chats: 2, turns: 1, documents: 1, fts: 1 }], + ], + other: [{ turnId: 'turn', role: 'user', snippet: 'unrelated' }], + }); + }); + + test('keeps integer relationships and full original text exclusively in contentful FTS', async () => { + const text = ' original\n\tUnicode café 🐈 text '; + await database.searchChat(chat(), 'original', new TestSource([ + document(text), + document('answer original', 'turn', 'assistant', 'answer'), + document(' ', 'empty'), + ]).read); + const snapshot = await withDatabase(databasePath, false, async db => ({ + ftsColumns: (await query<{ name: string }>(db, 'PRAGMA table_info(search_fts)')).map(column => column.name), + documentColumns: (await query<{ name: string }>(db, 'PRAGMA table_info(search_documents)')).map(column => column.name), + turnColumns: (await query<{ name: string }>(db, 'PRAGMA table_info(search_turns)')).map(column => column.name), + rows: await query(db, ` + SELECT typeof(c.id) AS chat_type, typeof(t.id) AS turn_type, typeof(d.id) AS document_type, + typeof(t.chat_id) AS chat_fk_type, typeof(d.turn_id) AS turn_fk_type, typeof(f.rowid) AS fts_type, + d.id = f.rowid AS matching_id, d.role, d.source_locator, f.text + FROM search_chats c JOIN search_turns t ON t.chat_id = c.id + JOIN search_documents d ON d.turn_id = t.id JOIN search_fts f ON f.rowid = d.id ORDER BY d.id + `), + storedContent: await query(db, 'SELECT c0 FROM search_fts_content ORDER BY id'), + counts: await counts(db), + foreignKeys: await query(db, 'PRAGMA foreign_key_check'), + indexedForeignKeys: await query(db, ` + SELECT 'chat_id' AS name FROM pragma_index_list('search_turns') l, pragma_index_info(l.name) i + WHERE i.name = 'chat_id' AND i.seqno = 0 + UNION ALL + SELECT 'turn_id' AS name FROM pragma_index_list('search_documents') l, pragma_index_info(l.name) i + WHERE i.name = 'turn_id' AND i.seqno = 0 + `), + })); + assert.deepStrictEqual(snapshot, { + ftsColumns: ['text'], + documentColumns: ['id', 'turn_id', 'role', 'source_locator'], + turnColumns: ['id', 'chat_id', 'turn_ref'], + rows: [text, 'answer original'].map((text, index) => ({ + chat_type: 'integer', turn_type: 'integer', document_type: 'integer', + chat_fk_type: 'integer', turn_fk_type: 'integer', fts_type: 'integer', matching_id: 1, + role: index, source_locator: index ? 'answer' : 'message', text, + })), + storedContent: [{ c0: text }, { c0: 'answer original' }], + counts: [{ chats: 1, turns: 1, documents: 2, fts: 2 }], + foreignKeys: [], + indexedForeignKeys: [{ name: 'chat_id' }, { name: 'turn_id' }], + }); + }); + + test('deletes peer storage independently and parent scopes with all their metadata and FTS', async () => { + const parent = chat(); + const peer = chat('copilot', parent.sessionUri, 'chat:/peer', 'storage:/peer'); + const outside = chat('copilot', 'session:/outside'); + const claude = chat('claude', 'session:/claude'); + const source = new TestSource([document('shared')]); + for (const identity of [parent, peer, outside, claude]) { + await database.searchChat(identity, 'shared', source.read); + } + await database.deleteScope(peer.storageUri); + const afterPeer = await withDatabase(databasePath, false, async db => ({ + counts: await counts(db), + chats: await query(db, 'SELECT chat_uri FROM search_chats ORDER BY chat_uri'), + })); + await database.searchChat(peer, 'shared', source.read); + await database.deleteScope(parent.sessionUri); + const afterParent = await withDatabase(databasePath, false, async db => ({ + counts: await counts(db), + chats: await query(db, 'SELECT chat_uri FROM search_chats ORDER BY chat_uri'), + ftsMatches: await query(db, `SELECT count(*) AS matches FROM search_fts WHERE search_fts MATCH 'shared'`), + })); + await database.deleteScope(outside.storageUri); + await database.deleteScope(claude.storageUri); + assert.deepStrictEqual({ afterPeer, afterParent, afterAll: await withDatabase(databasePath, false, counts) }, { + afterPeer: { + counts: [{ chats: 3, turns: 3, documents: 3, fts: 3 }], + chats: [{ chat_uri: claude.chatUri }, { chat_uri: outside.chatUri }, { chat_uri: parent.chatUri }], + }, + afterParent: { + counts: [{ chats: 2, turns: 2, documents: 2, fts: 2 }], + chats: [{ chat_uri: claude.chatUri }, { chat_uri: outside.chatUri }], + ftsMatches: [{ matches: 2 }], + }, + afterAll: [{ chats: 0, turns: 0, documents: 0, fts: 0 }], + }); + }); + + test('turn and document deletions also clean FTS through cascades and triggers', async () => { + await database.searchChat(chat(), 'shared', new TestSource([ + document('shared', 'first'), + document('shared', 'first', 'assistant'), + document('shared', 'second'), + ]).read); + const snapshot = await withDatabase(databasePath, true, async db => { + await exec(db, `PRAGMA foreign_keys = ON; DELETE FROM search_turns WHERE turn_ref = 'first'`); + const afterTurn = await counts(db); + await exec(db, 'DELETE FROM search_documents'); + return { afterTurn, afterDocument: await counts(db), matches: await query(db, `SELECT rowid FROM search_fts WHERE search_fts MATCH 'shared'`) }; + }); + assert.deepStrictEqual(snapshot, { + afterTurn: [{ chats: 1, turns: 1, documents: 1, fts: 1 }], + afterDocument: [{ chats: 1, turns: 1, documents: 0, fts: 0 }], + matches: [], + }); + }); + + test('deleting an absent cache or waiting for idle creates no directories or files', async () => { + await database.deleteScope('session:/missing'); + await database.whenIdle(); + assert.deepStrictEqual(await readdir(directory), []); + }); + + test('rolls back failed rebuilds without serving stale success or modifying unrelated snapshots', async () => { + const target = new TestSource([document('original')]); + const other = new TestSource([document('unrelated')]); + await database.searchChat(chat(), 'original', target.read); + await database.searchChat(chat('codex'), 'unrelated', other.read); + const before = await withDatabase(databasePath, false, async db => ({ + chats: await query(db, 'SELECT * FROM search_chats ORDER BY id'), + turns: await query(db, 'SELECT * FROM search_turns ORDER BY id'), + documents: await query(db, 'SELECT * FROM search_documents ORDER BY id'), + fts: await query(db, 'SELECT rowid, text FROM search_fts ORDER BY rowid'), + })); + const failure = new Error('source interrupted'); + await assert.rejects(database.searchChat({ ...chat(), sourceKey: 'new-source' }, 'original', async () => ({ + revision: 'broken-revision', + documents: (async function* () { + yield document('partial replacement', 'different-turn'); + throw failure; + })(), + })), error => error === failure); + const after = await withDatabase(databasePath, false, async db => ({ + chats: await query(db, 'SELECT * FROM search_chats ORDER BY id'), + turns: await query(db, 'SELECT * FROM search_turns ORDER BY id'), + documents: await query(db, 'SELECT * FROM search_documents ORDER BY id'), + fts: await query(db, 'SELECT rowid, text FROM search_fts ORDER BY rowid'), + })); + assert.deepStrictEqual({ + snapshot: after, + target: await database.searchChat(chat(), 'original', target.read), + other: await database.searchChat(chat('codex'), 'unrelated', other.read), + iterations: [target.iterations, other.iterations], + }, { + snapshot: before, + target: { matches: [{ turnId: 'turn', role: 'user', snippet: 'original' }], hasMore: false }, + other: { matches: [{ turnId: 'turn', role: 'user', snippet: 'unrelated' }], hasMore: false }, + iterations: [1, 1], + }); + }); + + test('source failures do not create a cache and do not poison the operation queue', async () => { + const failure = new Error('cannot read source'); + const failed = database.searchChat(chat(), 'word', async () => { throw failure; }); + const rejected = assert.rejects(failed, error => error === failure); + await rejected; + const filesAfterFailure = await readdir(directory); + const result = await database.searchChat(chat(), 'word', new TestSource([document('word')]).read); + assert.deepStrictEqual({ filesAfterFailure, result }, { + filesAfterFailure: [], + result: { matches: [{ turnId: 'turn', role: 'user', snippet: 'word' }], hasMore: false }, + }); + }); + + test('a failing initial rebuild leaves no partial chat and queued work still succeeds', async () => { + const source = new TestSource([document('unrelated')]); + await database.searchChat(chat('claude'), 'unrelated', source.read); + const failure = new Error('initial snapshot failed'); + const failed = database.searchChat(chat(), 'partial', async () => ({ + revision: 'new', + documents: (async function* () { + yield document('partial'); + throw failure; + })(), + })); + const rejected = assert.rejects(failed, error => error === failure); + const queued = new SessionSearchDatabase(databasePath).searchChat(chat('claude'), 'unrelated', source.read); + await rejected; + assert.deepStrictEqual({ + result: await queued, + counts: await withDatabase(databasePath, false, counts), + chats: await withDatabase(databasePath, false, db => query(db, 'SELECT harness FROM search_chats')), + }, { + result: { matches: [{ turnId: 'turn', role: 'user', snippet: 'unrelated' }], hasMore: false }, + counts: [{ chats: 1, turns: 1, documents: 1, fts: 1 }], + chats: [{ harness: 'claude' }], + }); + }); + + test('serializes source reads, rebuilds, deletion, and idle barriers across instances of the same path', async () => { + const readingDocuments = new DeferredPromise(); + const releaseDocuments = new DeferredPromise(); + const order: string[] = []; + const secondInstance = new SessionSearchDatabase(join(directory, 'index', '..', 'index', 'search.db')); + const source = new TestSource([document('next')], 'before-queue'); + const first = database.searchChat(chat(), 'first', async () => ({ + revision: 'first', + documents: (async function* () { + order.push('first documents'); + await readingDocuments.complete(); + await releaseDocuments.p; + yield document('first'); + order.push('first complete'); + })(), + })); + await readingDocuments.p; + const second = secondInstance.searchChat(chat(), 'next', async () => { + order.push(`second source ${source.revision}`); + return source.read(); + }); + const deletion = database.deleteScope(chat().sessionUri); + const idle = secondInstance.whenIdle(); + const orderWhileBlocked = [...order]; + source.revision = 'after-queue'; + await releaseDocuments.complete(); + const [firstResult, secondResult] = await Promise.all([first, second, deletion, idle]); + assert.deepStrictEqual({ + orderWhileBlocked, + order, + results: [firstResult.matches.map(match => match.snippet), secondResult.matches.map(match => match.snippet)], + counts: await withDatabase(databasePath, false, counts), + }, { + orderWhileBlocked: ['first documents'], + order: ['first documents', 'first complete', 'second source after-queue'], + results: [['first'], ['next']], + counts: [{ chats: 0, turns: 0, documents: 0, fts: 0 }], + }); + }); + + test('independent cache paths make progress while another path is rebuilding', async () => { + const started = new DeferredPromise(); + const release = new DeferredPromise(); + const blocked = database.searchChat(chat(), 'first', async () => { + await started.complete(); + await release.p; + return new TestSource([document('first')]).read(); + }); + await started.p; + try { + const other = new SessionSearchDatabase(join(directory, 'other.db')); + const result = await other.searchChat(chat(), 'other', new TestSource([document('other')]).read); + assert.deepStrictEqual(result, { matches: [{ turnId: 'turn', role: 'user', snippet: 'other' }], hasMore: false }); + } finally { + await release.complete(); + await blocked; + } + }); + + test('filters to the target chat before the 20/21 limit and orders equal ranks by integer document id', async () => { + const foreign = new TestSource(Array.from({ length: 100 }, (_, index) => document('needle', `foreign-${index}`))); + await database.searchChat(chat('claude'), 'needle', foreign.read); + const target = new TestSource(Array.from({ length: 21 }, (_, index) => document(`needle ${'padding '.repeat(40)}`, `target-${index}`))); + const overflow = await database.searchChat(chat(), 'needle', target.read); + const reopened = await new SessionSearchDatabase(databasePath).searchChat(chat(), 'needle', target.read); + target.revision = 'twenty'; + target.documents.pop(); + const exact = await database.searchChat(chat(), 'needle', target.read); + assert.deepStrictEqual({ + results: [overflow, reopened, exact].map(result => ({ + turns: result.matches.map(match => match.turnId), + hasMore: result.hasMore, + })), + foreignCount: await withDatabase(databasePath, false, db => query(db, `SELECT count(*) AS count FROM search_turns WHERE turn_ref LIKE 'foreign-%'`)), + }, { + results: [true, true, false].map(hasMore => ({ + turns: Array.from({ length: AGENT_CHAT_SEARCH_MAX_RESULTS }, (_, index) => `target-${index}`), + hasMore, + })), + foreignCount: [{ count: 100 }], + }); + }); + + test('ranks lexical relevance ahead of insertion order', async () => { + const result = await database.searchChat(chat(), 'needle', new TestSource([ + document(`needle ${'padding '.repeat(100)}`, 'long'), + document('needle', 'short'), + ]).read); + assert.deepStrictEqual(result.matches.map(match => match.turnId), ['short', 'long']); + }); + + test('bounds snippets around full-message matches, including huge nearby tokens', async () => { + const texts = [ + `${'prefix '.repeat(900)}nearby needle context ${'after '.repeat(900)}`, + `${'x'.repeat(7000)} needle ${'y'.repeat(7000)}`, + ' short\n\tneedle text ', + ]; + const result = await database.searchChat(chat(), 'needle', new TestSource(texts.map((text, index) => document(text, `turn-${index}`))).read); + assert.deepStrictEqual({ + matches: result.matches.length, + bounded: result.matches.every(match => match.snippet.length <= 220), + found: result.matches.every(match => match.snippet.includes('needle')), + markers: result.matches.some(match => /[\uFDD0\uFDD1]/u.test(match.snippet)), + context: result.matches.find(match => match.turnId === 'turn-0')?.snippet.includes('nearby needle context'), + short: result.matches.find(match => match.turnId === 'turn-2')?.snippet, + texts: await withDatabase(databasePath, false, db => query(db, 'SELECT text FROM search_fts ORDER BY rowid')), + }, { + matches: 3, bounded: true, found: true, markers: false, context: true, short: 'short needle text', + texts: texts.map(text => ({ text })), + }); + }); + + test('uses Unicode lexical AND terms and treats FTS or SQL syntax as literal words', async () => { + const source = new TestSource([ + document('café 東京 naïve Ελληνικά e\u0301lan \uE000private'), + document('alpha beta', 'both'), + document('alpha', 'only-alpha'), + document('beta', 'only-beta'), + document('alpha OR beta', 'literal-operator'), + ]); + const queries = ['café 東京', 'naïve Ελληνικά', 'e\u0301lan \uE000private', '"alpha" (beta)*', 'alpha OR beta', `alpha'; DROP TABLE search_chats; --`]; + const results = []; + for (const query of queries) { + results.push((await database.searchChat(chat(), query, source.read)).matches.map(match => match.turnId)); + } + assert.deepStrictEqual({ + results, + counts: await withDatabase(databasePath, false, counts), + }, { + results: [['turn'], ['turn'], ['turn'], ['both', 'literal-operator'], ['literal-operator'], []], + counts: [{ chats: 1, turns: 5, documents: 5, fts: 5 }], + }); + }); + + test('validates queries without reading history or creating a database', async () => { + const source = new TestSource([document('unused')]); + await assert.rejects(database.searchChat(chat(), ' ', source.read), /must not be empty/); + await assert.rejects(database.searchChat(chat(), 'x'.repeat(MAX_SESSION_SEARCH_QUERY_LENGTH + 1), source.read), /maximum length/); + const punctuation = await database.searchChat(chat(), '*"() -', source.read); + assert.deepStrictEqual({ punctuation, reads: source.reads, files: await readdir(directory) }, { + punctuation: { matches: [], hasMore: false }, reads: 0, files: [], + }); + }); + + test('treats opaque chat, source, turn, and cleanup identities as parameters', async () => { + const identity = `'; DELETE FROM search_chats; --`; + const target: ISessionSearchChat = { + harness: identity, sessionUri: identity, chatUri: identity, storageUri: identity, sourceKey: identity, + }; + const source = new TestSource([document('needle', identity, 'assistant', identity)], identity); + const unrelated = new TestSource([document('unrelated')]); + const result = await database.searchChat(target, 'needle', source.read); + await database.searchChat(chat(), 'unrelated', unrelated.read); + await database.deleteScope(identity); + assert.deepStrictEqual({ + result, + counts: await withDatabase(databasePath, false, counts), + remaining: await database.searchChat(chat(), 'unrelated', unrelated.read), + }, { + result: { matches: [{ turnId: identity, role: 'assistant', snippet: 'needle' }], hasMore: false }, + counts: [{ chats: 1, turns: 1, documents: 1, fts: 1 }], + remaining: { matches: [{ turnId: 'turn', role: 'user', snippet: 'unrelated' }], hasMore: false }, + }); + }); + + for (const kind of ['unmarked', 'foreign-application', 'future-schema', 'not-sqlite'] as const) { + test(`refuses ${kind} databases without changing bytes or permissions`, async () => { + await mkdir(join(directory, 'index'), { mode: 0o755 }); + if (kind === 'not-sqlite') { + await writeFile(databasePath, 'not a sqlite database', { mode: 0o644 }); + } else if (kind === 'future-schema') { + await database.searchChat(chat(), 'word', new TestSource([document('word')]).read); + await withDatabase(databasePath, true, db => exec(db, 'PRAGMA user_version = 2147483647')); + } else { + await withDatabase(databasePath, true, db => exec(db, ` + CREATE TABLE foreign_history (text TEXT); + INSERT INTO foreign_history VALUES ('provider history must not change'); + ${kind === 'foreign-application' ? 'PRAGMA application_id = 12345; PRAGMA user_version = 1;' : ''} + `)); + } + if (!isWindows) { + await chmod(databasePath, 0o644); + } + const before = { contents: await readFile(databasePath), file: await stat(databasePath), folder: await stat(join(directory, 'index')) }; + await assert.rejects(database.searchChat(chat(), 'word', new TestSource([document('word')]).read)); + await assert.rejects(database.deleteScope(chat().sessionUri)); + for (const request of [ + { kind: 'pending', model: { id: 'model', dimensions: 1 } }, + { kind: 'store', model: { id: 'model', dimensions: 1 }, values: [] }, + { kind: 'search', model: { id: 'model', dimensions: 1 }, vector: [1] }, + ] satisfies ISessionSemanticRequest[]) { + await assert.rejects(database.semanticSearch(chat().sessionUri, [chat().chatUri], request)); + } + const after = { contents: await readFile(databasePath), file: await stat(databasePath), folder: await stat(join(directory, 'index')) }; + assert.deepStrictEqual({ + contents: after.contents, mode: after.file.mode, modified: after.file.mtimeMs, + folderMode: after.folder.mode, files: await readdir(join(directory, 'index')), + }, { + contents: before.contents, mode: before.file.mode, modified: before.file.mtimeMs, + folderMode: before.folder.mode, files: ['search.db'], + }); + }); + } + + if (!isWindows) { + test('creates private cache files and directories and closes every SQLite connection', async () => { + await database.searchChat(chat(), 'private', new TestSource([document('private')]).read); + const [file, folder] = await Promise.all([stat(databasePath), stat(join(directory, 'index'))]); + const metadata = await withDatabase(databasePath, false, async db => ({ + application: await query(db, 'PRAGMA application_id'), + version: await query(db, 'PRAGMA user_version'), + })); + await rm(databasePath); + const result = await database.searchChat(chat(), 'rebuilt', new TestSource([document('rebuilt')]).read); + assert.deepStrictEqual({ + file: file.mode & 0o777, folder: folder.mode & 0o777, + metadata, result, files: await readdir(join(directory, 'index')), + }, { + file: 0o600, folder: 0o700, + metadata: { application: [{ application_id: 0x56535343 }], version: [{ user_version: 2 }] }, + result: { matches: [{ turnId: 'turn', role: 'user', snippet: 'rebuilt' }], hasMore: false }, + files: ['search.db'], + }); + }); + } + + suite('semantic search', () => { + const model: ISessionEmbeddingModel = { id: 'test-model-v1', dimensions: 2 }; + const target = chat(); + + async function pending(identity = target, selectedModel = model, allowed = [identity.chatUri]) { + const result = await database.semanticSearch(identity.sessionUri, allowed, { kind: 'pending', model: selectedModel }); + assert.strictEqual(result.kind, 'pending'); + return result; + } + + async function store(chunks: readonly ISessionEmbeddingChunk[], vector: readonly number[] = [1, 0], identity = target, selectedModel = model) { + return database.semanticSearch(identity.sessionUri, [identity.chatUri], { + kind: 'store', model: selectedModel, + values: chunks.map(chunk => ({ id: chunk.id, contentHash: chunk.contentHash, vector })), + }); + } + + async function search(vector: readonly number[] = [1, 0], identity = target, selectedModel = model, allowed = [identity.chatUri]) { + const result = await database.semanticSearch(identity.sessionUri, allowed, { kind: 'search', model: selectedModel, vector }); + assert.strictEqual(result.kind, 'search'); + return result; + } + + async function embedAll(identity = target, vector: readonly number[] = [1, 0]) { + let count = 0; + for (; ;) { + const batch = await pending(identity); + if (!batch.chunks.length) { + return count; + } + count += batch.chunks.length; + await store(batch.chunks, vector, identity); + } + } + + test('retrieves a paraphrase missed by lexical search and persists normalized little-endian vectors', async () => { + const text = 'The automobile will not start because its battery is flat.'; + const recipes = 'Measure flour and yeast before kneading bread dough.'; + const knitting = 'Choose wool and needles for knitting a warm scarf.'; + const source = new TestSource([document(text), document(recipes, 'recipes'), document(knitting, 'knitting')]); + const lexical = await database.searchChat(target, 'car engine problem', source.read); + const lexicalOnly = await withDatabase(databasePath, false, semanticCounts); + const before = await search(); + const batch = await pending(); + await database.semanticSearch(target.sessionUri, [target.chatUri], { + kind: 'store', model, + values: batch.chunks.map((chunk, index) => ({ + id: chunk.id, contentHash: chunk.contentHash, + vector: [[30, 40], [-40, 30], [-30, -40]][index], + })), + }); + const result = await search([3, 4]); + const recipeResult = await search([-4, 3]); + const reused = await new SessionSearchDatabase(databasePath).semanticSearch(target.sessionUri, [target.chatUri], { kind: 'pending', model }); + const metadata = await withDatabase(databasePath, false, async db => ({ + chunks: await query(db, 'SELECT document_id, start_offset, end_offset, content_hash FROM search_chunks WHERE id = 1'), + columns: (await query<{ name: string }>(db, 'PRAGMA table_info(search_chunks)')).map(column => column.name), + vectors: (await query<{ vector: Buffer; kind: string; dimensions: number }>(db, 'SELECT vector, typeof(vector) AS kind, dimensions FROM search_embeddings WHERE chunk_id = 1')).map(row => ({ + kind: row.kind, dimensions: row.dimensions, bytes: row.vector.length, + values: [row.vector.readFloatLE(0), row.vector.readFloatLE(4)], + })), + })); + assert.deepStrictEqual({ + lexical, lexicalOnly, before, batch, reused, metadata, + matches: result.matches.map(match => ({ ...match, score: Math.round(match.score * 1000) / 1000 })), + recipeMatches: recipeResult.matches.map(match => match.turnId), + incomplete: result.incomplete, + }, { + lexical: { matches: [], hasMore: false }, + lexicalOnly: [{ chunks: 0, embeddings: 0 }], + before: { kind: 'search', matches: [], hasMore: false, incomplete: true }, + batch: { + kind: 'pending', + chunks: [text, recipes, knitting].map((text, index) => ({ id: index + 1, contentHash: createHash('sha256').update(text).digest('hex'), text })), + hasMore: false, + }, + reused: { kind: 'pending', chunks: [], hasMore: false }, + metadata: { + chunks: [{ document_id: 1, start_offset: 0, end_offset: text.length, content_hash: batch.chunks[0].contentHash }], + columns: ['id', 'document_id', 'start_offset', 'end_offset', 'content_hash'], + vectors: [{ kind: 'blob', dimensions: 2, bytes: 8, values: [Math.fround(0.6), Math.fround(0.8)] }], + }, + matches: [{ chat: target.chatUri, turnId: 'turn', role: 'user', snippet: text, score: 1 }], + recipeMatches: ['recipes'], + incomplete: false, + }); + }); + + test('filters pending, store and ranking by both session and allowed chats before all limits', async () => { + const foreign = chat('copilot', 'session:/foreign'); + const peer = chat('copilot', target.sessionUri, 'chat:/peer'); + const source = new TestSource(Array.from({ length: 40 }, (_, index) => document(`irrelevant ${index}`, `other-${index}`))); + for (const identity of [foreign, peer]) { + await database.searchChat(identity, 'irrelevant', source.read); + await embedAll(identity); + } + await database.searchChat(target, 'selected', new TestSource([document('selected result')]).read); + const batch = await pending(); + const wrongSession = await pending(foreign, model, [target.chatUri]); + const wrongChat = await pending(target, model, [foreign.chatUri]); + await assert.rejects(store(batch.chunks, [1, 0], foreign), /outside the allowed chats/); + await assert.rejects(store(batch.chunks, [1, 0], peer), /outside the allowed chats/); + await store(batch.chunks); + assert.deepStrictEqual({ + pending: batch.chunks.map(chunk => chunk.text), hasMore: batch.hasMore, + wrongSession, wrongChat, + result: await search(), + emptyAllowed: await search([1, 0], target, model, []), + unlisted: await search([1, 0], target, model, [foreign.chatUri]), + }, { + pending: ['selected result'], hasMore: false, + wrongSession: { kind: 'pending', chunks: [], hasMore: false }, + wrongChat: { kind: 'pending', chunks: [], hasMore: false }, + result: { kind: 'search', matches: [{ chat: target.chatUri, turnId: 'turn', role: 'user', snippet: 'selected result', score: 1 }], hasMore: false, incomplete: false }, + emptyAllowed: { kind: 'search', matches: [], hasMore: false, incomplete: false }, + unlisted: { kind: 'search', matches: [], hasMore: false, incomplete: true }, + }); + }); + + test('reports incomplete coverage for authorized chats absent from the cache', async () => { + const peer = chat('copilot', target.sessionUri, 'chat:/peer'); + const allowed = [target.chatUri, peer.chatUri]; + const beforeCache = await search([1, 0], target, model, allowed); + await database.searchChat(target, 'automobile', new TestSource([ + document('An automobile requires a charged battery.', 'vehicle'), + document('Fresh bread needs time to rise.', 'baking'), + ]).read); + const batch = await pending(); + await database.semanticSearch(target.sessionUri, [target.chatUri], { + kind: 'store', model, + values: batch.chunks.map(chunk => ({ + id: chunk.id, contentHash: chunk.contentHash, + vector: chunk.text.includes('automobile') ? [1, 0] : [0, 1], + })), + }); + const missingPeer = await search([1, 0], target, model, allowed); + await database.searchChat(peer, 'word', new TestSource([]).read); + const emptyPeer = await search([1, 0], target, model, allowed); + assert.deepStrictEqual({ + beforeCache, + missingPeer: { incomplete: missingPeer.incomplete, turns: missingPeer.matches.map(match => match.turnId), hasMore: missingPeer.hasMore }, + emptyPeer: { incomplete: emptyPeer.incomplete, turns: emptyPeer.matches.map(match => match.turnId), hasMore: emptyPeer.hasMore }, + }, { + beforeCache: { kind: 'search', matches: [], hasMore: false, incomplete: true }, + missingPeer: { incomplete: true, turns: ['vehicle'], hasMore: false }, + emptyPeer: { incomplete: false, turns: ['vehicle'], hasMore: false }, + }); + }); + + test('bounds pending batches, advances past stored chunks, and caps deterministic ranking at twenty', async () => { + await database.searchChat(target, 'text', new TestSource(Array.from({ length: 35 }, (_, index) => document(`text ${index}`, `turn-${index}`))).read); + const batches: { length: number; hasMore: boolean }[] = []; + for (; ;) { + const batch = await pending(); + batches.push({ length: batch.chunks.length, hasMore: batch.hasMore }); + if (!batch.chunks.length) { + break; + } + await store(batch.chunks); + } + const result = await search(); + assert.deepStrictEqual({ + batches, + turns: result.matches.map(match => match.turnId), + hasMore: result.hasMore, incomplete: result.incomplete, + }, { + batches: [{ length: MAX_SESSION_EMBEDDING_BATCH, hasMore: true }, { length: 16, hasMore: true }, { length: 3, hasMore: false }, { length: 0, hasMore: false }], + turns: Array.from({ length: 20 }, (_, index) => `turn-${index}`), + hasMore: true, incomplete: false, + }); + }); + + test('isolates model versions and dimensions and reports only eligible missing embeddings', async () => { + const models = [model, { ...model, id: 'test-model-v2' }, { ...model, dimensions: 3 }]; + await database.searchChat(target, 'text', new TestSource([document('text')]).read); + const batch = await pending(); + await store(batch.chunks); + const isolated = []; + for (const other of models.slice(1)) { + const missing = await pending(target, other); + isolated.push({ chunks: missing.chunks.length, search: await search(Array(other.dimensions).fill(1), target, other) }); + await store(missing.chunks, Array(other.dimensions).fill(-1), target, other); + } + const results = []; + for (const selectedModel of models) { + results.push(await search(Array(selectedModel.dimensions).fill(1), target, selectedModel)); + } + assert.deepStrictEqual({ + isolated, + matches: results.map(result => result.matches.length), + incomplete: results.map(result => result.incomplete), + cache: await withDatabase(databasePath, false, semanticCounts), + }, { + isolated: models.slice(1).map(() => ({ chunks: 1, search: { kind: 'search', matches: [], hasMore: false, incomplete: true } })), + matches: [1, 0, 0], incomplete: [false, false, false], + cache: [{ chunks: 1, embeddings: 3 }], + }); + }); + + test('preserves all long content with overlapping UTF-16 offsets without storing duplicate text', async () => { + const text = `First paragraph ${'words '.repeat(180)}\n\n${'🐈'.repeat(8500)}\n\nLast paragraph ${'ending '.repeat(500)}`; + await database.searchChat(target, 'paragraph', new TestSource([document(text)]).read); + const embedded = await embedAll(); + const rows = await withDatabase(databasePath, false, db => query<{ start_offset: number; end_offset: number; content_hash: string }>(db, 'SELECT start_offset, end_offset, content_hash FROM search_chunks ORDER BY id')); + let reconstructed = ''; + let end = 0; + for (const row of rows) { + assert.ok(row.start_offset <= end && row.end_offset > end && row.end_offset - row.start_offset <= 1500); + const chunk = text.slice(row.start_offset, row.end_offset); + assert.strictEqual(row.content_hash, createHash('sha256').update(chunk).digest('hex')); + assert.ok(!/^[\uDC00-\uDFFF]|[\uD800-\uDBFF]$/u.test(chunk)); + reconstructed += text.slice(end, row.end_offset); + end = row.end_offset; + } + assert.deepStrictEqual({ + reconstructed, count: embedded, rows: rows.length, + boundedCount: rows.length < text.length / 500, + overlap: rows.slice(1).every((row, index) => rows[index].end_offset - row.start_offset >= 200), + paragraphBoundary: text.slice(0, rows[0].end_offset).endsWith('\n\n'), + }, { reconstructed: text, count: rows.length, rows: rows.length, boundedCount: true, overlap: true, paragraphBoundary: true }); + }); + + test('deduplicates by chat, turn and role using the best chunk and takes its snippet', async () => { + const peer = chat('copilot', target.sessionUri, 'chat:/peer'); + const texts = [ + document(`${'unrelated '.repeat(190)}\n\n${'The late matching passage explains battery maintenance. '.repeat(50)}`), + document('A weaker matching passage.', 'turn', 'user', 'other-document'), + document('The assistant also explains repairs.', 'turn', 'assistant'), + document('Opposite meaning.', 'opposite'), + document('Below threshold.', 'threshold'), + ]; + await database.searchChat(target, 'text', new TestSource(texts).read); + const batch = await pending(); + await database.semanticSearch(target.sessionUri, [target.chatUri], { + kind: 'store', model, + values: batch.chunks.map(chunk => ({ + id: chunk.id, contentHash: chunk.contentHash, + vector: chunk.text.includes('late matching passage') && !chunk.text.includes('unrelated') ? [1, 0] + : chunk.text.startsWith('A weaker') ? [3, 4] : chunk.text.startsWith('The assistant') ? [4, 3] + : chunk.text.startsWith('Below threshold') ? [1, 10] : [-1, 0], + })), + }); + await database.searchChat(peer, 'text', new TestSource([document('Peer meaning.')]).read); + await embedAll(peer, [3, 4]); + const result = await search([1, 0], target, model, [target.chatUri, peer.chatUri]); + assert.deepStrictEqual({ + matches: result.matches.map(match => ({ chat: match.chat, turnId: match.turnId, role: match.role, score: Math.round(match.score * 10) / 10 })), + snippet: result.matches[0].snippet.includes('late matching passage'), + bounded: result.matches.every(match => match.snippet.length <= 220), + hasMore: result.hasMore, incomplete: result.incomplete, + }, { + matches: [ + { chat: target.chatUri, turnId: 'turn', role: 'user', score: 1 }, + { chat: target.chatUri, turnId: 'turn', role: 'assistant', score: 0.8 }, + { chat: peer.chatUri, turnId: 'turn', role: 'user', score: 0.6 }, + ], + snippet: true, bounded: true, hasMore: false, incomplete: false, + }); + }); + + test('rejects stale generations after rebuild even when replacement text and hashes are identical', async () => { + const source = new TestSource([document('same content')]); + await database.searchChat(target, 'same', source.read); + const previous = await pending(); + await store(previous.chunks); + source.revision = 'replacement'; + await database.searchChat(target, 'same', source.read); + const afterRebuild = await withDatabase(databasePath, false, semanticCounts); + const replacement = await pending(); + const beforeFailure = await readFile(databasePath); + await assert.rejects(store(previous.chunks), /stale/); + assert.deepStrictEqual({ + afterRebuild, + sameHash: replacement.chunks[0].contentHash === previous.chunks[0].contentHash, + newId: replacement.chunks[0].id > previous.chunks[0].id, + unchanged: await readFile(databasePath), + counts: await withDatabase(databasePath, false, semanticCounts), + }, { afterRebuild: [{ chunks: 0, embeddings: 0 }], sameHash: true, newId: true, unchanged: beforeFailure, counts: [{ chunks: 1, embeddings: 0 }] }); + }); + + test('rolls back the whole store batch on stale hashes or out-of-scope chunks', async () => { + const foreign = chat('copilot', 'session:/other'); + await database.searchChat(target, 'text', new TestSource([document('text')]).read); + await database.searchChat(foreign, 'other', new TestSource([document('other')]).read); + const batch = await pending(); + const other = await pending(foreign); + await store(batch.chunks, [0, 1]); + for (const invalid of [ + { ...other.chunks[0], contentHash: '0'.repeat(64) }, + other.chunks[0], + ]) { + const before = await readFile(databasePath); + await assert.rejects(store([...batch.chunks, invalid]), /stale|outside/); + assert.deepStrictEqual(await readFile(databasePath), before); + } + await assert.rejects(store([{ ...batch.chunks[0], contentHash: '0'.repeat(64) }]), /stale/); + assert.deepStrictEqual(await search(), { kind: 'search', matches: [], hasMore: false, incomplete: false }); + }); + + test('cascades chunk and embedding cleanup on documents, turns, peer storage and sessions', async () => { + const peer = chat('copilot', target.sessionUri, 'chat:/peer', 'storage:/peer'); + for (const identity of [target, peer]) { + await database.searchChat(identity, 'text', new TestSource([ + document('text', 'first'), document('text', 'second'), document('text', 'third'), + ]).read); + await embedAll(identity); + } + const snapshots = []; + await withDatabase(databasePath, true, async db => { + await exec(db, 'PRAGMA foreign_keys = ON; DELETE FROM search_documents WHERE id = (SELECT min(id) FROM search_documents)'); + snapshots.push(await semanticCounts(db)); + await exec(db, `DELETE FROM search_turns WHERE turn_ref = 'second'`); + snapshots.push(await semanticCounts(db)); + }); + await database.deleteScope(peer.storageUri); + snapshots.push(await withDatabase(databasePath, false, semanticCounts)); + await database.deleteScope(target.sessionUri); + snapshots.push(await withDatabase(databasePath, false, semanticCounts)); + assert.deepStrictEqual(snapshots, [ + [{ chunks: 5, embeddings: 5 }], [{ chunks: 3, embeddings: 3 }], + [{ chunks: 1, embeddings: 1 }], [{ chunks: 0, embeddings: 0 }], + ]); + }); + + test('migrates version one without rebuilding FTS and generates old document chunks only on pending', async () => { + await mkdir(join(directory, 'index')); + await withDatabase(databasePath, true, db => exec(db, ` + CREATE TABLE search_chats ( + id INTEGER PRIMARY KEY, harness TEXT NOT NULL, session_uri TEXT NOT NULL, chat_uri TEXT NOT NULL, + storage_uri TEXT NOT NULL, source_key TEXT NOT NULL, revision TEXT NOT NULL, index_version INTEGER NOT NULL, + UNIQUE(harness, chat_uri) + ); + CREATE INDEX search_chats_session ON search_chats(session_uri); + CREATE INDEX search_chats_storage ON search_chats(storage_uri); + CREATE TABLE search_turns ( + id INTEGER PRIMARY KEY, chat_id INTEGER NOT NULL REFERENCES search_chats(id) ON DELETE CASCADE, + turn_ref TEXT NOT NULL, UNIQUE(chat_id, turn_ref) + ); + CREATE TABLE search_documents ( + id INTEGER PRIMARY KEY, turn_id INTEGER NOT NULL REFERENCES search_turns(id) ON DELETE CASCADE, + role INTEGER NOT NULL CHECK(role IN (0, 1)), source_locator TEXT NOT NULL + ); + CREATE INDEX search_documents_turn ON search_documents(turn_id); + CREATE VIRTUAL TABLE search_fts USING fts5(text); + CREATE TRIGGER search_documents_delete AFTER DELETE ON search_documents BEGIN + DELETE FROM search_fts WHERE rowid = old.id; + END; + INSERT INTO search_chats VALUES (7, 'copilot', 'session:/parent', 'session:/parent/default', 'session:/parent', 'backing-session', 'revision-1', 1); + INSERT INTO search_turns VALUES (9, 7, 'turn'); + INSERT INTO search_documents VALUES (11, 9, 0, 'message'); + INSERT INTO search_fts (rowid, text) VALUES (11, 'original cached text'); + PRAGMA application_id = 1448301379; + PRAGMA user_version = 1; + `)); + const source = new TestSource([document('source must not be enumerated')]); + const lexical = await database.searchChat(target, 'original', source.read); + const before = await withDatabase(databasePath, false, async db => ({ + version: await query(db, 'PRAGMA user_version'), + documents: await query(db, 'SELECT * FROM search_documents'), + fts: await query(db, 'SELECT rowid, text FROM search_fts'), + semantic: await semanticCounts(db), + })); + const batch = await pending(); + assert.deepStrictEqual({ lexical, before, iterations: source.iterations, text: batch.chunks.map(chunk => chunk.text) }, { + lexical: { matches: [{ turnId: 'turn', role: 'user', snippet: 'original cached text' }], hasMore: false }, + before: { + version: [{ user_version: 2 }], + documents: [{ id: 11, turn_id: 9, role: 0, source_locator: 'message' }], + fts: [{ rowid: 11, text: 'original cached text' }], + semantic: [{ chunks: 0, embeddings: 0 }], + }, + iterations: 0, text: ['original cached text'], + }); + }); + + test('validates all untrusted requests before opening or changing the cache', async () => { + const validValue = { id: 1, contentHash: 'a'.repeat(64), vector: [1, 0] }; + const invalid: unknown[] = [ + null, {}, { kind: 'unknown', model }, + ...[undefined, null, '', ' '.repeat(2), 'x'.repeat(129)].map(id => ({ kind: 'pending', model: { ...model, id } })), + ...[0, -1, 1.5, NaN, Infinity, '2', MAX_SESSION_EMBEDDING_DIMENSIONS + 1].map(dimensions => ({ kind: 'pending', model: { ...model, dimensions } })), + ...[undefined, null, [], [1], [1, 2, 3], [NaN, 1], [Infinity, 1], [1, -Infinity], [0, 0], [1, '2'], new Float32Array([1, 0])].map(vector => ({ kind: 'search', model, vector })), + ...[0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '1'].map(id => ({ kind: 'store', model, values: [{ ...validValue, id }] })), + ...['', 'a'.repeat(63), 'a'.repeat(65), 'x'.repeat(64)].map(contentHash => ({ kind: 'store', model, values: [{ ...validValue, contentHash }] })), + ...[undefined, null, {}, [undefined], [validValue, validValue], Array.from({ length: 33 }, (_, index) => ({ ...validValue, id: index + 1 }))].map(values => ({ kind: 'store', model, values })), + { kind: 'store', model, values: [{ ...validValue, vector: [NaN, 1] }] }, + { kind: 'store', model, values: [{ ...validValue, vector: [0, 0] }] }, + { kind: 'store', model, values: [{ ...validValue, vector: [1] }] }, + ]; + for (const request of invalid) { + assert.throws(() => validateSessionSemanticRequest(request)); + await assert.rejects(database.semanticSearch(target.sessionUri, [target.chatUri], request as ISessionSemanticRequest)); + } + assert.deepStrictEqual(await readdir(directory), []); + await database.searchChat(target, 'text', new TestSource([document('text')]).read); + const before = await readFile(databasePath); + for (const request of invalid) { + await assert.rejects(database.semanticSearch(target.sessionUri, [target.chatUri], request as ISessionSemanticRequest)); + } + assert.deepStrictEqual(await readFile(databasePath), before); + assert.doesNotThrow(() => validateSessionSemanticRequest({ + kind: 'store', model, + values: Array.from({ length: 32 }, (_, index) => ({ ...validValue, id: index + 1 })), + })); + }); + + test('normalizes finite extreme vectors without overflow or underflow', async () => { + await database.searchChat(target, 'text', new TestSource([document('text')]).read); + const batch = await pending(); + const scores = []; + for (const component of [Number.MAX_VALUE, Number.MIN_VALUE]) { + await store(batch.chunks, [component, component]); + scores.push((await search([component, component])).matches[0].score); + } + assert.ok(scores.every(score => Math.abs(score - 1) < 0.000001)); + }); + + test('absent caches and empty allowlists create no files', async () => { + assert.deepStrictEqual({ + pending: await pending(), + search: await search(), + store: await store([]), + emptyAllowed: await pending(target, model, []), + files: await readdir(directory), + }, { + pending: { kind: 'pending', chunks: [], hasMore: false }, + search: { kind: 'search', matches: [], hasMore: false, incomplete: true }, + store: { kind: 'store' }, + emptyAllowed: { kind: 'pending', chunks: [], hasMore: false }, + files: [], + }); + }); + }); +}); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 0742542bc514bb..cc6073fac320f8 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -42,6 +42,9 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.inputPills', "When session metadata or active-turn status pills appear above the input, press Shift+Tab to reach them, use the Left and Right arrow keys to move between them, and press Enter or Space to activate one. Open the context menu{0} to choose which pills are shown. Pull Requests Options lets you show all pull requests or only open and draft ones, remembered across sessions. If every pull request is filtered out, use the toolbar context menu to show all again.", '')); content.push(localize('sessionsChat.removePullRequestArtifact', "For pull requests recorded as session artifacts, the pull request dropdown offers Remove Pull Request Artifact from Session on each row. Use Tab to reach its actions. When only one pull request is visible, use the pull request pill's context menu instead. Removal is immediate and only deletes the artifact record; it does not close the pull request or remove independent session associations.")); content.push(localize('sessionsChat.externalSessionFilter', "The Sessions list Filter menu includes an External submenu. Use it to choose whether external sessions from another application are shown for the last 24 hours, the last 7 days, always, or not at all.")); + content.push(localize('sessionsChat.sessionContentSearch', "Use the search button in the Sessions toolbar or run Chat: Search Agent Session Content (Preview) from the Command Palette to search saved Copilot user and assistant messages using literal terms. Search covers all projects on connected hosts and includes archived sessions. Type a query, use Up and Down Arrow to choose a result, and press Enter to open its chat at the matching message. Escape returns focus. For title-only filtering, run Sessions: Find Session by Title. The picker reports search progress, unsupported hosts, and failures.")); + content.push(localize('sessionsChat.semanticSessionContentSearch', "Session content search uses keywords by default. Use Tab or Shift+Tab to reach Enable Semantic Search, the sparkle toggle, and press Enter or Space to find related messages by meaning as well. Select a registered Copilot embeddings provider if prompted, then confirm before your queries and saved user and assistant messages across all projects on connected hosts are sent to that provider. Vectors are stored locally by each host. Cancel sends nothing to the provider. Permission applies only to this open search; closing search or changing the workspace revokes it. Editing the query cancels pending work. Activate the toggle again to return to keyword search. Results announce Keyword match, Semantic match, or Keyword and semantic match. The picker reports incomplete semantic coverage and explicitly falls back to keyword results when semantic search is unavailable.")); + content.push(localize('sessionsChat.semanticSessionSearchBudget', "The search picker title identifies Keyword or Keyword and Semantic mode. The first semantic pass may take time and use the provider's quota. Each query embeds at most 2048 document chunks across all sessions, plus one query embedding. Unchanged cached chunks are not re-embedded. The picker reports document embedding budget usage. When the budget is exhausted, search still uses cached vectors and reports incomplete semantic coverage.")); content.push(localize('sessionsChat.externalSessionBanner', "When you first open a session created in another application, a banner appears at the top of the chat. Use Tab to reach its external-session picker, choose an option, and activate Save. The Close action dismisses the banner without changing the setting. Saving or closing permanently dismisses the banner.")); content.push(localize('sessionsChat.delegatedMessage', "Messages sent by another session or chat show a source annotation above the message. Press Tab to focus the annotation, then press Enter or Space to open the source chat.")); content.push(localize('sessionsChat.createdBySession', "When a session was created by another session, focus it in the Sessions list and use the Show Hover command{0}. Move focus to the Created by link, then press Enter or Space to open the creator session.", '')); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts b/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts index f1a5ec56946847..8a631cd3a59c77 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts @@ -5,6 +5,9 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { IAgentHostConnectionsService } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { DEFAULT_CHAT_ID } from '../../../../platform/agentHost/common/state/sessionState.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; @@ -22,12 +25,28 @@ class SessionsOpenerParticipant implements ISessionOpenerParticipant { async handleOpenSessionResource(accessor: ServicesAccessor, resource: URI, openOptions?: ISessionOpenOptions): Promise { const sessionsManagementService = accessor.get(ISessionsManagementService); const sessionsService = accessor.get(ISessionsService); - const target = sessionsManagementService.getSession(resource); + const sessionResource = resource.with({ fragment: '' }); + let target = sessionsManagementService.getSession(sessionResource); + if (!target && resource.fragment) { + const connectionsService = accessor.get(IAgentHostConnectionsService); + const identity = connectionsService.resolveSessionResourceIdentity(sessionResource); + if (identity) { + target = sessionsManagementService.getSessions().find(session => { + const candidate = connectionsService.resolveSessionResourceIdentity(session.resource); + return candidate?.connectionAuthority === identity.connectionAuthority && isEqual(candidate.backendSession, identity.backendSession); + }); + } + } if (!target) { return false; } - await sessionsService.openSession(resource, { preserveFocus: openOptions?.editorOptions?.preserveFocus, source: 'link' }); + if (resource.fragment) { + const chatResource = resource.fragment === DEFAULT_CHAT_ID ? target.mainChat.get().resource : target.resource.with({ fragment: resource.fragment }); + await sessionsService.openChat(target, chatResource, { preserveFocus: openOptions?.editorOptions?.preserveFocus, source: 'link' }); + } else { + await sessionsService.openSession(resource, { preserveFocus: openOptions?.editorOptions?.preserveFocus, source: 'link' }); + } return true; } } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts index d1f217541cc8cf..dc083e2bf62fee 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts @@ -20,6 +20,38 @@ import { SessionsChatAccessibilityHelp } from '../../browser/sessionsChatAccessi suite('SessionsChatAccessibilityHelp', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('documents saved session content search and keyboard navigation', () => { + const instantiationService = store.add(new TestInstantiationService()); + const configuration = new TestConfigurationService(); + store.add(configuration.onDidChangeConfigurationEmitter); + instantiationService.stub(IConfigurationService, configuration); + instantiationService.stub(ISessionsPartService, new class extends mock() { }()); + instantiationService.stub(ISessionsService, new class extends mock() { }()); + instantiationService.stub(IWorkbenchLayoutService, { mainContainer: mainWindow.document.createElement('div') }); + const provider = store.add(new SessionsChatAccessibilityHelp().getProvider(instantiationService)); + const help = provider.provideContent(); + assert.deepStrictEqual({ + command: help.includes('Chat: Search Agent Session Content (Preview)'), + button: help.includes('search button in the Sessions toolbar'), + titleFilter: help.includes('Sessions: Find Session by Title'), + scope: help.includes('all projects on connected hosts and includes archived sessions'), + accept: help.includes('press Enter to open its chat at the matching message'), + cancel: help.includes('Escape returns focus'), + semantic: help.includes('uses keywords by default'), + semanticKeyboard: help.includes('Use Tab or Shift+Tab to reach Enable Semantic Search, the sparkle toggle, and press Enter or Space'), + consent: help.includes('confirm before your queries and saved user and assistant messages across all projects on connected hosts are sent'), + decline: help.includes('Cancel sends nothing to the provider'), + revoke: help.includes('closing search or changing the workspace revokes it'), + incomplete: help.includes('incomplete semantic coverage'), + fallback: help.includes('falls back to keyword results'), + title: help.includes('title identifies Keyword or Keyword and Semantic mode'), + cost: help.includes('may take time and use the provider\'s quota'), + budget: help.includes('at most 2048 document chunks across all sessions, plus one query embedding'), + cache: help.includes('Unchanged cached chunks are not re-embedded'), + budgetExhausted: help.includes('When the budget is exhausted, search still uses cached vectors'), + }, { command: true, button: true, titleFilter: true, scope: true, accept: true, cancel: true, semantic: true, semanticKeyboard: true, consent: true, decline: true, revoke: true, incomplete: true, fallback: true, title: true, cost: true, budget: true, cache: true, budgetExhausted: true }); + }); + test('describes forking to the side and the keyboard-only alternative', () => { const instantiationService = store.add(new TestInstantiationService()); const configuration = new TestConfigurationService(); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsOpenerParticipant.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsOpenerParticipant.test.ts index 1a1f5b6ce10244..c05cd750c62eda 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsOpenerParticipant.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsOpenerParticipant.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { URI } from '../../../../../base/common/uri.js'; +import { constObservable } from '../../../../../base/common/observable.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -13,7 +14,7 @@ import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/ import { openSessionByResource } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; import { SessionsOpenerParticipantContribution } from '../../browser/sessionsOpenerParticipant.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; -import { ISession } from '../../../../services/sessions/common/session.js'; +import { IChat, ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; suite('SessionsOpenerParticipant', () => { @@ -45,4 +46,42 @@ suite('SessionsOpenerParticipant', () => { assert.deepStrictEqual(opened, { resource, preserveFocus: true }); }); + + for (const chatId of ['default', 'peer']) { + test(`opens the exact ${chatId} chat using the owning host rather than a same-ID remote session`, async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + const resource = URI.parse(`agent-host-copilotcli:/session#${chatId}`); + const actualResource = URI.parse('agent-host-copilotcli:/session'); + const remoteResource = URI.parse('remote-other-copilotcli:/session'); + const backendSession = URI.parse('copilotcli:/session'); + const session = upcastPartial({ + resource: actualResource, + mainChat: constObservable(upcastPartial({ resource: actualResource })), + }); + instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ + resolveSessionResourceIdentity: candidate => ({ + connectionAuthority: candidate.scheme === remoteResource.scheme ? 'other' : 'local', + backendSession, + }), + })); + instantiationService.stub(ISessionsManagementService, upcastPartial({ + getSession: () => undefined, + getSessions: () => [upcastPartial({ resource: remoteResource }), session], + })); + let opened: { session: URI; chat: URI; preserveFocus: boolean | undefined } | undefined; + instantiationService.stub(ISessionsService, upcastPartial({ + openChat: async (target, chat, options) => { + opened = { session: target.resource, chat, preserveFocus: options?.preserveFocus }; + }, + })); + disposables.add(new SessionsOpenerParticipantContribution()); + await instantiationService.invokeFunction(openSessionByResource, resource, { editorOptions: { preserveFocus: true } }); + assert.deepStrictEqual(opened, { + session: actualResource, + chat: actualResource.with({ fragment: chatId === 'default' ? '' : chatId }), + preserveFocus: true, + }); + }); + } }); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index eda7c857e0c212..bf39025101d86d 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -47,6 +47,7 @@ import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../chat/common/constants.js'; import { INewSessionComposerService } from '../../../chat/browser/newSessionComposerService.js'; import { WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; +import { SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID, SEARCH_AGENT_SESSION_CONTENT_TITLE } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionSearch.js'; const CLOSE_SESSION_COMMAND_ID = 'sessionsViewPane.closeSession'; registerAction2(class CloseSessionAction extends Action2 { @@ -258,12 +259,13 @@ MenuRegistry.appendMenuItem(Menus.SidebarSessionsHeader, { MenuRegistry.appendMenuItem(Menus.SidebarSessionsHeader, { command: { - id: 'sessionsViewPane.find', - title: localize2('find', "Find Session"), + id: SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID, + title: SEARCH_AGENT_SESSION_CONTENT_TITLE, icon: Codicon.search, }, group: 'navigation', order: 20, + when: ChatContextKeys.enabled, }); MenuRegistry.appendMenuItem(SessionsViewFilterSubMenu, { @@ -456,14 +458,16 @@ registerAction2(class FindSessionAction extends Action2 { constructor() { super({ id: 'sessionsViewPane.find', - title: localize2('find', "Find Session"), + title: localize2('findSessionByTitle', "Find Session by Title"), icon: Codicon.search, category: SessionsCategories.Sessions, + f1: true, + precondition: ChatContextKeys.enabled, }); } - override run(accessor: ServicesAccessor) { + override async run(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); - const view = viewsService.getViewWithId(SessionsViewId); + const view = await viewsService.openView(SessionsViewId, true); return view?.openFind(); } }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts index c33adac689c501..27ade394a6b8a7 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { isIMenuItem, isISubmenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { isIMenuItem, isISubmenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -35,11 +36,32 @@ import { ISessionSection, NEW_SESSION_FOR_WORKSPACE_ACTION_ID } from '../../brow import { ISelectWorkspaceOptions } from '../../../../browser/parts/chatView.js'; import { WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; import { MARK_SESSION_READ_COMMAND_ID, MARK_SESSION_UNREAD_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionSearch.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; suite('Sessions - Actions', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('uses the existing Sessions header search button without a duplicate overflow entry', () => { + const entries = MenuRegistry.getMenuItems(Menus.SidebarSessionsHeader).filter(isIMenuItem) + .filter(item => item.command.id === SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID || item.command.id === 'sessionsViewPane.find'); + assert.deepStrictEqual(entries.map(item => ({ + id: item.command.id, icon: item.command.icon, group: item.group, order: item.order, when: item.when?.serialize(), + })), [{ + id: SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID, icon: Codicon.search, group: 'navigation', order: 20, when: ChatContextKeys.enabled.serialize(), + }]); + }); + + test('keeps title-only session find available in the Command Palette', () => { + const entry = MenuRegistry.getMenuItems(MenuId.CommandPalette).filter(isIMenuItem) + .find(item => item.command.id === 'sessionsViewPane.find'); + assert.deepStrictEqual({ + title: entry && (typeof entry.command.title === 'string' ? entry.command.title : entry.command.title.value), + when: entry?.when?.serialize(), + }, { title: 'Find Session by Title', when: ChatContextKeys.enabled.serialize() }); + }); + test('contributes New Chat to the session header overflow', () => { const action = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) .filter(isIMenuItem) diff --git a/src/vs/workbench/api/browser/mainThreadEmbeddings.ts b/src/vs/workbench/api/browser/mainThreadEmbeddings.ts index de8c19a2226e4f..26499d3c5df344 100644 --- a/src/vs/workbench/api/browser/mainThreadEmbeddings.ts +++ b/src/vs/workbench/api/browser/mainThreadEmbeddings.ts @@ -4,72 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; -import { InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; import { ExtHostContext, ExtHostEmbeddingsShape, MainContext, MainThreadEmbeddingsShape } from '../common/extHost.protocol.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; - - -interface IEmbeddingsProvider { - provideEmbeddings(input: string[], token: CancellationToken): Promise<{ values: number[] }[]>; -} - -const IEmbeddingsService = createDecorator('embeddingsService'); - -interface IEmbeddingsService { - - _serviceBrand: undefined; - - readonly onDidChange: Event; - - allProviders: Iterable; - - registerProvider(id: string, provider: IEmbeddingsProvider): IDisposable; - - computeEmbeddings(id: string, input: string[], token: CancellationToken): Promise<{ values: number[] }[]>; -} - -class EmbeddingsService implements IEmbeddingsService { - _serviceBrand: undefined; - - private providers: Map; - - private readonly _onDidChange = new Emitter(); - readonly onDidChange: Event = this._onDidChange.event; - - constructor() { - this.providers = new Map(); - } - - get allProviders(): Iterable { - return this.providers.keys(); - } - - registerProvider(id: string, provider: IEmbeddingsProvider): IDisposable { - this.providers.set(id, provider); - this._onDidChange.fire(); - return { - dispose: () => { - this.providers.delete(id); - this._onDidChange.fire(); - } - }; - } - - computeEmbeddings(id: string, input: string[], token: CancellationToken): Promise<{ values: number[] }[]> { - const provider = this.providers.get(id); - if (provider) { - return provider.provideEmbeddings(input, token); - } else { - return Promise.reject(new Error(`No embeddings provider registered with id: ${id}`)); - } - } -} - - -registerSingleton(IEmbeddingsService, EmbeddingsService, InstantiationType.Delayed); +import { IEmbeddingsService } from '../../services/embeddings/common/embeddingsService.js'; @extHostNamedCustomer(MainContext.MainThreadEmbeddings) export class MainThreadEmbeddings implements MainThreadEmbeddingsShape { @@ -83,10 +21,9 @@ export class MainThreadEmbeddings implements MainThreadEmbeddingsShape { @IEmbeddingsService private readonly embeddingsService: IEmbeddingsService ) { this._proxy = context.getProxy(ExtHostContext.ExtHostEmbeddings); - - this._store.add(embeddingsService.onDidChange((() => { + this._store.add(embeddingsService.onDidChange(() => { this._proxy.$acceptEmbeddingModels(Array.from(embeddingsService.allProviders)); - }))); + })); } dispose(): void { @@ -95,9 +32,8 @@ export class MainThreadEmbeddings implements MainThreadEmbeddingsShape { $registerEmbeddingProvider(handle: number, identifier: string): void { const registration = this.embeddingsService.registerProvider(identifier, { - provideEmbeddings: (input: string[], token: CancellationToken): Promise<{ values: number[] }[]> => { - return this._proxy.$provideEmbeddings(handle, input, token); - } + provideEmbeddings: (input: string[], token: CancellationToken): Promise<{ values: number[] }[]> => + this._proxy.$provideEmbeddings(handle, input, token), }); this._providers.set(handle, registration); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index ac0f48d982dcdb..1eda34776625e2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -74,6 +74,9 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui : localize('chat.sessionArchiveNudge', "An archive suggestion appears above the chat input when the session's pull requests are merged. Use Tab or Shift+Tab to reach Archive, Configure Automatic Cleanup, or Dismiss Archive Suggestion, then press Enter or Space. Configure Automatic Cleanup opens the settings for automatically archiving inactive merged sessions and permanently deleting automatically archived merged sessions. The explanation under What Does \"Archive\" Do? is collapsed by default. Use Tab to reach it and Enter or Space to expand or collapse it. Dismissing the suggestion, including with Escape while it is focused, returns to the chat input. Archiving the session hides it from the sessions list so you can focus on your remaining tasks. The session is not deleted. Ask your agent to find it, or look in the \"Archived\" section of the sessions list. You can unarchive it anytime. The worktree created for the session, if any, will be deleted. You can recreate it by unarchiving the session.")); } if (type === 'panelChat' || type === 'quickChat' || type === 'editsView' || type === 'agentView') { + content.push(localize('chat.sessionContentSearch', "Use the search button in the sessions toolbar or run Chat: Search Agent Session Content (Preview) from the Command Palette to search saved Copilot user and assistant messages using literal terms. Search is limited to the open workspace in the editor window. An empty editor window searches all projects on connected hosts. Archived sessions are included within that scope, without opening chats. Type a query, use Up and Down Arrow to choose a result, and press Enter to open its chat at the matching message. Escape closes search and returns focus. For title-only filtering, run Chat: Find Agent Session by Title. Unsupported hosts and search failures are reported in the picker.")); + content.push(localize('chat.semanticSessionContentSearch', "Session content search uses keywords by default. Use Tab or Shift+Tab to reach Enable Semantic Search, the sparkle toggle, and press Enter or Space to find related messages by meaning as well. Select a registered Copilot embeddings provider if prompted, then confirm before your queries and saved user and assistant messages in the search scope are sent to that provider. Vectors are stored locally by each host. Cancel sends nothing to the provider. Permission applies only to this open search; closing search or changing the workspace revokes it. Editing the query cancels pending work. Activate the toggle again to return to keyword search. Results announce Keyword match, Semantic match, or Keyword and semantic match. The picker reports incomplete semantic coverage and explicitly falls back to keyword results when semantic search is unavailable.")); + content.push(localize('chat.semanticSessionSearchBudget', "The search picker title identifies Keyword or Keyword and Semantic mode. The first semantic pass may take time and use the provider's quota. Each query embeds at most 2048 document chunks across all sessions, plus one query embedding. Unchanged cached chunks are not re-embedded. The picker reports document embedding budget usage. When the budget is exhausted, search still uses cached vectors and reports incomplete semantic coverage.")); content.push(localize('chat.modelPicker.optimizeFor', "In the tabbed model picker, the selected model's details open beside the list. Moving between model rows updates the details immediately without selecting a model. The card stays visible when the pointer leaves a row. Press Right Arrow from a model row to focus its details, and Left Arrow to return. When Auto's \"Optimize for\" options are available, Efficiency, Balance, and Intelligence remain visible while Auto is off. They look muted while off but remain interactive. Use arrow keys to move between options, then Enter or Space to choose a preference and turn Auto on. Turning Auto off preserves the selected preference.")); content.push(localize('chat.modelPicker.resetToDefault', "In a model's details, use Tab to reach thinking effort, context, and model actions. Use arrow keys to move between options, then Enter or Space to choose one. Reset to Default appears beside Pin Model when thinking effort or context has been changed. It restores both settings to the model's defaults without changing its pinned state. Choosing an option or resetting the settings selects that model and keeps its details open. Pinning or unpinning moves the model in the list without moving its details or keyboard focus. Press Escape to return to the list, and Escape again to close the picker.")); content.push(localize('chat.modelPicker.pricingDetails', "Pricing Details expands in place without moving the model's controls. Expansion and collapse are immediate when reduced motion is enabled. If the details exceed the available space, use Page Up or Page Down while the model details have focus to scroll.")); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts index 5efb25e21ee301..7a94a07f762713 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts @@ -33,6 +33,7 @@ import { CopilotConfigSlashSubmitHandlerContribution } from './copilotConfigSlas import { primeLegacyMigrationStartupSnapshot } from './agentHostLegacyMigration.js'; import './agentHostSettings.contribution.js'; import './agentSessionSettings.contribution.js'; +import './agentHostSessionSearch.js'; /** * Freezes the legacy-migration setting at startup so enabling it only takes effect 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 ce2c77d8383645..a066be8bde3b66 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -11,7 +11,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import { ActionType, type IIsArchivedChangedAction, type IIsReadChangedAction, type INotification, type SessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { readSessionMatchesByProjectRoot, readSessionMultiRootMetadata, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { IWorkspaceContextService, type IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceContextService, type IWorkspace } from '../../../../../../platform/workspace/common/workspace.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -382,7 +382,7 @@ export class AgentHostSessionListStore extends Disposable { /** Uses workspace-file provenance for multi-root workspaces and path containment otherwise. */ private _isSessionInWorkspace(entry: IAgentHostSessionListEntry): boolean { - const inWorkspace = this._computeSessionInWorkspace(entry); + const inWorkspace = isAgentHostSessionInWorkspace(entry.summary, this._workspaceContextService.getWorkspace()); // A legacy session is matched by its repository root, which must be a local // path; a remote project (e.g. an `https://` repo URL) silently matches // nothing. Excluding one is legitimate, so only report the broken input, and @@ -394,28 +394,6 @@ export class AgentHostSessionListStore extends Disposable { return inWorkspace; } - private _computeSessionInWorkspace(entry: IAgentHostSessionListEntry): boolean { - const workingDirectories = this._containmentCandidates(entry.summary); - const workspace = this._workspaceContextService.getWorkspace(); - const folders = workspace.folders; - const configuration = workspace.configuration; - const multiRoot = readSessionMultiRootMetadata(entry.summary._meta); - if (multiRoot) { - // A multi-root window matches strictly by workspace-file identity so two - // different `.code-workspace` files that share a folder don't cross over. - if (URI.isUri(configuration)) { - return extUriBiasedIgnorePathCase.isEqual(URI.parse(multiRoot.workspaceFile), configuration); - } - // An empty window shows every session; a single-folder (or other - // non-multi-root) window falls back to working-directory containment. - return folders.length === 0 || this._matchesAnyFolder(workingDirectories, folders); - } - if (folders.length === 0) { - return true; - } - return this._matchesAnyFolder(workingDirectories, folders); - } - private _filterEntriesToWorkspace(): void { // The retained projection can only narrow; a successful refresh supplies newly eligible sessions. const removed: IAgentHostSessionListRemoval[] = []; @@ -432,34 +410,6 @@ export class AgentHostSessionListStore extends Disposable { } } - private _matchesAnyFolder(workingDirectories: readonly URI[], folders: readonly IWorkspaceFolder[]): boolean { - return workingDirectories.some(directory => - folders.some(folder => extUriBiasedIgnorePathCase.isEqualOrParent(directory, folder.uri)) - ); - } - - /** - * The directories a session may be matched against a workspace folder by: its - * working directories plus - for legacy Copilot CLI sessions only - its - * server-owned project (repository) root. Those legacy sessions run out of a - * `copilot-worktrees/` directory outside the repository, so working - * directories alone would hide them from a window opened on that repository. - * The marker has to outlive adoption: a migrated session is still a legacy - * session and must not drop out of the list the moment it migrates. - */ - private _containmentCandidates(summary: SessionSummary): readonly URI[] { - const candidates = summary.workingDirectories?.map(directory => URI.parse(directory)) ?? []; - if (summary.project?.uri && readSessionMatchesByProjectRoot(summary._meta)) { - const project = URI.parse(summary.project.uri); - // A project can be a remote (e.g. `https://github.com/owner/repo`), whose - // `fsPath` is not a location on disk and would silently never match. - if (project.scheme === Schemas.file) { - candidates.push(project); - } - } - return candidates; - } - private _toRemoval(entry: IAgentHostSessionListEntry): IAgentHostSessionListRemoval { return { provider: entry.provider, @@ -472,3 +422,24 @@ export class AgentHostSessionListStore extends Disposable { return `${provider}://${rawId}`; } } + +/** Matches workspace-file provenance first, otherwise folder containment with legacy worktree support. */ +export function isAgentHostSessionInWorkspace(summary: Pick, workspace: IWorkspace): boolean { + const multiRoot = readSessionMultiRootMetadata(summary._meta); + if (multiRoot && URI.isUri(workspace.configuration)) { + return extUriBiasedIgnorePathCase.isEqual(URI.parse(multiRoot.workspaceFile), workspace.configuration); + } + if (workspace.folders.length === 0) { + return true; + } + const candidates = summary.workingDirectories?.map(directory => URI.parse(directory)) ?? []; + if (summary.project?.uri && readSessionMatchesByProjectRoot(summary._meta)) { + const project = URI.parse(summary.project.uri); + if (project.scheme === Schemas.file) { + candidates.push(project); + } + } + return candidates.some(directory => + workspace.folders.some(folder => extUriBiasedIgnorePathCase.isEqualOrParent(directory, folder.uri)) + ); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionSearch.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionSearch.ts new file mode 100644 index 00000000000000..2e03b88773c42f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionSearch.ts @@ -0,0 +1,611 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { status } from '../../../../../../base/browser/ui/aria/aria.js'; +import { raceTimeout, RunOnceScheduler } from '../../../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { escapeIcons } from '../../../../../../base/common/iconLabels.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; +import { AgentSession, IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; +import { IAgentHostConnectionInfo, IAgentHostConnectionsService, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { MAX_SESSION_SEARCH_QUERY_LENGTH, getAgentSessionSearchTerms, IAgentSessionSearchMatch } from '../../../../../../platform/agentHost/common/agentHostSessionSearch.js'; +import { remoteAgentHostSessionTypeId } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { AGENT_HOST_SCHEME } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { supportsAgentHostSessionSearch } from '../../../../../../platform/agentHost/common/meta/agentHostSessionSearchMeta.js'; +import { DEFAULT_CHAT_ID, isSessionStatusArchived, parseChatUri } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../../../platform/quickinput/common/quickInput.js'; +import { IWorkspaceContextService, type IWorkspace } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IEmbeddingsService } from '../../../../../services/embeddings/common/embeddingsService.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { CHAT_CATEGORY } from '../../actions/chatActions.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; +import { ChatViewPaneTarget, IChatWidget, IChatWidgetService } from '../../chat.js'; +import { sessionOpenerRegistry } from '../agentSessionsOpener.js'; +import { isAgentHostSessionInWorkspace } from './agentHostSessionListStore.js'; +import { ISemanticSessionSearchOptions, MAX_SEMANTIC_SESSION_SEARCH_DOCUMENT_CHUNKS, mergeSessionSearchResults, SemanticSessionSearch, SemanticSessionSearchConsent, SessionSearchMatchSource } from './semanticSessionSearch.js'; + +export const SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID = 'workbench.action.chat.searchAgentSessionContent'; +export const SEARCH_AGENT_SESSION_CONTENT_TITLE = localize2('searchAgentSessionContent', "Search Agent Session Content (Preview)"); +const searchableProvider = 'copilotcli'; + +interface ISearchSession { + readonly host: IAgentHostConnectionInfo; + readonly metadata: IAgentSessionMetadata; +} + +export interface IAgentHostSessionSearchItem extends IQuickPickItem { + readonly session: ISearchSession; + readonly match: IAgentSessionSearchMatch; + readonly resource: URI; + readonly semanticScore?: number; +} + +interface ISearchState { + readonly items: readonly IAgentHostSessionSearchItem[]; + readonly busy: boolean; + readonly scanned: number; + readonly total: number; + readonly failures: number; + readonly unavailableHosts: readonly string[]; + readonly hasMore: boolean; + readonly message?: string; + readonly semantic?: { readonly scanned: number; readonly unavailable: number; readonly incomplete: number }; + readonly semanticBudget?: { readonly used: number; readonly exhausted: boolean }; +} + +function plainText(value: string): string { + return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function searchItemKey(item: IAgentHostSessionSearchItem): string { + return JSON.stringify([item.session.host.authority, item.match.chat, item.match.turnId, item.match.role]); +} + +/** Empty editor windows and the Agents window search across workspaces. */ +export function getAgentHostSessionSearchWorkspace(workspace: IWorkspace, isSessionsWindow: boolean): IWorkspace | undefined { + return !isSessionsWindow && workspace.folders.length > 0 + ? { ...workspace, folders: [...workspace.folders] } + : undefined; +} + +function isSearchHostInWorkspace(host: IAgentHostConnectionInfo, workspace: IWorkspace): boolean { + return workspace.folders.some(folder => folder.uri.scheme === AGENT_HOST_SCHEME + ? folder.uri.authority === host.authority + : host.isAmbient); +} + +function isSearchSessionInWorkspace(session: IAgentSessionMetadata, workspace: IWorkspace): boolean { + return isAgentHostSessionInWorkspace({ + workingDirectories: session.workingDirectories?.map(directory => directory.toString()), + project: session.project ? { ...session.project, uri: session.project.uri.toString() } : undefined, + _meta: session._meta, + }, workspace); +} + +export function createAgentHostSessionSearchItem(session: ISearchSession, match: IAgentSessionSearchMatch, source?: SessionSearchMatchSource): IAgentHostSessionSearchItem | undefined { + const chat = parseChatUri(match.chat); + if (!chat || !isEqual(URI.parse(chat.session), session.metadata.session)) { + return undefined; + } + const provider = AgentSession.provider(session.metadata.session); + if (provider !== searchableProvider) { + return undefined; + } + const scheme = session.host.isAmbient ? `${LOCAL_AGENT_HOST_SCHEME_PREFIX}${provider}` : remoteAgentHostSessionTypeId(session.host.authority, provider); + const resource = session.metadata.session.with({ scheme, fragment: chat.chatId === DEFAULT_CHAT_ID ? '' : chat.chatId }); + const title = plainText(session.metadata.summary ?? '') || localize('search.untitled', "Untitled session"); + const author = match.role === 'user' ? localize('search.user', "User") : localize('search.assistant', "Assistant"); + const project = plainText(session.metadata.project?.displayName ?? '') || localize('search.noProject', "No project"); + const host = plainText(session.host.name); + let description = isSessionStatusArchived(session.metadata.status) + ? localize('search.archivedDescription', "{0} · {1} · {2} · Archived", author, host, project) + : localize('search.description', "{0} · {1} · {2}", author, host, project); + if (source) { + const kind = source === 'both' ? localize('search.bothMatch', "Keyword and semantic match") + : source === 'semantic' ? localize('search.semanticMatch', "Semantic match") : localize('search.keywordMatch', "Keyword match"); + description = localize('search.matchDescription', "{0} · {1}", description, kind); + } + const snippet = plainText(match.snippet); + return { + label: escapeIcons(title), + description: escapeIcons(description), + detail: escapeIcons(snippet), + ariaLabel: localize('search.resultAria', "{0}, {1}, {2}", title, description, snippet), + alwaysShow: true, + session, + match, + resource, + }; +} + +/** Serializes query generations while allowing at most four session requests in a generation. */ +export class AgentHostSessionSearch extends Disposable { + private readonly scheduler = this._register(new RunOnceScheduler(() => this.start(), 300)); + private readonly cancellation = this._register(new MutableDisposable()); + private generation = 0; + private pending: string | undefined; + private running = false; + private disposed = false; + + constructor( + private readonly connections: () => readonly IAgentHostConnectionInfo[], + private readonly update: (state: ISearchState) => void, + private readonly logService: ILogService, + private readonly getWorkspace: () => IWorkspace | undefined = () => undefined, + private readonly getSemanticOptions: () => ISemanticSessionSearchOptions | undefined = () => undefined, + ) { + super(); + } + + setQuery(value: string): void { + this.cancellation.value?.cancel(); + this.cancellation.clear(); + this.generation++; + this.pending = value.trim() || undefined; + this.scheduler.cancel(); + let message: string | undefined; + if (this.pending && this.pending.length > MAX_SESSION_SEARCH_QUERY_LENGTH) { + message = localize('search.queryTooLong', "Use at most {0} characters to search saved messages.", MAX_SESSION_SEARCH_QUERY_LENGTH); + this.pending = undefined; + } else if (this.pending && getAgentSessionSearchTerms(this.pending).length === 0) { + message = localize('search.noTerms', "Enter a word or number to search saved messages."); + this.pending = undefined; + } + this.update({ items: [], busy: !!this.pending, scanned: 0, total: 0, failures: 0, unavailableHosts: [], hasMore: false, message }); + if (this.pending) { + this.scheduler.schedule(); + } + } + + override dispose(): void { + this.cancellation.value?.cancel(); + this.disposed = true; + this.generation++; + this.pending = undefined; + super.dispose(); + } + + private start(): void { + if (this.running || this.disposed || !this.pending) { + return; + } + const query = this.pending; + const generation = this.generation; + this.pending = undefined; + this.running = true; + const cancellation = new CancellationTokenSource(); + this.cancellation.value = cancellation; + void this.search(query, generation, cancellation.token).finally(() => { + if (this.cancellation.value === cancellation) { + this.cancellation.clear(); + } + this.running = false; + if (!this.scheduler.isScheduled()) { + this.start(); + } + }); + } + + private async search(query: string, generation: number, token: CancellationToken): Promise { + const current = () => !this.disposed && generation === this.generation; + const workspace = this.getWorkspace(); + const items: IAgentHostSessionSearchItem[] = []; + const semanticItems: IAgentHostSessionSearchItem[] = []; + const keywordKeys = new Set(); + const options = this.getSemanticOptions(); + const semanticSearch = options && new SemanticSessionSearch(query, options, token); + const semantic = semanticSearch ? { scanned: 0, unavailable: 0, incomplete: 0 } : undefined; + const sessions: ISearchSession[] = []; + const unavailableHosts: string[] = []; + let scanned = 0; + let failures = 0; + let hasMore = false; + const publish = (busy: boolean) => { + if (current()) { + const merged = semanticSearch + ? mergeSessionSearchResults(items, semanticItems, searchItemKey) + .map(({ item, source }) => createAgentHostSessionSearchItem(item.session, item.match, source)!) + : items; + this.update({ + items: merged.slice(0, 100), busy, scanned, total: sessions.length, failures, unavailableHosts: [...unavailableHosts], + hasMore: hasMore || merged.length > 100, semantic: semantic && { ...semantic }, + semanticBudget: semanticSearch && { used: semanticSearch.documentChunksUsed, exhausted: semanticSearch.budgetExhausted }, + }); + } + }; + for (const host of this.connections()) { + if (!current()) { + return; + } + if (workspace && !isSearchHostInWorkspace(host, workspace)) { + continue; + } + const connection = host.connection; + if (!connection) { + continue; + } + const initialized = connection.initializeResult.get(); + if (!connection.searchSessionHistory || (!connection.supportsSessionHistorySearch && initialized && !supportsAgentHostSessionSearch(initialized))) { + unavailableHosts.push(plainText(host.name)); + continue; + } + try { + const catalog = await connection.listSessions(); + if (!current()) { + return; + } + const supported = connection.supportsSessionHistorySearch + ? await connection.supportsSessionHistorySearch() + : supportsAgentHostSessionSearch(connection.initializeResult.get()); + if (!current()) { + return; + } + if (supported) { + sessions.push(...catalog.filter(metadata => AgentSession.provider(metadata.session) === searchableProvider + && (!workspace || isSearchSessionInWorkspace(metadata, workspace))).map(metadata => ({ metadata, host }))); + } else { + unavailableHosts.push(plainText(host.name)); + } + } catch (error) { + failures++; + this.logFailure('catalog', host, undefined, error); + } + publish(true); + } + let next = 0; + const worker = async () => { + while (current() && next < sessions.length && (semanticSearch || items.length < 100)) { + const session = sessions[next++]; + let refreshed = false; + try { + const result = await session.host.connection!.searchSessionHistory!(session.metadata.session, query); + if (!current()) { + return; + } + hasMore ||= result.hasMore; + refreshed = true; + for (const match of result.matches) { + if (items.length === 100) { + hasMore = true; + break; + } + const item = createAgentHostSessionSearchItem(session, match); + if (item) { + if (semanticSearch && keywordKeys.has(searchItemKey(item))) { + continue; + } + items.push(item); + keywordKeys.add(searchItemKey(item)); + } + } + } catch (error) { + if (!current()) { + return; + } + failures++; + this.logFailure('session', session.host, session.metadata.session, error); + } + if (semanticSearch && semantic) { + publish(true); + try { + if (!refreshed) { + throw new Error('Session index could not be refreshed'); + } + const result = await semanticSearch.search(session.host.connection!, session.metadata.session); + if (!current()) { + return; + } + hasMore ||= result.hasMore; + if (result.incomplete) { + semantic.incomplete++; + } + for (const match of result.matches) { + const item = createAgentHostSessionSearchItem(session, match); + if (item) { + const existing = semanticItems.findIndex(candidate => searchItemKey(candidate) === searchItemKey(item)); + if (existing < 0) { + semanticItems.push({ ...item, semanticScore: match.score }); + } else if (semanticItems[existing].semanticScore! < match.score) { + semanticItems[existing] = { ...item, semanticScore: match.score }; + } + } + } + semanticItems.sort((a, b) => b.semanticScore! - a.semanticScore!); + if (semanticItems.length > 100) { + hasMore = true; + semanticItems.length = 100; + } + } catch (error) { + if (!current()) { + return; + } + semantic.unavailable++; + semantic.incomplete++; + this.logFailure('semantic', session.host, session.metadata.session, error); + } + semantic.scanned++; + } + scanned++; + publish(true); + } + }; + await Promise.all(Array.from({ length: Math.min(4, sessions.length) }, () => worker())); + hasMore ||= next < sessions.length; + publish(false); + } + + private logFailure(stage: string, host: IAgentHostConnectionInfo, session: URI | undefined, error: unknown): void { + // Server error messages can contain search terms or saved conversation text. + this.logService.warn('[AgentHostSessionSearch]', stage, host.authority, session?.toString(), error instanceof Error ? error.name : 'Error'); + } +} + +/** Reveals the exact turn and role; missing turns must not redirect to another message with similar text. */ +export function revealAgentHostSessionSearchMatch(widget: IChatWidget, match: IAgentSessionSearchMatch): boolean { + const candidates = widget.viewModel?.getItems().filter(item => match.role === 'user' ? isRequestVM(item) : isResponseVM(item)) ?? []; + const item = candidates.find(item => item.id === match.turnId || (isResponseVM(item) && item.requestId === match.turnId)); + if (!item) { + return false; + } + widget.reveal(item); + widget.focus(item); + return true; +} + +export async function openAgentHostSessionSearchResult(item: IAgentHostSessionSearchItem, openChat: (resource: URI) => Promise): Promise { + const widget = await openChat(item.resource); + if (!widget) { + throw new Error('Session search result could not be opened'); + } + return revealAgentHostSessionSearchMatch(widget, item.match); +} + +/** Sessions navigation updates the active chat before its widget finishes loading. */ +export async function waitForAgentHostSessionSearchWidget(item: IAgentHostSessionSearchItem, chatWidgetService: IChatWidgetService, connectionsService: IAgentHostConnectionsService): Promise { + const store = new DisposableStore(); + try { + return await raceTimeout(new Promise(resolve => { + const check = () => { + const widget = chatWidgetService.getAllWidgets().find(widget => { + const resource = widget.viewModel?.sessionResource; + const identity = resource && connectionsService.resolveSessionResourceIdentity(resource); + return identity?.connectionAuthority === item.session.host.authority + && isEqual(identity.backendSession, item.session.metadata.session) + && (resource?.fragment || DEFAULT_CHAT_ID) === (item.resource.fragment || DEFAULT_CHAT_ID); + }); + if (widget) { + resolve(widget); + } + }; + store.add(chatWidgetService.onDidAddWidget(widget => { + store.add(widget.onDidChangeViewModel(check)); + check(); + })); + for (const widget of chatWidgetService.getAllWidgets()) { + store.add(widget.onDidChangeViewModel(check)); + } + check(); + }), 10_000); + } finally { + store.dispose(); + } +} + +registerAction2(class SearchAgentSessionContentAction extends Action2 { + constructor() { + super({ + id: SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID, + title: SEARCH_AGENT_SESSION_CONTENT_TITLE, + icon: Codicon.search, + category: CHAT_CATEGORY, + f1: true, + precondition: ChatContextKeys.enabled, + menu: { id: MenuId.AgentSessionsToolbar, group: 'navigation', order: 2, when: ChatContextKeys.enabled }, + }); + } + + override run(accessor: ServicesAccessor): void { + const quickInputService = accessor.get(IQuickInputService); + const connectionsService = accessor.get(IAgentHostConnectionsService); + const chatWidgetService = accessor.get(IChatWidgetService); + const chatSessionsService = accessor.get(IChatSessionsService); + const notificationService = accessor.get(INotificationService); + const logService = accessor.get(ILogService); + const workspaceContextService = accessor.get(IWorkspaceContextService); + const environmentService = accessor.get(IWorkbenchEnvironmentService); + const embeddingsService = accessor.get(IEmbeddingsService); + const dialogService = accessor.get(IDialogService); + const getWorkspace = () => getAgentHostSessionSearchWorkspace(workspaceContextService.getWorkspace(), environmentService.isSessionsWindow); + const getScopeLabel = () => getWorkspace() + ? localize('search.workspaceScope', "Copilot · Current workspace · Archived sessions included") + : localize('search.scope', "Copilot · All projects on connected hosts · Archived sessions included"); + const store = new DisposableStore(); + const consent = store.add(new SemanticSessionSearchConsent()); + const picker = store.add(quickInputService.createQuickPick()); + let semanticNotice = ''; + let enablingSemantic = false; + const updateSemanticButton = () => { + const enabled = !!consent.approved; + picker.title = enabled + ? localize('search.hybridTitle', "{0} — Keyword and Semantic", SEARCH_AGENT_SESSION_CONTENT_TITLE.value) + : localize('search.keywordTitle', "{0} — Keyword", SEARCH_AGENT_SESSION_CONTENT_TITLE.value); + picker.buttons = [{ + iconClass: ThemeIcon.asClassName(Codicon.sparkle), + tooltip: enabled + ? localize('search.disableSemantic', "Disable Semantic Search (Enabled for This Search: {0})", consent.approved!.providerId) + : localize('search.enableSemantic', "Enable Semantic Search (Sends Saved Messages to Copilot After Confirmation)"), + toggle: { checked: enabled }, + }]; + picker.placeholder = enabled + ? localize('search.semanticPlaceholder', "Search saved user and assistant messages by keyword or meaning") + : localize('search.placeholder', "Search saved user and assistant messages (all literal terms must match)"); + }; + picker.ariaLabel = localize('search.aria', "Search saved Copilot session messages"); + picker.description = getScopeLabel(); + picker.matchOnLabel = false; + picker.matchOnDescription = false; + picker.matchOnDetail = false; + picker.sortByLabel = false; + updateSemanticButton(); + let lastState: ISearchState | undefined; + const update = (state: ISearchState) => { + lastState = state; + const scope = getScopeLabel(); + picker.items = state.items; + picker.busy = state.busy; + if (!picker.value.trim()) { + picker.description = localize('search.idleStatus', "{0}\n{1}", scope, semanticNotice); + return; + } + const progress = state.message ?? localize('search.progress', "{0} results · {1}/{2} sessions scanned · {3} failures", state.items.length, state.scanned, state.total, state.failures); + const more = state.hasMore ? localize('search.more', " More matches may be available; refine your search.") : ''; + const unavailable = state.unavailableHosts.length + ? localize('search.unavailable', " Search unavailable on {0}; connect or update the host and search again.", state.unavailableHosts.join(', ')) + : ''; + const semanticProgress = state.semantic + ? localize('search.semanticProgress', " Semantic coverage: {0}/{1} sessions checked · {2} incomplete. Document embedding budget: {3}/{4} chunks per query.", state.semantic.scanned, state.total, state.semantic.incomplete, state.semanticBudget?.used ?? 0, MAX_SEMANTIC_SESSION_SEARCH_DOCUMENT_CHUNKS) + : ''; + const budget = state.semanticBudget?.exhausted + ? localize('search.semanticBudgetExhausted', " Embedding budget exhausted; searching cached vectors. Semantic coverage is incomplete.") + : ''; + const semanticUnavailable = state.semantic?.unavailable + ? localize('search.semanticUnavailableSessions', " Semantic unavailable; showing keyword results for {0} sessions.", state.semantic.unavailable) + : semanticNotice; + picker.description = localize('search.status', "{0}\n{1}{2}{3}{4}{5}{6}", scope, progress, more, unavailable, semanticProgress, semanticUnavailable, budget); + if (!state.busy) { + status(localize('search.announcement', "{0}{1}{2}{3}{4}{5}", progress, more, unavailable, semanticProgress, semanticUnavailable, budget)); + } + }; + const search = store.add(new AgentHostSessionSearch(() => connectionsService.connections, update, logService, getWorkspace, () => consent.approved)); + store.add(picker.onDidTriggerButton(async () => { + if (enablingSemantic) { + consent.cancelPending(); + return; + } + semanticNotice = ''; + if (consent.approved) { + consent.revoke(); + updateSemanticButton(); + search.setQuery(picker.value); + return; + } + enablingSemantic = true; + picker.ignoreFocusOut = true; + let restartSearch = false; + try { + const result = await consent.enable(embeddingsService, async (providers, token) => { + if (providers.length === 1) { + return providers[0]; + } + return (await dialogService.prompt({ + type: 'question', + message: localize('search.selectEmbeddingProvider', "Select a Copilot Embeddings Provider"), + detail: localize('search.selectEmbeddingProviderDetail', "You will be asked to confirm before any query or saved messages are sent."), + buttons: providers.map(provider => ({ label: provider, run: () => provider })), + cancelButton: true, + custom: true, + token, + })).result; + }, async (provider, token) => { + const scope = getWorkspace() + ? localize('search.consentWorkspace', "the current workspace") + : localize('search.consentAllProjects', "all projects on connected hosts"); + return (await dialogService.confirm({ + type: 'question', + message: localize('search.semanticConfirmation', "Enable Semantic Search for This Open Search?"), + detail: localize('search.semanticConfirmationDetail', "Your search queries and saved user and assistant messages in {0}, including archived sessions, will be sent to the selected Copilot embeddings provider: {1}. The first pass may take time and use the provider's quota. Each query embeds at most {2} document chunks across all sessions, plus one query embedding. Vectors are stored locally by each host; unchanged cached chunks are not re-embedded. After the budget is exhausted, search uses cached vectors and reports incomplete coverage. Permission lasts only while this search is open and is revoked when the workspace changes. Cancel sends nothing to the provider.", scope, provider, MAX_SEMANTIC_SESSION_SEARCH_DOCUMENT_CHUNKS), + primaryButton: localize('search.semanticConfirm', "Enable Semantic Search"), + custom: true, + token, + })).confirmed; + }); + if (result === 'unavailable') { + semanticNotice = localize('search.semanticUnavailableProvider', " Semantic unavailable; showing keyword results. Sign in to Copilot or enable a Copilot embeddings provider and try again."); + } + restartSearch = result === 'enabled'; + } catch { + semanticNotice = localize('search.semanticEnableFailed', " Semantic unavailable; showing keyword results."); + } finally { + enablingSemantic = false; + if (!store.isDisposed) { + picker.ignoreFocusOut = false; + updateSemanticButton(); + if (restartSearch) { + search.setQuery(picker.value); + } else if (lastState) { + update(lastState); + } else if (semanticNotice) { + picker.description = localize('search.idleStatus', "{0}\n{1}", getScopeLabel(), semanticNotice); + status(semanticNotice); + } + } + } + })); + store.add(Event.any( + workspaceContextService.onDidChangeWorkspaceFolders, + workspaceContextService.onDidChangeWorkbenchState, + workspaceContextService.onDidChangeWorkspaceName, + )(() => { + const wasEnabled = !!consent.approved; + consent.revoke(); + semanticNotice = wasEnabled + ? localize('search.semanticScopeChanged', " Semantic search disabled because the workspace changed. Enable it again to confirm the new scope.") + : ''; + updateSemanticButton(); + search.setQuery(picker.value); + })); + store.add(embeddingsService.onDidChange(() => { + if (consent.approved && ![...embeddingsService.allProviders].includes(consent.approved.providerId)) { + consent.revoke(); + semanticNotice = localize('search.semanticProviderRemoved', " Semantic unavailable; showing keyword results. The selected Copilot embeddings provider is no longer available."); + updateSemanticButton(); + search.setQuery(picker.value); + } + })); + store.add(picker.onDidChangeValue(value => { + consent.cancelPending(); + search.setQuery(value); + })); + store.add(picker.onDidHide(() => store.dispose())); + store.add(picker.onDidAccept(async () => { + const item = picker.selectedItems[0]; + if (!item) { + return; + } + picker.hide(); + try { + const revealed = await openAgentHostSessionSearchResult(item, async resource => { + for (const participant of sessionOpenerRegistry.getParticipants()) { + if (await participant.handleOpenSessionResource?.(accessor, resource.with({ fragment: resource.fragment || DEFAULT_CHAT_ID }))) { + return waitForAgentHostSessionSearchWidget(item, chatWidgetService, connectionsService); + } + } + await chatSessionsService.activateChatSessionItemProvider(resource.scheme); + return chatWidgetService.openSession(resource, ChatViewPaneTarget, { revealIfOpened: true }); + }); + if (!revealed) { + notificationService.info(localize('search.turnUnavailable', "The chat was opened, but the saved matching message could not be located. Use Find in the chat to search for the text.")); + } + } catch { + logService.warn('[AgentHostSessionSearch] Could not open result', item.session.host.authority, item.resource.toString()); + notificationService.warn(localize('search.openFailed', "Could not open the matching chat. The session may have been deleted or its host disconnected.")); + } + })); + picker.show(); + } +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/semanticSessionSearch.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/semanticSessionSearch.ts new file mode 100644 index 00000000000000..9fec21e9cd7b74 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/semanticSessionSearch.ts @@ -0,0 +1,216 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Limiter, raceCancellationError } from '../../../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../../base/common/errors.js'; +import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { ISessionEmbeddingModel, ISessionSemanticMatch, MAX_SESSION_EMBEDDING_DIMENSIONS } from '../../../../../../platform/agentHost/common/sessionSemanticSearch.js'; +import { IEmbeddingsService } from '../../../../../services/embeddings/common/embeddingsService.js'; + +export interface ISemanticSessionSearchOptions { + readonly providerId: string; + readonly embeddingsService: IEmbeddingsService; +} + +/** Consent is owned by one open picker, never by a workspace or a provider. */ +export class SemanticSessionSearchConsent extends Disposable { + private readonly pending = this._register(new MutableDisposable()); + private options: ISemanticSessionSearchOptions | undefined; + + get approved(): ISemanticSessionSearchOptions | undefined { + return this.options; + } + + async enable( + embeddingsService: IEmbeddingsService, + select: (providers: readonly string[], token: CancellationToken) => Promise, + confirm: (provider: string, token: CancellationToken) => Promise, + ): Promise<'enabled' | 'cancelled' | 'unavailable'> { + this.revoke(); + const source = new CancellationTokenSource(); + this.pending.value = source; + const token = source.token; + try { + const providers = [...embeddingsService.allProviders].filter(id => id.startsWith('copilot.')); + if (!providers.length) { + return 'unavailable'; + } + const providerId = await select(providers, token); + if (token.isCancellationRequested || !providerId || !providers.includes(providerId)) { + return 'cancelled'; + } + if (!await confirm(providerId, token) || token.isCancellationRequested) { + return 'cancelled'; + } + if (![...embeddingsService.allProviders].includes(providerId)) { + return 'unavailable'; + } + this.options = { providerId, embeddingsService }; + return 'enabled'; + } finally { + if (this.pending.value === source) { + this.pending.clear(); + } + } + } + + cancelPending(): void { + this.pending.value?.cancel(); + this.pending.clear(); + } + + revoke(): void { + this.options = undefined; + this.cancelPending(); + } + + override dispose(): void { + this.revoke(); + super.dispose(); + } +} + +const embeddingRequests = new Limiter<{ values: number[] }[]>(2); + +export const MAX_SEMANTIC_SESSION_SEARCH_DOCUMENT_CHUNKS = 2048; + +/** One instance per query generation shares its query vector across all session workers. */ +export class SemanticSessionSearch { + private queryEmbedding: Promise<{ model: ISessionEmbeddingModel; vector: readonly number[] }> | undefined; + private documentChunks = 0; + private exhausted = false; + + get documentChunksUsed(): number { + return this.documentChunks; + } + + get budgetExhausted(): boolean { + return this.exhausted; + } + + constructor( + private readonly query: string, + private readonly options: ISemanticSessionSearchOptions, + private readonly token: CancellationToken, + ) { } + + private checkCancellation(): void { + if (this.token.isCancellationRequested) { + throw new CancellationError(); + } + } + + private async embed(input: string[], dimensions?: number): Promise<{ values: number[] }[]> { + this.checkCancellation(); + const result = await raceCancellationError(embeddingRequests.queue(async () => { + this.checkCancellation(); + if (!this.options.providerId.startsWith('copilot.') || ![...this.options.embeddingsService.allProviders].includes(this.options.providerId)) { + throw new Error('Semantic embeddings provider unavailable'); + } + return this.options.embeddingsService.computeEmbeddings(this.options.providerId, input, this.token); + }), this.token); + this.checkCancellation(); + if (result.length !== input.length || result.some(embedding => !embedding.values.length + || embedding.values.length > MAX_SESSION_EMBEDDING_DIMENSIONS || !embedding.values.some(value => value !== 0) + || (dimensions !== undefined && embedding.values.length !== dimensions) + || embedding.values.some(value => !Number.isFinite(value)))) { + throw new Error('Invalid semantic embeddings'); + } + return result; + } + + private getQueryEmbedding(): Promise<{ model: ISessionEmbeddingModel; vector: readonly number[] }> { + return this.queryEmbedding ??= this.embed([this.query]).then(([embedding]) => ({ + model: { id: this.options.providerId, dimensions: embedding.values.length }, + vector: embedding.values, + })); + } + + async search(connection: IAgentConnection, session: URI): Promise<{ matches: readonly ISessionSemanticMatch[]; hasMore: boolean; incomplete: boolean }> { + this.checkCancellation(); + if (!connection.sessionSemanticSearch || !connection.supportsSessionSemanticSearch || !await connection.supportsSessionSemanticSearch()) { + throw new Error('Semantic session search unavailable'); + } + this.checkCancellation(); + const { model, vector } = await this.getQueryEmbedding(); + let incomplete = false; + for (let batch = 0; batch < 32; batch++) { + this.checkCancellation(); + const pending = await connection.sessionSemanticSearch(session, { kind: 'pending', model }); + this.checkCancellation(); + if (pending.kind !== 'pending') { + throw new Error('Invalid pending embeddings result'); + } + if (!pending.chunks.length) { + incomplete = pending.hasMore; + break; + } + const remaining = MAX_SEMANTIC_SESSION_SEARCH_DOCUMENT_CHUNKS - this.documentChunks; + const chunks = pending.chunks.slice(0, remaining); + const partial = chunks.length < pending.chunks.length; + if (partial) { + this.exhausted = true; + } + if (!chunks.length) { + incomplete = true; + break; + } + // Reserve the shared budget before awaiting other session workers. + this.documentChunks += chunks.length; + const embeddings = await this.embed(chunks.map(chunk => chunk.text), model.dimensions); + this.checkCancellation(); + const stored = await connection.sessionSemanticSearch(session, { + kind: 'store', model, + values: chunks.map((chunk, index) => ({ id: chunk.id, contentHash: chunk.contentHash, vector: embeddings[index].values })), + }); + this.checkCancellation(); + if (stored.kind !== 'store') { + throw new Error('Invalid stored embeddings result'); + } + incomplete = pending.hasMore || partial; + if (!pending.hasMore || partial) { + break; + } + } + this.exhausted ||= incomplete && this.documentChunks === MAX_SEMANTIC_SESSION_SEARCH_DOCUMENT_CHUNKS; + this.checkCancellation(); + const result = await connection.sessionSemanticSearch(session, { kind: 'search', model, vector }); + this.checkCancellation(); + if (result.kind !== 'search') { + throw new Error('Invalid semantic search result'); + } + return { matches: result.matches.filter(match => Number.isFinite(match.score)), hasMore: result.hasMore, incomplete: incomplete || result.incomplete }; + } +} + +export type SessionSearchMatchSource = 'keyword' | 'semantic' | 'both'; + +/** Reciprocal rank fusion uses ranks, not incomparable lexical and vector scores. */ +export function mergeSessionSearchResults(keyword: readonly T[], semantic: readonly T[], key: (item: T) => string): { item: T; source: SessionSearchMatchSource }[] { + const results = new Map(); + for (const [items, source] of [[keyword, 'keyword'], [semantic, 'semantic']] as const) { + const seen = new Set(); + let position = 0; + for (const item of items) { + const id = key(item); + if (seen.has(id)) { + continue; + } + seen.add(id); + const rank = 1 / (60 + ++position); + const existing = results.get(id); + if (existing) { + existing.rank += rank; + existing.source = 'both'; + } else { + results.set(id, { item, source, rank }); + } + } + } + return [...results.values()].sort((a, b) => b.rank - a.rank).map(({ item, source }) => ({ item, source })); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index d76f1a0099e1a0..6407a269eb153f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -26,7 +26,7 @@ import { showClearEditingSessionConfirmation } from '../widgetHosts/editor/chatE import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../../common/constants.js'; -import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; +import { ACTION_ID_NEW_CHAT, CHAT_CATEGORY } from '../actions/chatActions.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -1053,18 +1053,16 @@ export class FindAgentSessionInViewerAction extends Action2 { constructor() { super({ id: 'agentSessionsViewer.find', - title: localize2('find', "Find Agent Session"), + title: localize2('findAgentSessionByTitle', "Find Agent Session by Title"), icon: Codicon.search, - menu: { - id: MenuId.AgentSessionsToolbar, - group: 'navigation', - order: 2, - } + f1: true, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, IsSessionsWindowContext.negate()), }); } - override run(accessor: ServicesAccessor, agentSessionsControl?: IAgentSessionsControl) { - const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; + override async run(accessor: ServicesAccessor, agentSessionsControl?: IAgentSessionsControl) { + const control = agentSessionsControl ?? (await accessor.get(IViewsService).openView(ChatViewId, true))?.agentSessionsControl; if (control) { return control.openFind(); } else { diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index f2fd5155099ea5..8c952326627ce5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -14,6 +14,19 @@ import { AGENT_SESSION_RENAME_ACTION_ID } from '../../../browser/agentSessions/a suite('Chat Accessibility Help', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('documents saved session content search and keyboard navigation', () => { + const help = getAccessibilityHelpText('agentView', new MockKeybindingService(), true); + assert.deepStrictEqual({ + command: help.includes('Chat: Search Agent Session Content (Preview)'), + button: help.includes('search button in the sessions toolbar'), + titleFilter: help.includes('Chat: Find Agent Session by Title'), + scope: help.includes('limited to the open workspace in the editor window'), + emptyWindow: help.includes('An empty editor window searches all projects on connected hosts'), + accept: help.includes('press Enter to open its chat at the matching message'), + cancel: help.includes('Escape closes search and returns focus'), + }, { command: true, button: true, titleFilter: true, scope: true, emptyWindow: true, accept: true, cancel: true }); + }); + test('documents model details and activating Auto through Optimize for', () => { const help = getAccessibilityHelpText('agentView', new MockKeybindingService(), true); assert.deepStrictEqual({ @@ -25,6 +38,27 @@ suite('Chat Accessibility Help', () => { }, { details: true, immediatePreview: true, inactivePreferences: true, mutedPreferences: true, activation: true }); }); + test('documents opt-in semantic search, consent, cancellation and incomplete coverage', () => { + const help = getAccessibilityHelpText('agentView', new MockKeybindingService(), true); + assert.deepStrictEqual({ + default: help.includes('uses keywords by default'), + keyboard: help.includes('Use Tab or Shift+Tab to reach Enable Semantic Search, the sparkle toggle, and press Enter or Space'), + provider: help.includes('Select a registered Copilot embeddings provider'), + consent: help.includes('confirm before your queries and saved user and assistant messages in the search scope are sent'), + local: help.includes('Vectors are stored locally'), + decline: help.includes('Cancel sends nothing to the provider'), + lifetime: help.includes('closing search or changing the workspace revokes it'), + labels: help.includes('Keyword match, Semantic match, or Keyword and semantic match'), + incomplete: help.includes('incomplete semantic coverage'), + fallback: help.includes('falls back to keyword results'), + title: help.includes('title identifies Keyword or Keyword and Semantic mode'), + cost: help.includes('may take time and use the provider\'s quota'), + budget: help.includes('at most 2048 document chunks across all sessions, plus one query embedding'), + cache: help.includes('Unchanged cached chunks are not re-embedded'), + budgetExhausted: help.includes('When the budget is exhausted, search still uses cached vectors'), + }, { default: true, keyboard: true, provider: true, consent: true, local: true, decline: true, lifetime: true, labels: true, incomplete: true, fallback: true, title: true, cost: true, budget: true, cache: true, budgetExhausted: true }); + }); + test('documents keyboard search in the model picker', () => { const help = getAccessibilityHelpText('agentView', new MockKeybindingService(), true); assert.deepStrictEqual({ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionSearch.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionSearch.test.ts new file mode 100644 index 00000000000000..a3096e12dfdd36 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionSearch.test.ts @@ -0,0 +1,802 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import sinon from 'sinon'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostConnectionInfo, IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IAgentSessionSearchMatch, IAgentSessionSearchResult } from '../../../../../../platform/agentHost/common/agentHostSessionSearch.js'; +import { AgentHostSessionSearchCapabilityMetaKey } from '../../../../../../platform/agentHost/common/meta/agentHostSessionSearchMeta.js'; +import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; +import { buildChatUri, SessionStatus, SESSION_META_EHCLI_ADOPTABLE_KEY, withSessionMultiRootMetadata } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; +import { AgentHostSessionSearch, createAgentHostSessionSearchItem, getAgentHostSessionSearchWorkspace, IAgentHostSessionSearchItem, openAgentHostSessionSearchResult, SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID, waitForAgentHostSessionSearchWidget } from '../../../browser/agentSessions/agentHost/agentHostSessionSearch.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { IChatWidgetViewModelChangeEvent, IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; +import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel } from '../../../common/model/chatViewModel.js'; +import { IEmbeddingsService } from '../../../../../services/embeddings/common/embeddingsService.js'; +import { ISemanticSessionSearchOptions } from '../../../browser/agentSessions/agentHost/semanticSessionSearch.js'; +import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IQuickInputButton, IQuickInputService, IQuickPick, QuickInputHideReason } from '../../../../../../platform/quickinput/common/quickInput.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; + +suite('AgentHostSessionSearch', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let clock: sinon.SinonFakeTimers; + + setup(() => { clock = sinon.useFakeTimers(); }); + teardown(() => { clock.restore(); sinon.restore(); }); + + test('uses the existing editor sessions search button without a duplicate overflow entry', () => { + const entries = MenuRegistry.getMenuItems(MenuId.AgentSessionsToolbar).filter(isIMenuItem) + .filter(item => item.command.id === SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID || item.command.id === 'agentSessionsViewer.find'); + assert.deepStrictEqual(entries.map(item => ({ + id: item.command.id, icon: item.command.icon, group: item.group, order: item.order, when: item.when?.serialize(), + })), [{ + id: SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID, icon: Codicon.search, group: 'navigation', order: 2, when: ChatContextKeys.enabled.serialize(), + }]); + }); + + function metadata(id: string, provider = 'copilotcli'): IAgentSessionMetadata { + return { session: URI.from({ scheme: provider, path: `/${id}` }), summary: id, startTime: 0, modifiedTime: 0 }; + } + + function match(session: IAgentSessionMetadata, snippet = 'saved text', chatId = 'default'): IAgentSessionSearchMatch { + return { chat: buildChatUri(session.session, chatId), turnId: 'turn', role: 'user', snippet }; + } + + function host(overrides: Partial = {}, supported = true, authority = 'local'): IAgentHostConnectionInfo { + return { + authority, name: authority, isAmbient: authority === 'local', address: undefined, + connection: upcastPartial({ + initializeResult: observableValue('initialize', upcastPartial({ _meta: { [AgentHostSessionSearchCapabilityMetaKey]: supported } })), + listSessions: async () => [metadata('session')], + searchSessionHistory: async () => ({ matches: [], hasMore: false }), + ...overrides, + }), + }; + } + + function workspace(folders: URI[], configuration?: URI): IWorkspace { + return { + id: 'workspace', + configuration, + folders: folders.map((uri, index) => ({ uri, index, name: uri.path, toResource: relative => URI.joinPath(uri, relative) })), + }; + } + + test('scopes editor windows but keeps Agents and empty editor windows cross-workspace', () => { + const opened = workspace([URI.file('/workspace')]); + const scope = getAgentHostSessionSearchWorkspace(opened, false); + assert.deepStrictEqual({ + editor: scope?.folders.map(folder => folder.uri.toString()), + snapshot: scope?.folders !== opened.folders, + agents: getAgentHostSessionSearchWorkspace(opened, true), + empty: getAgentHostSessionSearchWorkspace(workspace([]), false), + }, { editor: ['file:///workspace'], snapshot: true, agents: undefined, empty: undefined }); + }); + + test('filters the workspace before requesting histories and includes archived sessions and secondary roots', async () => { + const folder = URI.file('/workspace/repo'); + const sessions: IAgentSessionMetadata[] = [ + { ...metadata('root'), workingDirectories: [folder] }, + { ...metadata('child'), workingDirectories: [URI.joinPath(folder, 'src')], status: SessionStatus.IsArchived }, + { ...metadata('secondary'), workingDirectories: [URI.file('/elsewhere'), folder] }, + { ...metadata('similar-prefix'), workingDirectories: [URI.file('/workspace/repository')] }, + { ...metadata('elsewhere'), workingDirectories: [URI.file('/elsewhere')] }, + metadata('no-workspace'), + ]; + const requested: string[] = []; + let result = { total: 0, scanned: 0, items: 0 }; + const search = store.add(new AgentHostSessionSearch(() => [host({ + listSessions: async () => sessions, + searchSessionHistory: async resource => { + requested.push(resource.path); + return { matches: [match(sessions.find(session => session.session.path === resource.path)!)], hasMore: false }; + }, + })], state => { result = { total: state.total, scanned: state.scanned, items: state.items.length }; }, new NullLogService(), () => workspace([folder]))); + search.setQuery('saved'); + await clock.tickAsync(300); + assert.deepStrictEqual({ requested, result }, { + requested: ['/root', '/child', '/secondary'], result: { total: 3, scanned: 3, items: 3 }, + }); + }); + + test('uses the existing workspace-file identity and legacy worktree matching rules', async () => { + const first = URI.file('/workspace/first'); + const second = URI.file('/workspace/second'); + const configuration = URI.file('/workspace/current.code-workspace'); + const sessions: IAgentSessionMetadata[] = [ + { ...metadata('same-workspace'), workingDirectories: [URI.file('/old-root')], _meta: withSessionMultiRootMetadata(undefined, { workspaceFile: configuration.toString() }) }, + { ...metadata('different-workspace'), workingDirectories: [first], _meta: withSessionMultiRootMetadata(undefined, { workspaceFile: URI.file('/workspace/other.code-workspace').toString() }) }, + { ...metadata('second-root'), workingDirectories: [second] }, + { ...metadata('legacy-worktree'), workingDirectories: [URI.file('/worktrees/branch')], project: { uri: first, displayName: 'first' }, _meta: { [SESSION_META_EHCLI_ADOPTABLE_KEY]: true } }, + { ...metadata('unrelated-project'), project: { uri: first, displayName: 'first' } }, + ]; + const requested: string[] = []; + const search = store.add(new AgentHostSessionSearch(() => [host({ + listSessions: async () => sessions, + searchSessionHistory: async resource => { requested.push(resource.path); return { matches: [], hasMore: false }; }, + })], () => { }, new NullLogService(), () => workspace([first, second], configuration))); + search.setQuery('saved'); + await clock.tickAsync(300); + assert.deepStrictEqual(requested, ['/same-workspace', '/second-root', '/legacy-worktree']); + }); + + test('respects remote workspace authorities and does not match another host by local path', async () => { + const folder = URI.parse('vscode-remote://ssh-remote+first/workspace'); + const configuration = URI.parse('vscode-remote://ssh-remote+first/current.code-workspace'); + const requested: string[] = []; + let unrelatedCatalogs = 0; + const search = store.add(new AgentHostSessionSearch(() => [ + host({ + listSessions: async () => [ + { ...metadata('matching'), workingDirectories: [folder] }, + { ...metadata('other-authority'), workingDirectories: [folder.with({ authority: 'ssh-remote+second' })] }, + { ...metadata('same-local-path'), workingDirectories: [URI.file('/workspace')] }, + ], + searchSessionHistory: async resource => { requested.push(resource.path); return { matches: [], hasMore: false }; }, + }), + host({ + listSessions: async () => { + unrelatedCatalogs++; + return [{ ...metadata('other-host'), workingDirectories: [folder], _meta: withSessionMultiRootMetadata(undefined, { workspaceFile: configuration.toString() }) }]; + }, + searchSessionHistory: async resource => { requested.push(resource.path); return { matches: [], hasMore: false }; }, + }, true, 'another-host'), + ], () => { }, new NullLogService(), () => workspace([folder], configuration))); + search.setQuery('saved'); + await clock.tickAsync(300); + assert.deepStrictEqual({ requested, unrelatedCatalogs }, { requested: ['/matching'], unrelatedCatalogs: 0 }); + }); + + test('can search a workspace folder provided by its matching remote agent host', async () => { + const folder = toAgentHostUri(URI.file('/workspace'), 'remote-host'); + const requested: string[] = []; + const search = store.add(new AgentHostSessionSearch(() => [host({ + listSessions: async () => [ + { ...metadata('matching'), workingDirectories: [folder] }, + { ...metadata('other-host'), workingDirectories: [toAgentHostUri(URI.file('/workspace'), 'different-host')] }, + ], + searchSessionHistory: async resource => { requested.push(resource.path); return { matches: [], hasMore: false }; }, + }, true, 'remote-host')], () => { }, new NullLogService(), () => workspace([folder]))); + search.setQuery('saved'); + await clock.tickAsync(300); + assert.deepStrictEqual(requested, ['/matching']); + }); + + test('workspace changes discard stale results and search only the new workspace', async () => { + const first = URI.file('/first'); + const second = URI.file('/second'); + let scope = workspace([first]); + const catalog = new DeferredPromise(); + const requested: string[] = []; + const search = store.add(new AgentHostSessionSearch(() => [host({ + listSessions: async () => catalog.p, + searchSessionHistory: async resource => { requested.push(resource.path); return { matches: [], hasMore: false }; }, + })], () => { }, new NullLogService(), () => scope)); + search.setQuery('saved'); + await clock.tickAsync(300); + scope = workspace([second]); + search.setQuery('saved'); + await clock.tickAsync(300); + await catalog.complete([ + { ...metadata('old'), workingDirectories: [first] }, + { ...metadata('new'), workingDirectories: [second] }, + ]); + await clock.tickAsync(0); + assert.deepStrictEqual(requested, ['/new']); + }); + + test('empty input performs no catalog or history requests; edits are debounced', async () => { + const calls: string[] = []; + const connection = host({ + listSessions: async () => { calls.push('catalog'); return [metadata('session')]; }, + searchSessionHistory: async (_session, query) => { calls.push(query); return { matches: [], hasMore: false }; }, + }); + const search = store.add(new AgentHostSessionSearch(() => [connection], () => { }, new NullLogService())); + search.setQuery(' '); + await clock.tickAsync(500); + search.setQuery('""...'); + await clock.tickAsync(500); + search.setQuery('x'.repeat(513)); + await clock.tickAsync(500); + search.setQuery('first'); + await clock.tickAsync(200); + search.setQuery('second'); + await clock.tickAsync(299); + assert.deepStrictEqual(calls, []); + await clock.tickAsync(1); + assert.deepStrictEqual(calls, ['catalog', 'second']); + }); + + test('skips unsupported hosts and non-Copilot sessions and reports failures', async () => { + const calls: string[] = []; + const oldHost = host({ listSessions: async () => { throw new Error('must not enumerate'); } }, false, 'old'); + const failedHost = host({ listSessions: async () => { throw new Error('unavailable'); } }, true, 'failed'); + const supportedHost = host({ + listSessions: async () => [metadata('yes'), metadata('no', 'claude'), metadata('mock-only', 'copilot'), metadata('broken')], + searchSessionHistory: async (session, query) => { + calls.push(`${session.path}:${query}`); + if (session.path === '/broken') { + throw new Error('unavailable'); + } + return { matches: [match(metadata('yes'))], hasMore: true }; + }, + }); + const states: { busy: boolean; scanned: number; total: number; failures: number; unavailableHosts: readonly string[]; hasMore: boolean }[] = []; + const search = store.add(new AgentHostSessionSearch(() => [oldHost, failedHost, supportedHost], state => states.push(state), new NullLogService())); + search.setQuery('literal'); + await clock.tickAsync(300); + const last = states.at(-1)!; + assert.deepStrictEqual({ + calls, busy: last.busy, scanned: last.scanned, total: last.total, + failures: last.failures, unavailable: last.unavailableHosts, hasMore: last.hasMore, + }, { calls: ['/yes:literal', '/broken:literal'], busy: false, scanned: 2, total: 2, failures: 2, unavailable: ['old'], hasMore: true }); + }); + + test('uses local management search even when protocol extension methods are disabled', async () => { + const calls: string[] = []; + const session = metadata('local-session'); + const connection = host({ + listSessions: async () => { calls.push('catalog'); return [session]; }, + supportsSessionHistorySearch: async () => { calls.push('management-support'); return true; }, + searchSessionHistory: async () => { + calls.push('management-search'); + return { matches: [match(session)], hasMore: false }; + }, + }, false); + let result = { count: 0, unavailable: [] as readonly string[], failures: 0 }; + const search = store.add(new AgentHostSessionSearch(() => [connection], state => { + result = { count: state.items.length, unavailable: state.unavailableHosts, failures: state.failures }; + }, new NullLogService())); + search.setQuery('saved'); + await clock.tickAsync(300); + assert.deepStrictEqual({ calls, result }, { + calls: ['catalog', 'management-support', 'management-search'], + result: { count: 1, unavailable: [], failures: 0 }, + }); + }); + + for (const supported of [true, false]) { + test(`checks the capability after a pending handshake ${supported ? 'supports' : 'rejects'} search`, async () => { + const initializeResult = observableValue('initialize', undefined); + const catalog = new DeferredPromise(); + const calls: string[] = []; + let unavailable: readonly string[] = []; + const connection = host({ + initializeResult, + listSessions: async () => { + calls.push('catalog'); + const sessions = await catalog.p; + initializeResult.set(upcastPartial({ _meta: { [AgentHostSessionSearchCapabilityMetaKey]: supported } }), undefined); + return sessions; + }, + searchSessionHistory: async session => { + calls.push(session.scheme); + return { matches: [], hasMore: false }; + }, + }); + const search = store.add(new AgentHostSessionSearch(() => [connection], state => { unavailable = state.unavailableHosts; }, new NullLogService())); + search.setQuery('saved'); + await clock.tickAsync(300); + assert.deepStrictEqual({ calls, unavailable }, { calls: ['catalog'], unavailable: [] }); + await catalog.complete([metadata('session')]); + await clock.tickAsync(0); + assert.deepStrictEqual({ calls, unavailable }, supported + ? { calls: ['catalog', 'copilotcli'], unavailable: [] } + : { calls: ['catalog'], unavailable: ['local'] }); + }); + } + + test('query changes share four slots and stale completions cannot publish or queue more work', async () => { + const pending: DeferredPromise[] = []; + const calls: string[] = []; + const updates: string[][] = []; + let active = 0; + let maximumActive = 0; + const makeHost = (authority: string) => host({ + listSessions: async () => Array.from({ length: 6 }, (_, i) => metadata(`${authority}-${i}`)), + searchSessionHistory: async (session, query) => { + calls.push(query); + active++; + maximumActive = Math.max(maximumActive, active); + if (query === 'old') { + const deferred = new DeferredPromise(); + pending.push(deferred); + await deferred.p; + } + active--; + return { matches: [match(metadata(session.path.slice(1)), query)], hasMore: false }; + }, + }, true, authority); + const search = store.add(new AgentHostSessionSearch(() => [makeHost('first'), makeHost('second')], state => updates.push(state.items.map(item => item.match.snippet)), new NullLogService())); + search.setQuery('old'); + await clock.tickAsync(300); + search.setQuery('intermediate'); + await clock.tickAsync(300); + search.setQuery('new'); + await clock.tickAsync(300); + for (const deferred of pending) { + await deferred.complete({ matches: [], hasMore: false }); + } + await clock.tickAsync(0); + assert.deepStrictEqual({ + maximumActive, old: calls.filter(query => query === 'old').length, + intermediate: calls.includes('intermediate'), latest: calls.filter(query => query === 'new').length, + stalePublished: updates.some(items => items.includes('old')), final: updates.at(-1)?.length, + }, { maximumActive: 4, old: 4, intermediate: false, latest: 12, stalePublished: false, final: 12 }); + }); + + test('hiding cancels queued work and prevents late updates', async () => { + const deferred = new DeferredPromise(); + let requests = 0; + let updates = 0; + const connection = host({ + listSessions: async () => Array.from({ length: 12 }, (_, i) => metadata(`${i}`)), + searchSessionHistory: async () => { requests++; return deferred.p; }, + }); + const search = store.add(new AgentHostSessionSearch(() => [connection], () => { updates++; }, new NullLogService())); + search.setQuery('old'); + await clock.tickAsync(300); + search.setQuery('new'); + search.dispose(); + const updatesBeforeDispose = updates; + await deferred.complete({ matches: [], hasMore: false }); + await clock.tickAsync(500); + assert.deepStrictEqual({ requests, lateUpdates: updates - updatesBeforeDispose }, { requests: 4, lateUpdates: 0 }); + }); + + test('clearing a query cancels in-flight generation without starting another scan', async () => { + const catalog = new DeferredPromise(); + let requests = 0; + let resultCount = -1; + const search = store.add(new AgentHostSessionSearch(() => [host({ + listSessions: () => catalog.p, + searchSessionHistory: async () => { requests++; return { matches: [], hasMore: false }; }, + })], state => { resultCount = state.items.length; }, new NullLogService())); + search.setQuery('old'); + await clock.tickAsync(300); + search.setQuery(''); + await catalog.complete([metadata('session')]); + await clock.tickAsync(0); + assert.deepStrictEqual({ requests, resultCount }, { requests: 0, resultCount: 0 }); + }); + + test('caps displayed matches and leaves remaining sessions unscanned', async () => { + let last = { count: 0, hasMore: false, scanned: 0 }; + const search = store.add(new AgentHostSessionSearch(() => [host({ + listSessions: async () => Array.from({ length: 20 }, (_, i) => metadata(`${i}`)), + searchSessionHistory: async session => ({ matches: Array.from({ length: 20 }, () => match(metadata(session.path.slice(1)))), hasMore: false }), + })], state => { last = { count: state.items.length, hasMore: state.hasMore, scanned: state.scanned }; }, new NullLogService())); + search.setQuery('saved'); + await clock.tickAsync(300); + assert.deepStrictEqual({ count: last.count, hasMore: last.hasMore, stoppedEarly: last.scanned < 20 }, { count: 100, hasMore: true, stoppedEarly: true }); + }); + + function semanticOptions(compute: IEmbeddingsService['computeEmbeddings'] = async (_provider, input) => input.map(() => ({ values: [1, 0] })), allProviders = ['copilot.text-embedding-3-small']): ISemanticSessionSearchOptions { + return { + providerId: 'copilot.text-embedding-3-small', + embeddingsService: upcastPartial({ + allProviders, onDidChange: Event.None, computeEmbeddings: compute, + }), + }; + } + + test('keyword mode never probes semantic capabilities or sends text for embedding', async () => { + let count = 0; + const search = store.add(new AgentHostSessionSearch(() => [host({ + searchSessionHistory: async () => ({ matches: [match(metadata('session'))], hasMore: false }), + supportsSessionSemanticSearch: async () => assert.fail('semantic capability'), + sessionSemanticSearch: async () => assert.fail('semantic request'), + })], state => { count = state.items.length; }, new NullLogService())); + search.setQuery('text'); + await clock.tickAsync(300); + assert.strictEqual(count, 1); + }); + + test('finds independent paraphrases, preserves keywords, and scopes every semantic request after lexical refresh', async () => { + const folder = URI.file('/workspace'); + const sessions = ['literal', 'paraphrase', 'outside'].map(id => ({ + ...metadata(id), workingDirectories: [id === 'outside' ? URI.file('/elsewhere') : folder], + })); + const actions: string[] = []; + const inputs: string[][] = []; + const snapshots: string[][] = []; + let descriptions: (string | undefined)[] = []; + let coverage: { scanned: number; unavailable: number; incomplete: number } | undefined; + const options = semanticOptions(async (_provider, input) => { + inputs.push(input); + return input.map(() => ({ values: [1, 0] })); + }); + const search = store.add(new AgentHostSessionSearch(() => [host({ + listSessions: async () => sessions, + searchSessionHistory: async uri => { + actions.push(`${uri.path}:keyword`); + return { matches: uri.path === '/literal' ? [match(sessions[0], 'fix an automobile')] : [], hasMore: false }; + }, + supportsSessionSemanticSearch: async () => true, + sessionSemanticSearch: async (uri, request) => { + actions.push(`${uri.path}:${request.kind}`); + if (request.kind === 'pending') { + return { kind: 'pending', chunks: [{ id: 1, contentHash: 'hash', text: 'repair a car' }], hasMore: false }; + } + if (request.kind === 'store') { + return { kind: 'store' }; + } + return { + kind: 'search', hasMore: false, incomplete: false, + matches: [{ ...match(sessions.find(candidate => candidate.session.path === uri.path)!, 'repair a car'), score: 0.9 }], + }; + }, + })], state => { + snapshots.push(state.items.map(item => item.match.snippet)); + descriptions = state.items.map(item => item.description); + coverage = state.semantic; + }, new NullLogService(), () => workspace([folder]), () => options)); + search.setQuery('fix an automobile'); + await clock.tickAsync(300); + assert.deepStrictEqual({ + scoped: actions.every(action => !action.startsWith('/outside')), + refreshFirst: ['/literal', '/paraphrase'].every(id => actions.indexOf(`${id}:keyword`) < actions.indexOf(`${id}:pending`)), + inputs, + keywordProgress: snapshots.some(items => items.length === 1 && items[0] === 'fix an automobile'), + snippets: snapshots.at(-1), + kinds: descriptions.map(description => description?.split(' · ').at(-1)), + coverage, + }, { + scoped: true, refreshFirst: true, + inputs: [['fix an automobile'], ['repair a car'], ['repair a car']], + keywordProgress: true, snippets: ['fix an automobile', 'repair a car'], + kinds: ['Keyword and semantic match', 'Semantic match'], + coverage: { scanned: 2, unavailable: 0, incomplete: 0 }, + }); + }); + + for (const unavailable of ['provider', 'compute', 'host'] as const) { + test(`keeps keyword results and reports unavailable semantic coverage after ${unavailable} failure`, async () => { + const options = semanticOptions(async () => { throw new Error('private text must not be shown'); }, unavailable === 'provider' ? [] : undefined); + let result: { count: number; semantic: { scanned: number; unavailable: number; incomplete: number } | undefined } | undefined; + const search = store.add(new AgentHostSessionSearch(() => [host({ + searchSessionHistory: async () => ({ matches: [match(metadata('session'), 'keyword')], hasMore: false }), + supportsSessionSemanticSearch: async () => unavailable !== 'host', + sessionSemanticSearch: async () => assert.fail('request should not be reached'), + })], state => { result = { count: state.items.length, semantic: state.semantic }; }, new NullLogService(), undefined, () => options)); + search.setQuery('keyword'); + await clock.tickAsync(300); + assert.deepStrictEqual(result, { count: 1, semantic: { scanned: 1, unavailable: 1, incomplete: 1 } }); + }); + } + + for (const action of ['edit', 'toggle', 'hide', 'workspace'] as const) { + test(`cancels embedding uploads on ${action} before any store or subsequent batch`, async () => { + const blocked = new DeferredPromise<{ values: number[] }[]>(); + const calls: string[] = []; + let providerToken: CancellationToken | undefined; + let options: ISemanticSessionSearchOptions | undefined = semanticOptions(async (_provider, input, token) => { + calls.push(`embed:${input[0]}`); + if (input[0] === 'document') { + providerToken = token; + return blocked.p; + } + return [{ values: [1, 0] }]; + }); + let currentWorkspace = workspace([URI.file('/workspace')]); + const search = store.add(new AgentHostSessionSearch(() => [host({ + listSessions: async () => [{ ...metadata('session'), workingDirectories: [URI.file('/workspace')] }], + supportsSessionSemanticSearch: async () => true, + sessionSemanticSearch: async (_uri, request) => { + calls.push(request.kind); + return { kind: 'pending', chunks: [{ id: 1, contentHash: 'hash', text: 'document' }], hasMore: true }; + }, + })], () => { }, new NullLogService(), () => currentWorkspace, () => options)); + search.setQuery('old'); + await clock.tickAsync(300); + if (action === 'hide') { + search.dispose(); + } else if (action === 'edit') { + search.setQuery(''); + } else { + options = undefined; + if (action === 'workspace') { + currentWorkspace = workspace([]); + } + search.setQuery('old'); + } + await blocked.complete([{ values: [1, 0] }]); + await clock.tickAsync(300); + assert.deepStrictEqual({ calls, cancelled: providerToken?.isCancellationRequested }, { + calls: ['embed:old', 'pending', 'embed:document'], cancelled: true, + }); + }); + } + + test('semantic scanning continues beyond the keyword display cap and reports incomplete coverage', async () => { + const sessions = Array.from({ length: 20 }, (_, index) => metadata(`${index}`)); + const requested: string[] = []; + let result = { count: 0, hasMore: false, scanned: 0, paraphrase: false, incomplete: 0 }; + const options = semanticOptions(); + const search = store.add(new AgentHostSessionSearch(() => [host({ + listSessions: async () => sessions, + searchSessionHistory: async uri => ({ + matches: Array.from({ length: 20 }, (_, index) => ({ + ...match(sessions.find(session => session.session.path === uri.path)!), turnId: `${index}`, + })), hasMore: false, + }), + supportsSessionSemanticSearch: async () => true, + sessionSemanticSearch: async (uri, request) => { + if (request.kind === 'pending') { + return { kind: 'pending', chunks: [], hasMore: false }; + } + requested.push(uri.path); + return { + kind: 'search', hasMore: false, incomplete: uri.path === '/19', + matches: uri.path === '/19' ? [{ ...match(sessions[19], 'a paraphrase'), score: 0.8 }] : [], + }; + }, + })], state => { + result = { + count: state.items.length, hasMore: state.hasMore, scanned: state.scanned, + paraphrase: state.items.some(item => item.match.snippet === 'a paraphrase'), + incomplete: state.semantic?.incomplete ?? 0, + }; + }, new NullLogService(), undefined, () => options)); + search.setQuery('saved'); + await clock.tickAsync(300); + assert.deepStrictEqual({ sessions: requested.length, result }, { + sessions: 20, result: { count: 100, hasMore: true, scanned: 20, paraphrase: true, incomplete: 1 }, + }); + }); + + test('reports the shared query budget and searches every session after uploads are exhausted', async () => { + const sessions = Array.from({ length: 8 }, (_, index) => metadata(`${index}`)); + let documentChunks = 0; + let queryEmbeddings = 0; + let semanticSearches = 0; + let final = { used: 0, exhausted: false, scanned: 0, incomplete: 0, count: 0 }; + const options = semanticOptions(async (_provider, input) => { + if (input[0] === 'query') { + queryEmbeddings++; + } else { + documentChunks += input.length; + } + return input.map(() => ({ values: [1, 0] })); + }); + const search = store.add(new AgentHostSessionSearch(() => [host({ + listSessions: async () => sessions, + supportsSessionSemanticSearch: async () => true, + sessionSemanticSearch: async (uri, request) => { + if (request.kind === 'pending') { + return { + kind: 'pending', hasMore: true, + chunks: Array.from({ length: 16 }, (_, index) => ({ id: index + 1, contentHash: 'a'.repeat(64), text: 'synthetic document' })), + }; + } + if (request.kind === 'store') { + return { kind: 'store' }; + } + semanticSearches++; + return { + kind: 'search', hasMore: false, incomplete: false, + matches: [{ ...match(sessions.find(session => session.session.path === uri.path)!, 'cached match'), score: 1 }], + }; + }, + })], state => { + final = { + used: state.semanticBudget?.used ?? 0, exhausted: state.semanticBudget?.exhausted ?? false, + scanned: state.scanned, incomplete: state.semantic?.incomplete ?? 0, count: state.items.length, + }; + }, new NullLogService(), undefined, () => options)); + search.setQuery('query'); + await clock.tickAsync(300); + assert.deepStrictEqual({ documentChunks, queryEmbeddings, semanticSearches, final }, { + documentChunks: 2048, queryEmbeddings: 1, semanticSearches: 8, + final: { used: 2048, exhausted: true, scanned: 8, incomplete: 8, count: 8 }, + }); + }); + + for (const approval of ['decline', 'accept', 'missing'] as const) { + test(`picker semantic toggle ${approval} preserves keyword results and revokes approval on scope changes`, async () => { + const instantiation = store.add(new TestInstantiationService()); + const buttons = store.add(new Emitter()); + const changes = store.add(new Emitter()); + const hidden = store.add(new Emitter()); + const workspaceChanges = store.add(new Emitter()); + const actions: string[] = []; + let currentWorkspace = workspace([URI.file('/workspace')]); + let disposed = false; + let keywordRequests = 0; + const picker = store.add(upcastPartial>({ + value: '', items: [], buttons: [], selectedItems: [], + onDidChangeValue: changes.event, onDidTriggerButton: buttons.event, onDidHide: Event.map(hidden.event, () => ({ reason: QuickInputHideReason.Gesture })), + onDidAccept: Event.None, show: () => { }, hide: () => hidden.fire(), dispose: () => { disposed = true; }, + })); + instantiation.stub(IQuickInputService, {}); + instantiation.stub(IQuickInputService, 'createQuickPick', () => picker); + instantiation.stub(IAgentHostConnectionsService, { + connections: [host({ + listSessions: async () => [{ ...metadata('session'), workingDirectories: [URI.file('/workspace')] }], + searchSessionHistory: async () => { + keywordRequests++; + return { matches: [match(metadata('session'), 'keyword')], hasMore: false }; + }, + supportsSessionSemanticSearch: async () => true, + sessionSemanticSearch: async (_session, request) => { + actions.push(request.kind); + return request.kind === 'pending' ? { kind: 'pending', chunks: [], hasMore: false } + : { kind: 'search', matches: [], hasMore: false, incomplete: false }; + }, + })] + }); + instantiation.stub(IChatWidgetService, {}); + instantiation.stub(IChatSessionsService, {}); + instantiation.stub(INotificationService, {}); + instantiation.stub(ILogService, new NullLogService()); + instantiation.stub(IWorkspaceContextService, { + getWorkspace: () => currentWorkspace, + onDidChangeWorkspaceFolders: Event.None, onDidChangeWorkbenchState: Event.None, onDidChangeWorkspaceName: workspaceChanges.event, + }); + instantiation.stub(IWorkbenchEnvironmentService, { isSessionsWindow: false }); + instantiation.stub(IEmbeddingsService, { + allProviders: approval === 'missing' ? [] : ['copilot.fake'], onDidChange: Event.None, + computeEmbeddings: async () => { actions.push('compute'); return [{ values: [1, 0] }]; }, + }); + instantiation.stub(IDialogService, { + confirm: async confirmation => { + actions.push('confirm'); + assert.ok(confirmation.detail?.toString().includes('saved user and assistant messages in the current workspace')); + assert.ok(confirmation.detail?.toString().includes('copilot.fake')); + assert.ok(confirmation.detail?.toString().includes('may take time and use the provider\'s quota')); + assert.ok(confirmation.detail?.toString().includes('at most 2048 document chunks across all sessions, plus one query embedding')); + return { confirmed: approval === 'accept' }; + }, + }); + await CommandsRegistry.getCommand(SEARCH_AGENT_SESSION_CONTENT_COMMAND_ID)!.handler(instantiation); + picker.value = 'keyword'; + changes.fire(picker.value); + await clock.tickAsync(300); + const defaultState = { checked: picker.buttons[0].toggle?.checked, count: picker.items.length, actions: [...actions] }; + buttons.fire(picker.buttons[0]); + await clock.tickAsync(300); + const enabledState = { + checked: picker.buttons[0].toggle?.checked, count: picker.items.length, + unavailable: picker.description?.includes('Semantic unavailable; showing keyword results') ?? false, + actions: [...actions], keywordRequests, + hybridTitle: picker.title?.endsWith('— Keyword and Semantic'), + budget: picker.description?.includes('Document embedding budget: 0/2048 chunks per query') ?? false, + }; + currentWorkspace = workspace([]); + workspaceChanges.fire(); + await clock.tickAsync(300); + const changedScope = { checked: picker.buttons[0].toggle?.checked, actions: [...actions] }; + picker.hide(); + assert.deepStrictEqual({ defaultState, enabledState, changedScope, disposed }, { + defaultState: { checked: false, count: 1, actions: [] }, + enabledState: { + checked: approval === 'accept', count: 1, unavailable: approval === 'missing', + actions: approval === 'accept' ? ['confirm', 'compute', 'pending', 'search'] : approval === 'decline' ? ['confirm'] : [], + keywordRequests: approval === 'accept' ? 2 : 1, + hybridTitle: approval === 'accept', budget: approval === 'accept', + }, + changedScope: { + checked: false, + actions: approval === 'accept' ? ['confirm', 'compute', 'pending', 'search'] : approval === 'decline' ? ['confirm'] : [], + }, + disposed: true, + }); + }); + } + + test('formats plain text with author, project, distinct host, archive label and exact peer chat', () => { + const session = { + host: host({}, true, 'remote-machine'), + metadata: { ...metadata('id'), summary: '$(zap) Title\nsecond', project: { uri: URI.file('/project'), displayName: 'Project' }, status: SessionStatus.IsArchived }, + }; + const item = createAgentHostSessionSearchItem(session, match(session.metadata, '$(alert) **text**\nline', 'peer'))!; + assert.deepStrictEqual({ + label: item.label, description: item.description, detail: item.detail, ariaLabel: item.ariaLabel, + scheme: item.resource.scheme, fragment: item.resource.fragment, + unrelated: createAgentHostSessionSearchItem(session, match(metadata('different'))), + }, { + label: '\\$(zap) Title second', description: 'User · remote-machine · Project · Archived', detail: '\\$(alert) **text** line', + ariaLabel: '$(zap) Title second, User · remote-machine · Project · Archived, $(alert) **text** line', + scheme: 'remote-remote-machine-copilotcli', fragment: 'peer', unrelated: undefined, + }); + }); + + test('acceptance opens the exact chat and reveals the matching assistant turn', async () => { + const session = { host: host(), metadata: metadata('session') }; + const item = createAgentHostSessionSearchItem(session, { ...match(session.metadata, 'answer', 'peer'), role: 'assistant' })!; + const request = upcastPartial({ id: 'turn', message: { text: 'prompt', parts: [] }, messageText: 'prompt' }); + const response = upcastPartial({ id: 'response', requestId: 'turn', setVote: () => { } }); + const actions: string[] = []; + const widget = upcastPartial({ + viewModel: upcastPartial({ getItems: () => [request, response] }), + reveal: target => actions.push(`reveal:${target.id}`), + focus: target => actions.push(`focus:${target.id}`), + }); + const revealed = await openAgentHostSessionSearchResult(item, async resource => { + actions.push(resource.toString()); + return widget; + }); + assert.deepStrictEqual({ actions, revealed }, { actions: ['agent-host-copilotcli:/session#peer', 'reveal:response', 'focus:response'], revealed: true }); + }); + + test('a missing turn does not silently reveal another message with identical text', async () => { + const session = { host: host(), metadata: metadata('session') }; + const item = createAgentHostSessionSearchItem(session, match(session.metadata, '…saved text…'))!; + const actions: string[] = []; + const request = upcastPartial({ id: 'live-id', message: { text: '', parts: [] }, messageText: 'prefix saved text suffix' }); + const widget = upcastPartial({ + viewModel: upcastPartial({ getItems: () => [request] }), + reveal: target => actions.push(target.id), focus: () => { }, + }); + const revealed = await openAgentHostSessionSearchResult(item, async () => widget); + assert.deepStrictEqual({ actions, revealed }, { actions: [], revealed: false }); + }); + + test('waits for Sessions widget loading and matches both host identity and peer chat', async () => { + const session = { host: host(), metadata: metadata('session') }; + const item = createAgentHostSessionSearchItem(session, match(session.metadata, 'saved', 'peer'))!; + const added = store.add(new Emitter()); + const changed = store.add(new Emitter()); + const existing = upcastPartial({ + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource: item.resource.with({ scheme: 'remote-other-copilotcli' }) }), + }); + const widgets: IChatWidget[] = [existing]; + const widgetService = upcastPartial({ getAllWidgets: () => widgets, onDidAddWidget: added.event }); + const connections = upcastPartial({ + resolveSessionResourceIdentity: resource => ({ + connectionAuthority: resource.scheme === 'remote-other-copilotcli' ? 'other' : 'local', + backendSession: session.metadata.session, + }), + }); + let resolved: IChatWidget | undefined; + const waiting = waitForAgentHostSessionSearchWidget(item, widgetService, connections).then(widget => { resolved = widget; }); + await clock.tickAsync(0); + assert.strictEqual(resolved, undefined); + let resource = item.resource.with({ fragment: '' }); + const widget = upcastPartial({ + onDidChangeViewModel: changed.event, + get viewModel() { return upcastPartial({ sessionResource: resource }); }, + }); + widgets.push(widget); + added.fire(widget); + await clock.tickAsync(0); + assert.strictEqual(resolved, undefined); + resource = item.resource; + changed.fire({ previousSessionResource: undefined, currentSessionResource: resource }); + await waiting; + assert.strictEqual(resolved, widget); + }); + + test('waiting for a deleted or unloaded chat times out and disposes listeners', async () => { + const session = { host: host(), metadata: metadata('session') }; + const item = createAgentHostSessionSearchItem(session, match(session.metadata))!; + const added = store.add(new Emitter()); + const waiting = waitForAgentHostSessionSearchWidget(item, upcastPartial({ + getAllWidgets: () => [], onDidAddWidget: added.event, + }), upcastPartial({})); + await clock.tickAsync(10_000); + assert.strictEqual(await waiting, undefined); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsActions.test.ts index 93482ad2f21a10..a47f6e57f34fb2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsActions.test.ts @@ -21,8 +21,8 @@ import { USLayoutResolvedKeybinding } from '../../../../../../platform/keybindin import { IQuickInputService } from '../../../../../../platform/quickinput/common/quickInput.js'; import { IsSessionsWindowContext } from '../../../../../common/contextkeys.js'; import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; -import { AGENT_SESSION_RENAME_ACTION_ID, AgentSessionProviders } from '../../../browser/agentSessions/agentSessions.js'; -import { RenameAgentSessionAction } from '../../../browser/agentSessions/agentSessionsActions.js'; +import { AGENT_SESSION_RENAME_ACTION_ID, AgentSessionProviders, IAgentSessionsControl } from '../../../browser/agentSessions/agentSessions.js'; +import { FindAgentSessionInViewerAction, RenameAgentSessionAction } from '../../../browser/agentSessions/agentSessionsActions.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; @@ -30,6 +30,25 @@ import { IChatModel } from '../../../common/model/chatModel.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { IChatViewModel } from '../../../common/model/chatViewModel.js'; +suite('Agent session title search', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps title-only find in the palette rather than the search toolbar', async () => { + const action = new FindAgentSessionInViewerAction(); + const instantiationService = disposables.add(new TestInstantiationService()); + let opened = 0; + await instantiationService.invokeFunction(accessor => action.run(accessor, upcastPartial({ + openFind: () => { opened++; }, + }))); + assert.deepStrictEqual({ + palette: action.desc.f1, + title: typeof action.desc.title === 'string' ? action.desc.title : action.desc.title.value, + menu: action.desc.menu, + opened, + }, { palette: true, title: 'Find Agent Session by Title', menu: undefined, opened: 1 }); + }); +}); + suite('RenameAgentSessionAction', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const actionRegistration = registerAction2(RenameAgentSessionAction); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/semanticSessionSearch.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/semanticSessionSearch.test.ts new file mode 100644 index 00000000000000..e2cf51010c2061 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/semanticSessionSearch.test.ts @@ -0,0 +1,281 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { ISessionSemanticRequest } from '../../../../../../platform/agentHost/common/sessionSemanticSearch.js'; +import { buildChatUri } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IEmbeddingsService } from '../../../../../services/embeddings/common/embeddingsService.js'; +import { MAX_SEMANTIC_SESSION_SEARCH_DOCUMENT_CHUNKS, mergeSessionSearchResults, SemanticSessionSearch, SemanticSessionSearchConsent } from '../../../browser/agentSessions/agentHost/semanticSessionSearch.js'; + +suite('SemanticSessionSearch', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const providerId = 'copilot.text-embedding-3-small'; + const session = URI.parse('copilotcli:/session'); + + function embeddings(compute: IEmbeddingsService['computeEmbeddings'], allProviders = [providerId]): IEmbeddingsService { + return upcastPartial({ allProviders, onDidChange: Event.None, computeEmbeddings: compute }); + } + + function connection(search: NonNullable): IAgentConnection { + return upcastPartial({ supportsSessionSemanticSearch: async () => true, sessionSemanticSearch: search }); + } + + for (const approved of [false, true]) { + test(`requires explicit consent and offers only registered Copilot providers (${approved})`, async () => { + const consent = store.add(new SemanticSessionSearchConsent()); + const actions: string[] = []; + const service = embeddings(async () => { actions.push('compute'); return []; }, ['other.provider', providerId]); + const result = await consent.enable(service, async providers => { + actions.push(`select:${providers.join(',')}`); + return providers[0]; + }, async provider => { + actions.push(`confirm:${provider}`); + return approved; + }); + assert.deepStrictEqual({ result, provider: consent.approved?.providerId, actions }, { + result: approved ? 'enabled' : 'cancelled', + provider: approved ? providerId : undefined, + actions: [`select:${providerId}`, `confirm:${providerId}`], + }); + }); + } + + test('a missing Copilot provider never requests consent or computation', async () => { + const consent = store.add(new SemanticSessionSearchConsent()); + const result = await consent.enable(embeddings(async () => assert.fail('compute'), ['other.provider']), + async () => assert.fail('select'), async () => assert.fail('confirm')); + assert.deepStrictEqual({ result, options: consent.approved }, { result: 'unavailable', options: undefined }); + }); + + for (const action of ['query', 'workspace', 'hide'] as const) { + test(`revokes pending consent on ${action} and ignores late approval`, async () => { + const consent = store.add(new SemanticSessionSearchConsent()); + const confirmation = new DeferredPromise(); + let token: CancellationToken | undefined; + const enabling = consent.enable(embeddings(async () => assert.fail('compute')), + async providers => providers[0], async (_provider, cancellation) => { + token = cancellation; + return confirmation.p; + }); + await Promise.resolve(); + if (action === 'query') { + consent.cancelPending(); + } else if (action === 'workspace') { + consent.revoke(); + } else { + consent.dispose(); + } + await confirmation.complete(true); + assert.deepStrictEqual({ result: await enabling, cancelled: token?.isCancellationRequested, approved: consent.approved }, + { result: 'cancelled', cancelled: true, approved: undefined }); + }); + } + + test('scope revocation does not retain approval for a subsequent search', async () => { + const consent = store.add(new SemanticSessionSearchConsent()); + await consent.enable(embeddings(async () => assert.fail('compute')), async providers => providers[0], async () => true); + consent.revoke(); + assert.strictEqual(consent.approved, undefined); + }); + + test('shares one query embedding, stores exact pending identities, and reuses cached document vectors', async () => { + const requests: ISessionSemanticRequest[] = []; + const inputs: string[][] = []; + const stored = new Set(); + const service = embeddings(async (_provider, input) => { + inputs.push(input); + return input.map(() => ({ values: [1, 0] })); + }); + const host = connection(async (uri, request) => { + requests.push(request); + if (request.kind === 'pending') { + return { kind: 'pending', chunks: stored.has(uri.path) ? [] : [{ id: 7, contentHash: 'hash', text: 'a car can be repaired' }], hasMore: false }; + } + if (request.kind === 'store') { + stored.add(uri.path); + return { kind: 'store' }; + } + return { kind: 'search', matches: [], hasMore: false, incomplete: false }; + }); + const options = { providerId, embeddingsService: service }; + const generation = new SemanticSessionSearch('fix an automobile', options, CancellationToken.None); + await Promise.all([generation.search(host, session), generation.search(host, URI.parse('copilotcli:/second'))]); + await new SemanticSessionSearch('another query', options, CancellationToken.None).search(host, session); + assert.deepStrictEqual({ + inputs, + models: requests.every(request => request.model.id === providerId && request.model.dimensions === 2), + stored: requests.filter(request => request.kind === 'store').map(request => request.values), + searches: requests.filter(request => request.kind === 'search').map(request => request.vector), + }, { + inputs: [['fix an automobile'], ['a car can be repaired'], ['a car can be repaired'], ['another query']], + models: true, + stored: [[{ id: 7, contentHash: 'hash', vector: [1, 0] }], [{ id: 7, contentHash: 'hash', vector: [1, 0] }]], + searches: [[1, 0], [1, 0], [1, 0]], + }); + }); + + test('cancellation while embedding a document prevents storage, search and further uploads', async () => { + const source = store.add(new CancellationTokenSource()); + const started = new DeferredPromise(); + const document = new DeferredPromise<{ values: number[] }[]>(); + const requests: string[] = []; + const inputs: string[][] = []; + let providerToken: CancellationToken | undefined; + const service = embeddings(async (_provider, input, token) => { + inputs.push(input); + if (input[0] !== 'query') { + providerToken = token; + await started.complete(); + return document.p; + } + return [{ values: [1, 0] }]; + }); + const generation = new SemanticSessionSearch('query', { providerId, embeddingsService: service }, source.token); + const searching = generation.search(connection(async (_uri, request) => { + requests.push(request.kind); + return { kind: 'pending', chunks: [{ id: 1, contentHash: 'hash', text: 'document' }], hasMore: true }; + }), session); + const rejection = assert.rejects(searching, error => error instanceof Error && error.name === 'Canceled'); + await started.p; + source.cancel(); + await rejection; + await document.complete([{ values: [1, 0] }]); + assert.deepStrictEqual({ requests, inputs, cancelled: providerToken?.isCancellationRequested }, { + requests: ['pending'], inputs: [['query'], ['document']], cancelled: true, + }); + }); + + test('bounds embedding concurrency across generations and skips cancelled queued requests', async () => { + const sources = Array.from({ length: 6 }, () => store.add(new CancellationTokenSource())); + const started = new DeferredPromise(); + const blocked = new DeferredPromise<{ values: number[] }[]>(); + const inputs: string[] = []; + let active = 0; + let maximumActive = 0; + const service = embeddings(async (_provider, input) => { + inputs.push(input[0]); + active++; + maximumActive = Math.max(maximumActive, active); + if (active === 2) { + await started.complete(); + } + await blocked.p; + active--; + return [{ values: [1, 0] }]; + }); + const host = connection(async (_session, request) => request.kind === 'pending' + ? { kind: 'pending', chunks: [], hasMore: false } + : { kind: 'search', matches: [], hasMore: false, incomplete: false }); + const operations = sources.map((source, index) => new SemanticSessionSearch(`query-${index}`, { providerId, embeddingsService: service }, source.token).search(host, session)); + const completed = Promise.allSettled(operations); + await started.p; + for (const source of sources) { + source.cancel(); + } + await blocked.complete([{ values: [1, 0] }]); + await completed; + assert.deepStrictEqual({ inputs, maximumActive }, { inputs: ['query-0', 'query-1'], maximumActive: 2 }); + }); + + test('bounds pending uploads to 32 batches and reports incomplete coverage', async () => { + let uploads = 0; + const service = embeddings(async (_provider, input) => input.map(() => ({ values: [1, 0] }))); + const generation = new SemanticSessionSearch('query', { providerId, embeddingsService: service }, CancellationToken.None); + const result = await generation.search(connection(async (_session, request) => { + switch (request.kind) { + case 'pending': return { kind: 'pending', chunks: [{ id: uploads, contentHash: 'hash', text: 'document' }], hasMore: true }; + case 'store': uploads++; return { kind: 'store' }; + case 'search': return { kind: 'search', matches: [], hasMore: false, incomplete: false }; + } + }), session); + assert.deepStrictEqual({ uploads, result }, { uploads: 32, result: { matches: [], hasMore: false, incomplete: true } }); + }); + + test('shares a 2048-chunk budget across concurrent sessions, truncates the final batch, and still searches cached vectors', async () => { + let queries = 0; + let documents = 0; + const batchSizes: number[] = []; + const stored = new Map(); + const searched: string[] = []; + const service = embeddings(async (_provider, input) => { + if (input[0] === 'query') { + queries++; + } else { + documents += input.length; + batchSizes.push(input.length); + } + return input.map(() => ({ values: [1, 0] })); + }); + const host = connection(async (uri, request) => { + if (request.kind === 'pending') { + const offset = stored.get(uri.path) ?? 0; + return { + kind: 'pending', + chunks: uri.path === '/cached' ? [] : Array.from({ length: 15 }, (_, index) => ({ + id: offset + index + 1, contentHash: 'a'.repeat(64), text: `synthetic document ${offset + index}`, + })), + hasMore: uri.path !== '/cached', + }; + } + if (request.kind === 'store') { + stored.set(uri.path, (stored.get(uri.path) ?? 0) + request.values.length); + return { kind: 'store' }; + } + searched.push(uri.path); + return { + kind: 'search', matches: [{ chat: buildChatUri(uri, 'default'), turnId: 'turn', role: 'assistant', snippet: 'cached match', score: 1 }], + hasMore: false, incomplete: false, + }; + }); + const generation = new SemanticSessionSearch('query', { providerId, embeddingsService: service }, CancellationToken.None); + const results = await Promise.all(Array.from({ length: 8 }, (_, index) => generation.search(host, URI.parse(`copilotcli:/${index}`)))); + const cached = await generation.search(host, URI.parse('copilotcli:/cached')); + assert.deepStrictEqual({ + queries, documents, used: generation.documentChunksUsed, exhausted: generation.budgetExhausted, + partialBatch: batchSizes.filter(size => size !== 15), + stored: [...stored.values()].reduce((sum, count) => sum + count, 0), + searched: searched.length, allIncomplete: results.every(result => result.incomplete), + cached: { count: cached.matches.length, incomplete: cached.incomplete }, + }, { + queries: 1, documents: MAX_SEMANTIC_SESSION_SEARCH_DOCUMENT_CHUNKS, used: 2048, exhausted: true, + partialBatch: [8], stored: 2048, searched: 9, allIncomplete: true, cached: { count: 1, incomplete: false }, + }); + }); + + test('rejects document vectors with a different dimension before storing', async () => { + const requests: string[] = []; + const service = embeddings(async (_provider, input) => [{ values: input[0] === 'query' ? [1, 0] : [1] }]); + const generation = new SemanticSessionSearch('query', { providerId, embeddingsService: service }, CancellationToken.None); + await assert.rejects(generation.search(connection(async (_session, request) => { + requests.push(request.kind); + return { kind: 'pending', chunks: [{ id: 1, contentHash: 'hash', text: 'document' }], hasMore: false }; + }), session), /Invalid semantic embeddings/); + assert.deepStrictEqual(requests, ['pending']); + }); + + test('rejects a zero query vector before requesting saved messages', async () => { + const generation = new SemanticSessionSearch('query', { + providerId, embeddingsService: embeddings(async () => [{ values: [0, 0] }]), + }, CancellationToken.None); + await assert.rejects(generation.search(connection(async () => assert.fail('saved messages requested')), session), /Invalid semantic embeddings/); + }); + + test('fuses independent candidate lists by rank, deduplicates, and retains the keyword snippet', () => { + const keyword = [{ id: 'literal', snippet: 'literal' }, { id: 'both', snippet: 'keyword excerpt' }]; + const semantic = [{ id: 'meaning', snippet: 'a paraphrase' }, { id: 'both', snippet: 'semantic excerpt' }]; + assert.deepStrictEqual(mergeSessionSearchResults([...keyword, keyword[1]], semantic, item => item.id), [ + { item: keyword[1], source: 'both' }, + { item: keyword[0], source: 'keyword' }, + { item: semantic[0], source: 'semantic' }, + ]); + }); +}); diff --git a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts index 5e1fe4e23c21b2..16fa9d24cb23fc 100644 --- a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts +++ b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts @@ -17,6 +17,8 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { ILogService } from '../../../../platform/log/common/log.js'; import { AgentHostIpcChannels, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../../../../platform/agentHost/common/agentService.js'; import { IAgentHostEnablementService } from '../../../../platform/agentHost/common/agentHostEnablementService.js'; +import type { IAgentSessionSearchResult } from '../../../../platform/agentHost/common/agentHostSessionSearch.js'; +import type { ISessionSemanticRequest, ISessionSemanticResult } from '../../../../platform/agentHost/common/sessionSemanticSearch.js'; import { AgentHostIpcChannelTransport } from '../../../../platform/agentHost/browser/agentHostIpcChannelTransport.js'; import { AgentHostClientConnectionKind } from '../../../../platform/agentHost/common/agentHostTelemetry.js'; import { AgentHostClientState, AgentHostProtocolClient } from '../../../../platform/agentHost/browser/agentHostProtocolClient.js'; @@ -235,6 +237,22 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA return this._requireClient().getSessionStateFile(session, chat); } + searchSessionHistory(session: URI, query: string): Promise { + return this._requireClient().searchSessionHistory(session, query); + } + + supportsSessionHistorySearch(): Promise { + return this._requireClient().supportsSessionHistorySearch(); + } + + supportsSessionSemanticSearch(): Promise { + return this._requireClient().supportsSessionSemanticSearch(); + } + + sessionSemanticSearch(session: URI, request: ISessionSemanticRequest): Promise { + return this._requireClient().sessionSemanticSearch(session, request); + } + collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind, chat?: URI): Promise { return this._requireClient().collectDebugLogs(session, kind, chat); } diff --git a/src/vs/workbench/services/embeddings/common/embeddingsService.ts b/src/vs/workbench/services/embeddings/common/embeddingsService.ts new file mode 100644 index 00000000000000..205e4f2bb5a0d4 --- /dev/null +++ b/src/vs/workbench/services/embeddings/common/embeddingsService.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationError } from '../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export interface IEmbeddingsProvider { + provideEmbeddings(input: string[], token: CancellationToken): Promise<{ values: number[] }[]>; +} + +export const IEmbeddingsService = createDecorator('embeddingsService'); + +export interface IEmbeddingsService { + readonly _serviceBrand: undefined; + readonly onDidChange: Event; + readonly allProviders: Iterable; + registerProvider(id: string, provider: IEmbeddingsProvider): IDisposable; + computeEmbeddings(id: string, input: string[], token: CancellationToken): Promise<{ values: number[] }[]>; +} + +export class EmbeddingsService extends Disposable implements IEmbeddingsService { + declare readonly _serviceBrand: undefined; + private readonly providers = new Map(); + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + get allProviders(): Iterable { + return this.providers.keys(); + } + + registerProvider(id: string, provider: IEmbeddingsProvider): IDisposable { + this.providers.set(id, provider); + this._onDidChange.fire(); + return toDisposable(() => { + if (this.providers.get(id) === provider) { + this.providers.delete(id); + this._onDidChange.fire(); + } + }); + } + + computeEmbeddings(id: string, input: string[], token: CancellationToken): Promise<{ values: number[] }[]> { + if (token.isCancellationRequested) { + return Promise.reject(new CancellationError()); + } + const provider = this.providers.get(id); + return provider + ? provider.provideEmbeddings(input, token) + : Promise.reject(new Error(`No embeddings provider registered with id: ${id}`)); + } + + override dispose(): void { + this.providers.clear(); + super.dispose(); + } +} + +registerSingleton(IEmbeddingsService, EmbeddingsService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/embeddings/test/common/embeddingsService.test.ts b/src/vs/workbench/services/embeddings/test/common/embeddingsService.test.ts new file mode 100644 index 00000000000000..971a92fe0387f9 --- /dev/null +++ b/src/vs/workbench/services/embeddings/test/common/embeddingsService.test.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../../../base/common/errors.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { EmbeddingsService } from '../../common/embeddingsService.js'; + +suite('Workbench embeddings service', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('reuses the registered extension provider without changing inputs or cancellation', async () => { + const service = store.add(new EmbeddingsService()); + const token = store.add(new CancellationTokenSource()); + const calls: string[][] = []; + store.add(service.registerProvider('copilot.test', { + provideEmbeddings: async (input, cancellation) => { + assert.strictEqual(cancellation, token.token); + calls.push(input); + return [{ values: [1, 0] }]; + }, + })); + assert.deepStrictEqual({ + result: await service.computeEmbeddings('copilot.test', ['synthetic example'], token.token), + calls, + models: [...service.allProviders], + }, { result: [{ values: [1, 0] }], calls: [['synthetic example']], models: ['copilot.test'] }); + }); + + test('disposing an older registration does not remove its replacement', async () => { + const service = store.add(new EmbeddingsService()); + const token = store.add(new CancellationTokenSource()); + const older = store.add(service.registerProvider('copilot.test', { provideEmbeddings: async () => [{ values: [1, 0] }] })); + const newer = store.add(service.registerProvider('copilot.test', { provideEmbeddings: async () => [{ values: [0, 1] }] })); + older.dispose(); + assert.deepStrictEqual(await service.computeEmbeddings('copilot.test', ['synthetic'], token.token), [{ values: [0, 1] }]); + newer.dispose(); + await assert.rejects(service.computeEmbeddings('copilot.test', ['synthetic'], token.token), /No embeddings provider/); + }); + + test('propagates provider failures rather than returning empty success', async () => { + const service = store.add(new EmbeddingsService()); + const token = store.add(new CancellationTokenSource()); + store.add(service.registerProvider('copilot.test', { provideEmbeddings: async () => { throw new Error('endpoint unavailable'); } })); + await assert.rejects(service.computeEmbeddings('copilot.test', ['synthetic'], token.token), /endpoint unavailable/); + }); + + test('does not contact a provider for a cancelled request', async () => { + const service = store.add(new EmbeddingsService()); + const token = store.add(new CancellationTokenSource()); + let calls = 0; + store.add(service.registerProvider('copilot.test', { provideEmbeddings: async () => { calls++; return [{ values: [1, 0] }]; } })); + token.cancel(); + await assert.rejects(service.computeEmbeddings('copilot.test', ['synthetic'], token.token), isCancellationError); + assert.strictEqual(calls, 0); + }); +});