Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .qwen/design/daemon-extension-at-mention.md
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.
1 change: 1 addition & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,7 @@ export interface ServeExtensionEntry {
id: string;
name: string;
displayName?: string;
description?: string;
version: string;
isActive: boolean;
path: string;
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4771,6 +4771,9 @@ class QwenAgent implements Agent {
id: ext.id,
name: ext.name,
displayName: ext.displayName,
...(ext.config.description
? { description: ext.config.description }
: {}),
version: ext.version,
isActive: ext.isActive,
path: ext.path,
Expand Down
144 changes: 143 additions & 1 deletion packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import {
Session,
} from './Session.js';
import type { Content, FunctionCall, Part } from '@google/genai';
import type { ChatRecord, Config, GeminiChat } from '@qwen-code/qwen-code-core';
import type {
ChatRecord,
Config,
Extension,
GeminiChat,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
AuthType,
Expand Down Expand Up @@ -295,6 +300,48 @@ describe('Session', () => {
};
}

function makeExtension(overrides: Partial<Extension> = {}): Extension {
return {
id: 'browser',
name: 'browser',
displayName: 'Browser',
version: '1.0.0',
isActive: true,
path: process.cwd(),
config: {
name: 'browser',
version: '1.0.0',
description: 'Browser automation',
},
mcpServers: {
'browser-mcp': {
command: 'node',
},
},
contextFiles: [],
skills: [
{
name: 'browser-skill',
description: 'Use browser tools',
path: 'skills/browser/SKILL.md',
},
],
...overrides,
} as Extension;
}

function firstSentMessage(): Part[] {
const call = vi.mocked(mockChat.sendMessageStream).mock.calls[0];
const request = call?.[1] as { message?: Part[] } | undefined;
return request?.message ?? [];
}

function textParts(parts: Part[]): string[] {
return parts.flatMap((part) =>
typeof part.text === 'string' ? [part.text] : [],
);
}

beforeEach(() => {
currentModel = 'qwen3-code-plus';
currentAuthType = AuthType.USE_OPENAI;
Expand Down Expand Up @@ -6487,6 +6534,101 @@ describe('Session', () => {
}
});

it('injects active extension context for @ext mentions', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-acp-ext-'));
const contextFile = path.join(tempDir, 'context.md');

try {
await fs.writeFile(contextFile, 'extension context file', 'utf8');
const extension = makeExtension({
path: tempDir,
contextFiles: [contextFile],
});
mockConfig.getActiveExtensions = vi.fn().mockReturnValue([extension]);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'Use @ext:browser now' }],
});

const message = firstSentMessage();
expect(message[0]).toEqual({ text: 'Use @ext:browser now' });
const sentText = textParts(message).join('\n');
expect(sentText).toContain(
'--- Extension: Browser (untrusted third-party content) ---',
);
expect(sentText).toContain('Browser automation');
expect(sentText).toContain(
'- Skills: browser-skill (invoke via /<skill-name>)',
);
expect(sentText).toContain('- MCP Servers: browser-mcp');
expect(sentText).toContain('extension context file');
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});

it('dedupes repeated extension mentions and skips unknown mentions', async () => {
const extension = makeExtension();
mockConfig.getActiveExtensions = vi.fn().mockReturnValue([extension]);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [
{
type: 'text',
text: 'Use @ext:browser and @ext:browser and @ext:missing',
},
],
});

const sentText = textParts(firstSentMessage()).join('\n');
expect(sentText.match(/--- Extension: Browser/g)).toHaveLength(1);
expect(sentText).not.toContain('Extension: missing');
});

it('caps extension context files and skips files outside the extension', async () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-acp-ext-cap-'),
);
const outsideDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-acp-ext-outside-'),
);
const bigFile = path.join(tempDir, 'big.md');
const outsideFile = path.join(outsideDir, 'secret.md');

try {
await fs.writeFile(bigFile, 'x'.repeat(60_000), 'utf8');
await fs.writeFile(outsideFile, 'do not inject this secret', 'utf8');
const extension = makeExtension({
path: tempDir,
contextFiles: [bigFile, outsideFile],
});
mockConfig.getActiveExtensions = vi.fn().mockReturnValue([extension]);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: '@ext:browser' }],
});

const sentText = textParts(firstSentMessage()).join('\n');
expect(sentText).toContain('... (truncated)');
expect(sentText).not.toContain('do not inject this secret');
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
}
});

it('runs prompt inside runtime output dir context', async () => {
const runtimeDir = path.resolve('runtime', 'from-settings');
core.Storage.setRuntimeBaseDir(runtimeDir);
Expand Down
73 changes: 72 additions & 1 deletion packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -555,6 +561,22 @@ function isUserPromptRecord(record: ChatRecord): boolean {
);
}

const AT_TOKEN_RE = /@([^\s,;!?()[\]{}]+)/g;

Copy link
Copy Markdown
Collaborator

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,;!?()[\]{}]+)/g does not exclude a trailing period, so @ext:browser. at the end of a sentence is captured as ext:browser. (including the dot), which parseExtensionRef returns as {name: 'browser.'} — no extension matches and the mention silently fails to resolve.

The CLI path (atCommandProcessor.ts) explicitly terminates @path tokens 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


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[];
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -5085,6 +5121,7 @@ export class Session implements SessionContext {

// Add initial query text first
processedQueryParts.push({ text: initialQueryText });
processedQueryParts.push(...extensionParts);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The combined code path where a prompt contains both @file references (producing fileData parts) and @ext: mentions is untested. All three new Session tests use text-only prompts with @ext: but no file attachments. This processedQueryParts.push(...extensionParts) line (and the one at line 5136 in the else branch) are only reached when atPathCommandParts.length > 0 or embeddedContext.length > 0 — that fallthrough is never exercised.

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 resource_link block (producing a fileData part) and a text block containing @ext:browser, then asserting both the file content and the extension context block appear in the sent message.

— qwen3.7-max via Qwen Code /review


// Then add content parts (preserving binary files as inlineData)
for (const part of contentParts) {
Expand All @@ -5096,6 +5133,7 @@ export class Session implements SessionContext {
}
} else {
processedQueryParts.push({ text: initialQueryText.trim() });
processedQueryParts.push(...extensionParts);
}

// Process embedded context from resource blocks
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/serve/routes/workspace-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,9 @@ export function registerWorkspaceExtensionRoutes(
id: ext.id,
name: ext.name,
...(ext.displayName ? { displayName: ext.displayName } : {}),
...(ext.config.description
? { description: ext.config.description }
: {}),
version: ext.version,
isActive: ext.isActive,
path: ext.path,
Expand Down
Loading
Loading