mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-23 17:26:23 +00:00
Compare commits
4 Commits
main
...
fix/edit-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8106e094e8 | ||
|
|
2818bb604d | ||
|
|
571d2bd762 | ||
|
|
317d0af50e |
207
.github/workflows/release-vscode-companion.yml
vendored
207
.github/workflows/release-vscode-companion.yml
vendored
@@ -1,207 +0,0 @@
|
||||
name: 'Release VSCode IDE Companion'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.'
|
||||
required: false
|
||||
type: 'string'
|
||||
ref:
|
||||
description: 'The branch or ref (full git sha) to release from.'
|
||||
required: true
|
||||
type: 'string'
|
||||
default: 'main'
|
||||
dry_run:
|
||||
description: 'Run a dry-run of the release process; no branches, vsix packages or GitHub releases will be created.'
|
||||
required: true
|
||||
type: 'boolean'
|
||||
default: true
|
||||
create_preview_release:
|
||||
description: 'Auto apply the preview release tag, input version is ignored.'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
force_skip_tests:
|
||||
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-vscode-companion:
|
||||
runs-on: 'ubuntu-latest'
|
||||
environment:
|
||||
name: 'production-release'
|
||||
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'write'
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set booleans for simplified logic'
|
||||
env:
|
||||
CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}'
|
||||
DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}'
|
||||
id: 'vars'
|
||||
run: |-
|
||||
is_preview="false"
|
||||
if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then
|
||||
is_preview="true"
|
||||
fi
|
||||
echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
is_dry_run="false"
|
||||
if [[ "${DRY_RUN_INPUT}" == "true" ]]; then
|
||||
is_dry_run="true"
|
||||
fi
|
||||
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: 'Install Dependencies'
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
run: |-
|
||||
npm ci
|
||||
|
||||
- name: 'Install VSCE and OVSX'
|
||||
run: |-
|
||||
npm install -g @vscode/vsce
|
||||
npm install -g ovsx
|
||||
|
||||
- name: 'Get the version'
|
||||
id: 'version'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |
|
||||
# Get the base version from package.json regardless of scenario
|
||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if [[ "${IS_PREVIEW}" == "true" ]]; then
|
||||
# Generate preview version with timestamp based on actual package version
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
PREVIEW_VERSION="${BASE_VERSION}-preview.${TIMESTAMP}"
|
||||
RELEASE_TAG="preview.${TIMESTAMP}"
|
||||
|
||||
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "RELEASE_VERSION=${PREVIEW_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "VSCODE_TAG=preview" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Use specified version or get from package.json
|
||||
if [[ -n "${MANUAL_VERSION}" ]]; then
|
||||
RELEASE_VERSION="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
|
||||
RELEASE_TAG="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
|
||||
else
|
||||
RELEASE_VERSION="${BASE_VERSION}"
|
||||
RELEASE_TAG="${BASE_VERSION}"
|
||||
fi
|
||||
|
||||
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "VSCODE_TAG=latest" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
MANUAL_VERSION: '${{ inputs.version }}'
|
||||
|
||||
- name: 'Update package version (for preview releases)'
|
||||
if: '${{ steps.vars.outputs.is_preview == ''true'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||
run: |-
|
||||
# Update package.json with preview version
|
||||
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |
|
||||
npm run test:ci
|
||||
env:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Prepare VSCode Extension'
|
||||
run: |
|
||||
# Build and stage the extension + bundled CLI once.
|
||||
npm --workspace=qwen-code-vscode-ide-companion run prepackage
|
||||
|
||||
- name: 'Package VSIX (dry run)'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
else
|
||||
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
fi
|
||||
|
||||
- name: 'Upload VSIX Artifact (dry run)'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
|
||||
uses: 'actions/upload-artifact@v4'
|
||||
with:
|
||||
name: 'qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
|
||||
path: 'packages/qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
|
||||
if-no-files-found: 'error'
|
||||
|
||||
- name: 'Publish to Microsoft Marketplace'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
VSCE_PAT: '${{ secrets.VSCE_PAT }}'
|
||||
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
echo "Skipping Microsoft Marketplace for preview release"
|
||||
else
|
||||
vsce publish --pat "${VSCE_PAT}" --tag "${VSCODE_TAG}"
|
||||
fi
|
||||
|
||||
- name: 'Publish to OpenVSX'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}'
|
||||
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
# For preview releases, publish with preview tag
|
||||
# First package the extension for preview
|
||||
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --pre-release
|
||||
else
|
||||
# Package and publish normally
|
||||
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --tag "${VSCODE_TAG}"
|
||||
fi
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
${{ failure() }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |-
|
||||
gh issue create \
|
||||
--title "VSCode IDE Companion Release Failed for ${{ steps.version.outputs.RELEASE_VERSION }} on $(date +'%Y-%m-%d')" \
|
||||
--body "The VSCode IDE Companion release workflow failed. See the full run for details: ${DETAILS_URL}"
|
||||
@@ -5,8 +5,6 @@
|
||||
[](https://nodejs.org/)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15287" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15287" alt="QwenLM%2Fqwen-code | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
**An open-source AI agent that lives in your terminal.**
|
||||
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/zh/users/overview">中文</a> |
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getExtensionManager, extensionToOutputString } from './utils.js';
|
||||
import type { Extension, ExtensionManager } from '@qwen-code/qwen-code-core';
|
||||
import { getExtensionManager } from './utils.js';
|
||||
|
||||
const mockRefreshCache = vi.fn();
|
||||
const mockExtensionManagerInstance = {
|
||||
@@ -65,70 +64,3 @@ describe('getExtensionManager', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extensionToOutputString', () => {
|
||||
const mockIsEnabled = vi.fn();
|
||||
const mockExtensionManager = {
|
||||
isEnabled: mockIsEnabled,
|
||||
} as unknown as ExtensionManager;
|
||||
|
||||
const createMockExtension = (overrides = {}): Extension => ({
|
||||
id: 'test-ext-id',
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/path/to/extension',
|
||||
contextFiles: [],
|
||||
config: { name: 'test-extension', version: '1.0.0' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsEnabled.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should include status icon when inline is false', () => {
|
||||
const extension = createMockExtension();
|
||||
const result = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
false,
|
||||
);
|
||||
|
||||
// Should contain either ✓ or ✗ (with ANSI color codes)
|
||||
expect(result).toMatch(/test-extension/);
|
||||
expect(result).toContain('(1.0.0)');
|
||||
});
|
||||
|
||||
it('should exclude status icon when inline is true', () => {
|
||||
const extension = createMockExtension();
|
||||
const result = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
true,
|
||||
);
|
||||
|
||||
// Should start with extension name (after stripping potential whitespace)
|
||||
expect(result.trim()).toMatch(/^test-extension/);
|
||||
});
|
||||
|
||||
it('should default inline to false', () => {
|
||||
const extension = createMockExtension();
|
||||
const resultWithoutInline = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
);
|
||||
const resultWithInlineFalse = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(resultWithoutInline).toEqual(resultWithInlineFalse);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,6 @@ export function extensionToOutputString(
|
||||
extension: Extension,
|
||||
extensionManager: ExtensionManager,
|
||||
workspaceDir: string,
|
||||
inline = false,
|
||||
): string {
|
||||
const cwd = workspaceDir;
|
||||
const userEnabled = extensionManager.isEnabled(
|
||||
@@ -45,7 +44,7 @@ export function extensionToOutputString(
|
||||
);
|
||||
|
||||
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
|
||||
let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`;
|
||||
let output = `${status} ${extension.config.name} (${extension.config.version})`;
|
||||
output += `\n Path: ${extension.path}`;
|
||||
if (extension.installMetadata) {
|
||||
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
|
||||
|
||||
@@ -139,7 +139,7 @@ export const addCommand: CommandModule = {
|
||||
describe: 'Add a server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.usage('Usage: qwen mcp add [options] <name> <commandOrUrl> [args...]')
|
||||
.usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
|
||||
.parserConfiguration({
|
||||
'unknown-options-as-args': true, // Pass unknown options as server args
|
||||
'populate--': true, // Populate server args after -- separator
|
||||
|
||||
@@ -777,87 +777,4 @@ describe('extensionsCommand', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detail', () => {
|
||||
const detailAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'detail',
|
||||
)?.action;
|
||||
|
||||
if (!detailAction) {
|
||||
throw new Error('Detail action not found');
|
||||
}
|
||||
|
||||
let realMockExtensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
realMockExtensionManager = Object.create(ExtensionManager.prototype);
|
||||
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/extensions detail',
|
||||
name: 'detail',
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getExtensionManager: () => realMockExtensionManager,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage if no name is provided', async () => {
|
||||
await detailAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions detail <extension-name>',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if extension not found', async () => {
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
await detailAction(mockContext, 'nonexistent-extension');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Extension "nonexistent-extension" not found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show extension details when found', async () => {
|
||||
const extension: Extension = {
|
||||
id: 'test-ext',
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/test-ext',
|
||||
contextFiles: [],
|
||||
config: { name: 'test-ext', version: '1.0.0' },
|
||||
};
|
||||
mockGetExtensions.mockReturnValue([extension]);
|
||||
realMockExtensionManager.isEnabled = vi.fn().mockReturnValue(true);
|
||||
|
||||
await detailAction(mockContext, 'test-ext');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('test-ext'),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import open from 'open';
|
||||
import { extensionToOutputString } from '../../commands/extensions/utils.js';
|
||||
|
||||
const EXTENSION_EXPLORE_URL = {
|
||||
Gemini: 'https://geminicli.com/extensions/',
|
||||
@@ -476,53 +475,6 @@ async function enableAction(context: CommandContext, args: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function detailAction(context: CommandContext, args: string) {
|
||||
const extensionManager = context.services.config?.getExtensionManager();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
console.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Usage: /extensions detail <extension-name>'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const extensions = context.services.config!.getExtensions();
|
||||
const extension = extensions.find((extension) => extension.name === name);
|
||||
if (!extension) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Extension "{{name}}" not found.', { name }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: extensionToOutputString(
|
||||
extension,
|
||||
extensionManager,
|
||||
process.cwd(),
|
||||
true,
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeExtensions(
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
@@ -543,10 +495,7 @@ export async function completeExtensions(
|
||||
name.startsWith(partialArg),
|
||||
);
|
||||
|
||||
if (
|
||||
context.invocation?.name !== 'uninstall' &&
|
||||
context.invocation?.name !== 'detail'
|
||||
) {
|
||||
if (context.invocation?.name !== 'uninstall') {
|
||||
if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
|
||||
suggestions.unshift('--all');
|
||||
}
|
||||
@@ -645,16 +594,6 @@ const uninstallCommand: SlashCommand = {
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
const detailCommand: SlashCommand = {
|
||||
name: 'detail',
|
||||
get description() {
|
||||
return t('Get detail of an extension');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: detailAction,
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
export const extensionsCommand: SlashCommand = {
|
||||
name: 'extensions',
|
||||
get description() {
|
||||
@@ -669,7 +608,6 @@ export const extensionsCommand: SlashCommand = {
|
||||
installCommand,
|
||||
uninstallCommand,
|
||||
exploreExtensionsCommand,
|
||||
detailCommand,
|
||||
],
|
||||
action: (context, args) =>
|
||||
// Default to list if no subcommand is provided
|
||||
|
||||
@@ -28,7 +28,6 @@ type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent;
|
||||
import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js';
|
||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||
import { AnthropicContentConverter } from './converter.js';
|
||||
import { buildRuntimeFetchOptions } from '../../utils/runtimeFetchOptions.js';
|
||||
|
||||
type StreamingBlockState = {
|
||||
type: string;
|
||||
@@ -55,9 +54,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
) {
|
||||
const defaultHeaders = this.buildHeaders();
|
||||
const baseURL = contentGeneratorConfig.baseUrl;
|
||||
// Configure runtime options to ensure user-configured timeout works as expected
|
||||
// bodyTimeout is always disabled (0) to let Anthropic SDK timeout control the request
|
||||
const runtimeOptions = buildRuntimeFetchOptions('anthropic');
|
||||
|
||||
this.client = new Anthropic({
|
||||
apiKey: contentGeneratorConfig.apiKey,
|
||||
@@ -65,7 +61,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
timeout: contentGeneratorConfig.timeout,
|
||||
maxRetries: contentGeneratorConfig.maxRetries,
|
||||
defaultHeaders,
|
||||
...runtimeOptions,
|
||||
});
|
||||
|
||||
this.converter = new AnthropicContentConverter(
|
||||
|
||||
@@ -19,8 +19,6 @@ import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { AuthType } from '../../contentGenerator.js';
|
||||
import type { ChatCompletionToolWithCache } from './types.js';
|
||||
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
import type { OpenAIRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
// Mock OpenAI
|
||||
vi.mock('openai', () => ({
|
||||
@@ -34,10 +32,6 @@ vi.mock('openai', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
|
||||
buildRuntimeFetchOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
let provider: DashScopeOpenAICompatibleProvider;
|
||||
let mockContentGeneratorConfig: ContentGeneratorConfig;
|
||||
@@ -45,11 +39,6 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const mockedBuildRuntimeFetchOptions =
|
||||
buildRuntimeFetchOptions as unknown as MockedFunction<
|
||||
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
|
||||
>;
|
||||
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
|
||||
|
||||
// Mock ContentGeneratorConfig
|
||||
mockContentGeneratorConfig = {
|
||||
@@ -196,20 +185,18 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
it('should create OpenAI client with DashScope configuration', () => {
|
||||
const client = provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-CacheControl': 'enable',
|
||||
'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-AuthType': AuthType.QWEN_OAUTH,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-CacheControl': 'enable',
|
||||
'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-AuthType': AuthType.QWEN_OAUTH,
|
||||
},
|
||||
});
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
@@ -220,15 +207,13 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
|
||||
provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
ChatCompletionContentPartWithCache,
|
||||
ChatCompletionToolWithCache,
|
||||
} from './types.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
export class DashScopeOpenAICompatibleProvider
|
||||
implements OpenAICompatibleProvider
|
||||
@@ -69,16 +68,12 @@ export class DashScopeOpenAICompatibleProvider
|
||||
maxRetries = DEFAULT_MAX_RETRIES,
|
||||
} = this.contentGeneratorConfig;
|
||||
const defaultHeaders = this.buildHeaders();
|
||||
// Configure fetch options to ensure user-configured timeout works as expected
|
||||
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
|
||||
const fetchOptions = buildRuntimeFetchOptions('openai');
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
timeout,
|
||||
maxRetries,
|
||||
defaultHeaders,
|
||||
...(fetchOptions ? { fetchOptions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ import { DefaultOpenAICompatibleProvider } from './default.js';
|
||||
import type { Config } from '../../../config/config.js';
|
||||
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
import type { OpenAIRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
// Mock OpenAI
|
||||
vi.mock('openai', () => ({
|
||||
@@ -32,10 +30,6 @@ vi.mock('openai', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
|
||||
buildRuntimeFetchOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DefaultOpenAICompatibleProvider', () => {
|
||||
let provider: DefaultOpenAICompatibleProvider;
|
||||
let mockContentGeneratorConfig: ContentGeneratorConfig;
|
||||
@@ -43,11 +37,6 @@ describe('DefaultOpenAICompatibleProvider', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const mockedBuildRuntimeFetchOptions =
|
||||
buildRuntimeFetchOptions as unknown as MockedFunction<
|
||||
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
|
||||
>;
|
||||
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
|
||||
|
||||
// Mock ContentGeneratorConfig
|
||||
mockContentGeneratorConfig = {
|
||||
@@ -123,17 +112,15 @@ describe('DefaultOpenAICompatibleProvider', () => {
|
||||
it('should create OpenAI client with correct configuration', () => {
|
||||
const client = provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
@@ -144,17 +131,15 @@ describe('DefaultOpenAICompatibleProvider', () => {
|
||||
|
||||
provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include custom headers from buildHeaders', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Config } from '../../../config/config.js';
|
||||
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
|
||||
import type { OpenAICompatibleProvider } from './types.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
/**
|
||||
* Default provider for standard OpenAI-compatible APIs
|
||||
@@ -44,16 +43,12 @@ export class DefaultOpenAICompatibleProvider
|
||||
maxRetries = DEFAULT_MAX_RETRIES,
|
||||
} = this.contentGeneratorConfig;
|
||||
const defaultHeaders = this.buildHeaders();
|
||||
// Configure fetch options to ensure user-configured timeout works as expected
|
||||
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
|
||||
const fetchOptions = buildRuntimeFetchOptions('openai');
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
timeout,
|
||||
maxRetries,
|
||||
defaultHeaders,
|
||||
...(fetchOptions ? { fetchOptions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -218,30 +218,6 @@ describe('extension tests', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use default QWEN.md when contextFileName is empty array', async () => {
|
||||
const extDir = path.join(userExtensionsDir, 'ext-empty-context');
|
||||
fs.mkdirSync(extDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({
|
||||
name: 'ext-empty-context',
|
||||
version: '1.0.0',
|
||||
contextFileName: [],
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context content');
|
||||
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
const extensions = manager.getLoadedExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const ext = extensions.find((e) => e.config.name === 'ext-empty-context');
|
||||
expect(ext?.contextFiles).toEqual([
|
||||
path.join(userExtensionsDir, 'ext-empty-context', 'QWEN.md'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip extensions with invalid JSON and log a warning', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
@@ -718,14 +694,13 @@ describe('extension tests', () => {
|
||||
expect(() => validateName('UPPERCASE')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept names with underscores and dots', () => {
|
||||
expect(() => validateName('my_extension')).not.toThrow();
|
||||
expect(() => validateName('my.extension')).not.toThrow();
|
||||
expect(() => validateName('my_ext.v1')).not.toThrow();
|
||||
expect(() => validateName('ext_1.2.3')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject names with invalid characters', () => {
|
||||
expect(() => validateName('my_extension')).toThrow(
|
||||
'Invalid extension name',
|
||||
);
|
||||
expect(() => validateName('my.extension')).toThrow(
|
||||
'Invalid extension name',
|
||||
);
|
||||
expect(() => validateName('my extension')).toThrow(
|
||||
'Invalid extension name',
|
||||
);
|
||||
|
||||
@@ -190,7 +190,7 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
|
||||
}
|
||||
|
||||
function getContextFileNames(config: ExtensionConfig): string[] {
|
||||
if (!config.contextFileName || config.contextFileName.length === 0) {
|
||||
if (!config.contextFileName) {
|
||||
return ['QWEN.md'];
|
||||
} else if (!Array.isArray(config.contextFileName)) {
|
||||
return [config.contextFileName];
|
||||
@@ -1244,9 +1244,9 @@ export function hashValue(value: string): string {
|
||||
}
|
||||
|
||||
export function validateName(name: string) {
|
||||
if (!/^[a-zA-Z0-9-_.]+$/.test(name)) {
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), underscores (_), dots (.), and dashes (-) are allowed.`,
|
||||
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,51 +117,6 @@ describe('git extension helpers', () => {
|
||||
'Failed to clone Git repository from http://my-repo.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use marketplace source for marketplace type extensions', async () => {
|
||||
const installMetadata = {
|
||||
source: 'marketplace:my-plugin',
|
||||
type: 'marketplace' as const,
|
||||
marketplace: {
|
||||
pluginName: 'my-plugin',
|
||||
marketplaceSource: 'https://github.com/marketplace/my-plugin',
|
||||
},
|
||||
};
|
||||
const destination = '/dest';
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{
|
||||
name: 'origin',
|
||||
refs: { fetch: 'https://github.com/marketplace/my-plugin' },
|
||||
},
|
||||
]);
|
||||
|
||||
await cloneFromGit(installMetadata, destination);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
'https://github.com/marketplace/my-plugin',
|
||||
'./',
|
||||
['--depth', '1'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should use source for marketplace type without marketplace metadata', async () => {
|
||||
const installMetadata = {
|
||||
source: 'http://fallback-repo.com',
|
||||
type: 'marketplace' as const,
|
||||
};
|
||||
const destination = '/dest';
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'http://fallback-repo.com' } },
|
||||
]);
|
||||
|
||||
await cloneFromGit(installMetadata, destination);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
'http://fallback-repo.com',
|
||||
'./',
|
||||
['--depth', '1'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForExtensionUpdate', () => {
|
||||
|
||||
@@ -53,10 +53,7 @@ export async function cloneFromGit(
|
||||
): Promise<void> {
|
||||
try {
|
||||
const git = simpleGit(destination);
|
||||
let sourceUrl =
|
||||
installMetadata.type === 'marketplace' && installMetadata.marketplace
|
||||
? installMetadata.marketplace.marketplaceSource
|
||||
: installMetadata.source;
|
||||
let sourceUrl = installMetadata.source;
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
try {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { EnvHttpProxyAgent } from 'undici';
|
||||
|
||||
/**
|
||||
* JavaScript runtime type
|
||||
*/
|
||||
export type Runtime = 'node' | 'bun' | 'unknown';
|
||||
|
||||
/**
|
||||
* Detect the current JavaScript runtime
|
||||
*/
|
||||
export function detectRuntime(): Runtime {
|
||||
if (typeof process !== 'undefined' && process.versions?.['bun']) {
|
||||
return 'bun';
|
||||
}
|
||||
if (typeof process !== 'undefined' && process.versions?.node) {
|
||||
return 'node';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime fetch options for OpenAI SDK
|
||||
*/
|
||||
export type OpenAIRuntimeFetchOptions =
|
||||
| {
|
||||
dispatcher?: EnvHttpProxyAgent;
|
||||
timeout?: false;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Runtime fetch options for Anthropic SDK
|
||||
*/
|
||||
export type AnthropicRuntimeFetchOptions = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
httpAgent?: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fetch?: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* SDK type identifier
|
||||
*/
|
||||
export type SDKType = 'openai' | 'anthropic';
|
||||
|
||||
/**
|
||||
* Build runtime-specific fetch options for OpenAI SDK
|
||||
*/
|
||||
export function buildRuntimeFetchOptions(
|
||||
sdkType: 'openai',
|
||||
): OpenAIRuntimeFetchOptions;
|
||||
/**
|
||||
* Build runtime-specific fetch options for Anthropic SDK
|
||||
*/
|
||||
export function buildRuntimeFetchOptions(
|
||||
sdkType: 'anthropic',
|
||||
): AnthropicRuntimeFetchOptions;
|
||||
/**
|
||||
* Build runtime-specific fetch options based on the detected runtime and SDK type
|
||||
* This function applies runtime-specific configurations to handle timeout differences
|
||||
* across Node.js and Bun, ensuring user-configured timeout works as expected.
|
||||
*
|
||||
* @param sdkType - The SDK type ('openai' or 'anthropic') to determine return type
|
||||
* @returns Runtime-specific options compatible with the specified SDK
|
||||
*/
|
||||
export function buildRuntimeFetchOptions(
|
||||
sdkType: SDKType,
|
||||
): OpenAIRuntimeFetchOptions | AnthropicRuntimeFetchOptions {
|
||||
const runtime = detectRuntime();
|
||||
|
||||
// Always disable bodyTimeout (set to 0) to let SDK's timeout parameter
|
||||
// control the total request time. bodyTimeout only monitors intervals between
|
||||
// data chunks, not the total request time, so we disable it to ensure user-configured
|
||||
// timeout works as expected for both streaming and non-streaming requests.
|
||||
|
||||
switch (runtime) {
|
||||
case 'bun': {
|
||||
if (sdkType === 'openai') {
|
||||
// Bun: Disable built-in 300s timeout to let OpenAI SDK timeout control
|
||||
// This ensures user-configured timeout works as expected without interference
|
||||
return {
|
||||
timeout: false,
|
||||
};
|
||||
} else {
|
||||
// Bun: Use custom fetch to disable built-in 300s timeout
|
||||
// This allows Anthropic SDK timeout to control the request
|
||||
// Note: Bun's fetch automatically uses proxy settings from environment variables
|
||||
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY), so proxy behavior is preserved
|
||||
const bunFetch: typeof fetch = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
const bunFetchOptions: RequestInit = {
|
||||
...init,
|
||||
// @ts-expect-error - Bun-specific timeout option
|
||||
timeout: false,
|
||||
};
|
||||
return fetch(input, bunFetchOptions);
|
||||
};
|
||||
return {
|
||||
fetch: bunFetch,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case 'node': {
|
||||
// Node.js: Use EnvHttpProxyAgent to configure proxy and disable bodyTimeout
|
||||
// EnvHttpProxyAgent automatically reads proxy settings from environment variables
|
||||
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, etc.) to preserve proxy functionality
|
||||
// bodyTimeout is always 0 (disabled) to let SDK timeout control the request
|
||||
try {
|
||||
const agent = new EnvHttpProxyAgent({
|
||||
bodyTimeout: 0, // Disable to let SDK timeout control total request time
|
||||
});
|
||||
|
||||
if (sdkType === 'openai') {
|
||||
return {
|
||||
dispatcher: agent,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
httpAgent: agent,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// If undici is not available, return appropriate default
|
||||
if (sdkType === 'openai') {
|
||||
return undefined;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
// Unknown runtime: Try to use EnvHttpProxyAgent if available
|
||||
// EnvHttpProxyAgent automatically reads proxy settings from environment variables
|
||||
try {
|
||||
const agent = new EnvHttpProxyAgent({
|
||||
bodyTimeout: 0, // Disable to let SDK timeout control total request time
|
||||
});
|
||||
|
||||
if (sdkType === 'openai') {
|
||||
return {
|
||||
dispatcher: agent,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
httpAgent: agent,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
if (sdkType === 'openai') {
|
||||
return undefined;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface PermissionResponsePayload {
|
||||
optionId: string;
|
||||
}
|
||||
|
||||
export interface PermissionResponseMessage {
|
||||
type: string;
|
||||
data: PermissionResponsePayload;
|
||||
}
|
||||
@@ -431,6 +431,7 @@ export const App: React.FC = () => {
|
||||
type: 'permissionResponse',
|
||||
data: { optionId },
|
||||
});
|
||||
|
||||
setPermissionRequest(null);
|
||||
},
|
||||
[vscode],
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { QwenAgentManager } from '../services/qwenAgentManager.js';
|
||||
import type { ConversationStore } from '../services/conversationStore.js';
|
||||
import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js';
|
||||
import { MessageRouter } from './handlers/MessageRouter.js';
|
||||
|
||||
/**
|
||||
@@ -55,7 +56,7 @@ export class MessageHandler {
|
||||
* Set permission handler
|
||||
*/
|
||||
setPermissionHandler(
|
||||
handler: (message: { type: string; data: { optionId: string } }) => void,
|
||||
handler: (message: PermissionResponseMessage) => void,
|
||||
): void {
|
||||
this.router.setPermissionHandler(handler);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as vscode from 'vscode';
|
||||
import { QwenAgentManager } from '../services/qwenAgentManager.js';
|
||||
import { ConversationStore } from '../services/conversationStore.js';
|
||||
import type { AcpPermissionRequest } from '../types/acpTypes.js';
|
||||
import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js';
|
||||
import { PanelManager } from '../webview/PanelManager.js';
|
||||
import { MessageHandler } from '../webview/MessageHandler.js';
|
||||
import { WebViewContent } from '../webview/WebViewContent.js';
|
||||
@@ -251,10 +252,7 @@ export class WebViewProvider {
|
||||
}
|
||||
}
|
||||
};
|
||||
const handler = (message: {
|
||||
type: string;
|
||||
data: { optionId: string };
|
||||
}) => {
|
||||
const handler = (message: PermissionResponseMessage) => {
|
||||
if (message.type !== 'permissionResponse') {
|
||||
return;
|
||||
}
|
||||
@@ -270,6 +268,16 @@ export class WebViewProvider {
|
||||
optionId.toLowerCase().includes('reject');
|
||||
|
||||
if (isCancel) {
|
||||
// Close any open qwen-diff editors first
|
||||
try {
|
||||
void vscode.commands.executeCommand('qwen.diff.closeAll');
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Failed to close diffs after reject:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget – do not block the ACP resolve
|
||||
(async () => {
|
||||
try {
|
||||
@@ -296,7 +304,6 @@ export class WebViewProvider {
|
||||
const title =
|
||||
(request.toolCall as { title?: string } | undefined)
|
||||
?.title || '';
|
||||
// Normalize kind for UI – fall back to 'execute'
|
||||
let kind = ((
|
||||
request.toolCall as { kind?: string } | undefined
|
||||
)?.kind || 'execute') as string;
|
||||
@@ -319,7 +326,6 @@ export class WebViewProvider {
|
||||
title,
|
||||
kind,
|
||||
status: 'failed',
|
||||
// Best-effort pass-through (used by UI hints)
|
||||
rawInput: (request.toolCall as { rawInput?: unknown })
|
||||
?.rawInput,
|
||||
locations: (
|
||||
|
||||
@@ -24,10 +24,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
const [customMessage, setCustomMessage] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting
|
||||
const customInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall);
|
||||
// Prefer file name from locations, fall back to content[].path if present
|
||||
@@ -94,10 +91,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
|
||||
// Number keys 1-9 for quick select
|
||||
const numMatch = e.key.match(/^[1-9]$/);
|
||||
if (
|
||||
numMatch &&
|
||||
!customInputRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
if (numMatch) {
|
||||
const index = parseInt(e.key, 10) - 1;
|
||||
if (index < options.length) {
|
||||
e.preventDefault();
|
||||
@@ -109,7 +103,10 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
// Arrow keys for navigation
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const totalItems = options.length + 1; // +1 for custom input
|
||||
if (options.length === 0) {
|
||||
return;
|
||||
}
|
||||
const totalItems = options.length;
|
||||
if (e.key === 'ArrowDown') {
|
||||
setFocusedIndex((prev) => (prev + 1) % totalItems);
|
||||
} else {
|
||||
@@ -118,10 +115,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!customInputRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (focusedIndex < options.length) {
|
||||
onResponse(options[focusedIndex].optionId);
|
||||
@@ -234,28 +228,6 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom message input (extracted component) */}
|
||||
{(() => {
|
||||
const isFocused = focusedIndex === options.length;
|
||||
const rejectOptionId = options.find((o) =>
|
||||
o.kind.includes('reject'),
|
||||
)?.optionId;
|
||||
return (
|
||||
<CustomMessageInputRow
|
||||
isFocused={isFocused}
|
||||
customMessage={customMessage}
|
||||
setCustomMessage={setCustomMessage}
|
||||
onFocusRow={() => setFocusedIndex(options.length)}
|
||||
onSubmitReject={() => {
|
||||
if (rejectOptionId) {
|
||||
onResponse(rejectOptionId);
|
||||
}
|
||||
}}
|
||||
inputRef={customInputRef}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -263,50 +235,3 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CustomMessageInputRow: Reusable custom input row component (without hooks)
|
||||
*/
|
||||
interface CustomMessageInputRowProps {
|
||||
isFocused: boolean;
|
||||
customMessage: string;
|
||||
setCustomMessage: (val: string) => void;
|
||||
onFocusRow: () => void; // Set focus when mouse enters or input box is focused
|
||||
onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option)
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
|
||||
isFocused,
|
||||
customMessage,
|
||||
setCustomMessage,
|
||||
onFocusRow,
|
||||
onSubmitReject,
|
||||
inputRef,
|
||||
}) => (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] cursor-text text-[var(--app-primary-foreground)] ${
|
||||
isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
|
||||
}`}
|
||||
onMouseEnter={onFocusRow}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef as React.LegacyRef<HTMLInputElement> | undefined}
|
||||
type="text"
|
||||
placeholder="Tell Qwen what to do instead"
|
||||
spellCheck={false}
|
||||
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
|
||||
style={{ color: 'var(--app-input-foreground)' }}
|
||||
value={customMessage}
|
||||
onChange={(e) => setCustomMessage(e.target.value)}
|
||||
onFocus={onFocusRow}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
|
||||
e.preventDefault();
|
||||
onSubmitReject();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { IMessageHandler } from './BaseMessageHandler.js';
|
||||
import type { QwenAgentManager } from '../../services/qwenAgentManager.js';
|
||||
import type { ConversationStore } from '../../services/conversationStore.js';
|
||||
import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js';
|
||||
import { SessionMessageHandler } from './SessionMessageHandler.js';
|
||||
import { FileMessageHandler } from './FileMessageHandler.js';
|
||||
import { EditorMessageHandler } from './EditorMessageHandler.js';
|
||||
@@ -22,7 +23,7 @@ export class MessageRouter {
|
||||
private authHandler: AuthMessageHandler;
|
||||
private currentConversationId: string | null = null;
|
||||
private permissionHandler:
|
||||
| ((message: { type: string; data: { optionId: string } }) => void)
|
||||
| ((message: PermissionResponseMessage) => void)
|
||||
| null = null;
|
||||
|
||||
constructor(
|
||||
@@ -80,9 +81,7 @@ export class MessageRouter {
|
||||
// Handle permission response specially
|
||||
if (message.type === 'permissionResponse') {
|
||||
if (this.permissionHandler) {
|
||||
this.permissionHandler(
|
||||
message as { type: string; data: { optionId: string } },
|
||||
);
|
||||
this.permissionHandler(message as PermissionResponseMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -131,7 +130,7 @@ export class MessageRouter {
|
||||
* Set permission handler
|
||||
*/
|
||||
setPermissionHandler(
|
||||
handler: (message: { type: string; data: { optionId: string } }) => void,
|
||||
handler: (message: PermissionResponseMessage) => void,
|
||||
): void {
|
||||
this.permissionHandler = handler;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user