-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(daemon): support @extension mentions #6008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # Daemon @extension Mention Support | ||
|
|
||
| ## Goal | ||
|
|
||
| Daemon WebShell should match the CLI extension mention behavior for active extensions. Users can discover active extensions from `@` completion, select a canonical `@ext:<name>` mention, and have the daemon inject that extension's context into the model turn without changing the visible prompt text. | ||
|
|
||
| ## Design | ||
|
|
||
| - WebShell `@` completion combines active extension entries from workspace extension status with existing workspace file matches. Bare `@` shows extensions first, `@bro` filters extensions and files, and `@ext:` switches to extension-only completion. | ||
| - Extension completion inserts `@ext:<extension.name> ` so the daemon receives a stable reference independent of display text. | ||
| - Daemon extension status includes an optional `description` field populated from installed extension config. The field is additive for older clients. | ||
| - ACP session prompt resolution scans text prompt blocks for `@ext:<name>` tokens, matches only active extensions from session config, dedupes repeated mentions, and silently skips unknown or inactive names. | ||
| - The user-visible text is preserved exactly. Resolved extension context is appended as extra model text parts after the user's text. | ||
| - CLI and daemon share extension mention helpers for parsing, sanitizing display text, formatting capabilities, and reading context files with subpath and size guards. | ||
|
|
||
| ## Bounds | ||
|
|
||
| Context file reads are limited per file and by aggregate extension context budget. Files outside the installed extension directory are skipped, unreadable files are skipped with debug output, and repeated mentions consume budget once. | ||
|
|
||
| ## Verification | ||
|
|
||
| Targeted tests cover WebShell completion modes, daemon ACP context injection, repeated and unknown mentions, bounded context files, and the existing CLI extension mention processors. Final verification runs the repository build and typecheck. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -160,6 +160,12 @@ import { | |
| import { parseAcpModelOption } from '../../utils/acpModelUtils.js'; | ||
| import { classifyApiError } from '../../ui/hooks/useGeminiStream.js'; | ||
| import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; | ||
| import { | ||
| buildExtensionMentionContext, | ||
| EXTENSION_CONTEXT_BUDGET, | ||
| matchExtensionByRef, | ||
| parseExtensionRef, | ||
| } from '../../utils/extension-mention.js'; | ||
|
|
||
| // Import modular session components | ||
| import type { | ||
|
|
@@ -555,6 +561,22 @@ function isUserPromptRecord(record: ChatRecord): boolean { | |
| ); | ||
| } | ||
|
|
||
| const AT_TOKEN_RE = /@([^\s,;!?()[\]{}]+)/g; | ||
|
|
||
| function collectExtensionMentionRefs( | ||
| text: string, | ||
| mentions: Map<string, string>, | ||
| ): void { | ||
| for (const match of text.matchAll(AT_TOKEN_RE)) { | ||
| const pathName = match[1]; | ||
| if (!pathName) continue; | ||
| const ref = parseExtensionRef(pathName); | ||
| if (ref) { | ||
| mentions.set(ref.name.toLowerCase(), ref.name); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export interface AvailableCommandsSnapshot { | ||
| availableCommands: AvailableCommand[]; | ||
| availableSkills?: string[]; | ||
|
|
@@ -5002,10 +5024,12 @@ export class Session implements SessionContext { | |
| const FILE_URI_SCHEME = 'file://'; | ||
|
|
||
| const embeddedContext: EmbeddedResourceResource[] = []; | ||
| const extensionMentions = new Map<string, string>(); | ||
|
|
||
| const parts = message.map((part) => { | ||
| switch (part.type) { | ||
| case 'text': | ||
| collectExtensionMentionRefs(part.text, extensionMentions); | ||
| return { text: part.text }; | ||
| case 'image': | ||
| case 'audio': | ||
|
|
@@ -5040,11 +5064,23 @@ export class Session implements SessionContext { | |
| }); | ||
|
|
||
| const atPathCommandParts = parts.filter((part) => 'fileData' in part); | ||
| const extensionParts = await this.#resolveExtensionMentionParts( | ||
| extensionMentions, | ||
| abortSignal, | ||
| ); | ||
|
|
||
| if (atPathCommandParts.length === 0 && embeddedContext.length === 0) { | ||
| if ( | ||
| atPathCommandParts.length === 0 && | ||
| embeddedContext.length === 0 && | ||
| extensionParts.length === 0 | ||
| ) { | ||
| return parts; | ||
| } | ||
|
|
||
| if (atPathCommandParts.length === 0 && embeddedContext.length === 0) { | ||
| return [...parts, ...extensionParts]; | ||
| } | ||
|
|
||
| // Extract paths from @ commands - pass directly to readManyFiles without filtering | ||
| // since this is user-triggered behavior, not LLM-triggered | ||
| const pathSpecsToRead: string[] = atPathCommandParts.map( | ||
|
|
@@ -5085,6 +5121,7 @@ export class Session implements SessionContext { | |
|
|
||
| // Add initial query text first | ||
| processedQueryParts.push({ text: initialQueryText }); | ||
| processedQueryParts.push(...extensionParts); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The combined code path where a prompt contains both A bug in the combined path (e.g., extension parts inserted in wrong position, or duplicated) would go undetected. Consider adding a test that sends a prompt with both a — qwen3.7-max via Qwen Code /review |
||
|
|
||
| // Then add content parts (preserving binary files as inlineData) | ||
| for (const part of contentParts) { | ||
|
|
@@ -5096,6 +5133,7 @@ export class Session implements SessionContext { | |
| } | ||
| } else { | ||
| processedQueryParts.push({ text: initialQueryText.trim() }); | ||
| processedQueryParts.push(...extensionParts); | ||
| } | ||
|
|
||
| // Process embedded context from resource blocks | ||
|
|
@@ -5122,6 +5160,39 @@ export class Session implements SessionContext { | |
| return processedQueryParts; | ||
| } | ||
|
|
||
| async #resolveExtensionMentionParts( | ||
| extensionMentions: Map<string, string>, | ||
| abortSignal: AbortSignal, | ||
| ): Promise<Part[]> { | ||
| if (extensionMentions.size === 0) return []; | ||
| const activeExtensions = this.config.getActiveExtensions?.() ?? []; | ||
| if (activeExtensions.length === 0) return []; | ||
|
|
||
| const extensionParts: Part[] = []; | ||
| const resolvedExtensionNames = new Set<string>(); | ||
| let remainingBudget = EXTENSION_CONTEXT_BUDGET; | ||
| for (const name of extensionMentions.values()) { | ||
| const extension = matchExtensionByRef(name, activeExtensions); | ||
| if (!extension) { | ||
| this.debug( | ||
| `Extension "${name}" not found among active extensions. ` + | ||
| `Available: ${activeExtensions.map((e) => e.name).join(', ') || '(none)'}`, | ||
| ); | ||
| continue; | ||
| } | ||
| if (resolvedExtensionNames.has(extension.name)) continue; | ||
| resolvedExtensionNames.add(extension.name); | ||
| const context = await buildExtensionMentionContext(extension, { | ||
| remainingBudget, | ||
| signal: abortSignal, | ||
| onDebugMessage: (message) => this.debug(message), | ||
| }); | ||
| remainingBudget = context.remainingBudget; | ||
| extensionParts.push({ text: context.text }); | ||
| } | ||
| return extensionParts; | ||
| } | ||
|
|
||
| debug(msg: string): void { | ||
| if (this.config.getDebugMode()) { | ||
| debugLogger.warn(msg); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion]
AT_TOKEN_RE = /@([^\s,;!?()[\]{}]+)/gdoes not exclude a trailing period, so@ext:browser.at the end of a sentence is captured asext:browser.(including the dot), whichparseExtensionRefreturns as{name: 'browser.'}— no extension matches and the mention silently fails to resolve.The CLI path (
atCommandProcessor.ts) explicitly terminates@pathtokens when.is followed by whitespace or end-of-string, so daemon users get different behavior than CLI users for sentence-ending mentions. Tightening the regex to exclude trailing.,:and similar punctuation would make the two paths consistent.— qwen3.7-max via Qwen Code /review