mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-23 17:26:23 +00:00
Compare commits
2 Commits
main
...
feat/vscod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8d2a26eb | ||
|
|
48511c58a5 |
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> |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const AGENT_METHODS = {
|
||||
session_prompt: 'session/prompt',
|
||||
session_save: 'session/save',
|
||||
session_set_mode: 'session/set_mode',
|
||||
session_set_model: 'session/set_model',
|
||||
} as const;
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
|
||||
@@ -401,6 +401,21 @@ export class AcpConnection {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model for current session
|
||||
*
|
||||
* @param modelId - Model ID
|
||||
* @returns Set model response
|
||||
*/
|
||||
async setModel(modelId: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.setModel(
|
||||
modelId,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AcpSessionManager } from './acpSessionManager.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import type { PendingRequest } from '../types/connectionTypes.js';
|
||||
import { AGENT_METHODS } from '../constants/acpSchema.js';
|
||||
|
||||
describe('AcpSessionManager', () => {
|
||||
let sessionManager: AcpSessionManager;
|
||||
let mockChild: ChildProcess;
|
||||
let pendingRequests: Map<number, PendingRequest<unknown>>;
|
||||
let nextRequestId: { value: number };
|
||||
let writtenMessages: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
sessionManager = new AcpSessionManager();
|
||||
writtenMessages = [];
|
||||
|
||||
mockChild = {
|
||||
stdin: {
|
||||
write: vi.fn((msg: string) => {
|
||||
writtenMessages.push(msg);
|
||||
// Simulate async response
|
||||
const parsed = JSON.parse(msg.trim());
|
||||
const id = parsed.id;
|
||||
setTimeout(() => {
|
||||
const pending = pendingRequests.get(id);
|
||||
if (pending) {
|
||||
pending.resolve({ modeId: 'default', modelId: 'test-model' });
|
||||
pendingRequests.delete(id);
|
||||
}
|
||||
}, 10);
|
||||
}),
|
||||
},
|
||||
} as unknown as ChildProcess;
|
||||
|
||||
pendingRequests = new Map();
|
||||
nextRequestId = { value: 0 };
|
||||
});
|
||||
|
||||
describe('setModel', () => {
|
||||
it('sends session/set_model request with correct parameters', async () => {
|
||||
// First initialize the session
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
sessionManager.sessionId = 'test-session-id';
|
||||
|
||||
const responsePromise = sessionManager.setModel(
|
||||
'qwen3-coder-plus',
|
||||
mockChild,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
|
||||
// Wait for the response
|
||||
const response = await responsePromise;
|
||||
|
||||
// Verify the message was sent
|
||||
expect(writtenMessages.length).toBe(1);
|
||||
const sentMessage = JSON.parse(writtenMessages[0].trim());
|
||||
|
||||
expect(sentMessage.method).toBe(AGENT_METHODS.session_set_model);
|
||||
expect(sentMessage.params).toEqual({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: 'qwen3-coder-plus',
|
||||
});
|
||||
expect(response).toEqual({ modeId: 'default', modelId: 'test-model' });
|
||||
});
|
||||
|
||||
it('throws error when no active session', async () => {
|
||||
await expect(
|
||||
sessionManager.setModel(
|
||||
'qwen3-coder-plus',
|
||||
mockChild,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
),
|
||||
).rejects.toThrow('No active ACP session');
|
||||
});
|
||||
|
||||
it('increments request ID for each call', async () => {
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
sessionManager.sessionId = 'test-session-id';
|
||||
|
||||
await sessionManager.setModel(
|
||||
'model-1',
|
||||
mockChild,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
|
||||
await sessionManager.setModel(
|
||||
'model-2',
|
||||
mockChild,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
|
||||
const firstMessage = JSON.parse(writtenMessages[0].trim());
|
||||
const secondMessage = JSON.parse(writtenMessages[1].trim());
|
||||
|
||||
expect(firstMessage.id).toBe(0);
|
||||
expect(secondMessage.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMode', () => {
|
||||
it('sends session/set_mode request with correct parameters', async () => {
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
sessionManager.sessionId = 'test-session-id';
|
||||
|
||||
const responsePromise = sessionManager.setMode(
|
||||
'auto-edit',
|
||||
mockChild,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(writtenMessages.length).toBe(1);
|
||||
const sentMessage = JSON.parse(writtenMessages[0].trim());
|
||||
|
||||
expect(sentMessage.method).toBe(AGENT_METHODS.session_set_mode);
|
||||
expect(sentMessage.params).toEqual({
|
||||
sessionId: 'test-session-id',
|
||||
modeId: 'auto-edit',
|
||||
});
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws error when no active session', async () => {
|
||||
await expect(
|
||||
sessionManager.setMode(
|
||||
'default',
|
||||
mockChild,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
),
|
||||
).rejects.toThrow('No active ACP session');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -375,6 +375,32 @@ export class AcpSessionManager {
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model for current session (ACP session/set_model)
|
||||
*
|
||||
* @param modelId - Model ID
|
||||
*/
|
||||
async setModel(
|
||||
modelId: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
if (!this.sessionId) {
|
||||
throw new Error('No active ACP session');
|
||||
}
|
||||
console.log('[ACP] Sending session/set_model:', modelId);
|
||||
const res = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_set_model,
|
||||
{ sessionId: this.sessionId, modelId },
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log('[ACP] set_model response:', res);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to specified session
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AcpPermissionRequest,
|
||||
AuthenticateUpdateNotification,
|
||||
ModelInfo,
|
||||
AvailableCommand,
|
||||
} from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
|
||||
@@ -26,7 +27,10 @@ import {
|
||||
} from '../services/qwenConnectionHandler.js';
|
||||
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
|
||||
import { authMethod } from '../types/acpTypes.js';
|
||||
import { extractModelInfoFromNewSessionResult } from '../utils/acpModelInfo.js';
|
||||
import {
|
||||
extractModelInfoFromNewSessionResult,
|
||||
extractSessionModelState,
|
||||
} from '../utils/acpModelInfo.js';
|
||||
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
|
||||
import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';
|
||||
|
||||
@@ -207,6 +211,16 @@ export class QwenAgentManager {
|
||||
if (res.modelInfo && this.callbacks.onModelInfo) {
|
||||
this.callbacks.onModelInfo(res.modelInfo);
|
||||
}
|
||||
// Emit available models from connect result
|
||||
if (res.availableModels && res.availableModels.length > 0) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Emitting availableModels from connect():',
|
||||
res.availableModels.map((m) => m.modelId),
|
||||
);
|
||||
if (this.callbacks.onAvailableModels) {
|
||||
this.callbacks.onAvailableModels(res.availableModels);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -245,6 +259,27 @@ export class QwenAgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model from UI
|
||||
*/
|
||||
async setModelFromUi(modelId: string): Promise<ModelInfo | null> {
|
||||
try {
|
||||
const res = await this.connection.setModel(modelId);
|
||||
// Parse response and notify UI
|
||||
const result = (res?.result || {}) as { modelId?: string };
|
||||
const confirmedModelId = result.modelId || modelId;
|
||||
const modelInfo: ModelInfo = {
|
||||
modelId: confirmedModelId,
|
||||
name: confirmedModelId,
|
||||
};
|
||||
this.callbacks.onModelChanged?.(modelInfo);
|
||||
return modelInfo;
|
||||
} catch (err) {
|
||||
console.error('[QwenAgentManager] Failed to set model:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if current session is still active
|
||||
* This is a lightweight check to verify session validity
|
||||
@@ -1087,10 +1122,17 @@ export class QwenAgentManager {
|
||||
const autoAuthenticate = options?.autoAuthenticate ?? true;
|
||||
// Reuse existing session if present
|
||||
if (this.connection.currentSessionId) {
|
||||
console.log(
|
||||
'[QwenAgentManager] createNewSession: reusing existing session',
|
||||
this.connection.currentSessionId,
|
||||
);
|
||||
return this.connection.currentSessionId;
|
||||
}
|
||||
// Deduplicate concurrent session/new attempts
|
||||
if (this.sessionCreateInFlight) {
|
||||
console.log(
|
||||
'[QwenAgentManager] createNewSession: session creation already in flight',
|
||||
);
|
||||
return this.sessionCreateInFlight;
|
||||
}
|
||||
|
||||
@@ -1102,6 +1144,10 @@ export class QwenAgentManager {
|
||||
// Try to create a new ACP session. If Qwen asks for auth, let it handle authentication.
|
||||
try {
|
||||
newSessionResult = await this.connection.newSession(workingDir);
|
||||
console.log(
|
||||
'[QwenAgentManager] newSession returned:',
|
||||
JSON.stringify(newSessionResult, null, 2),
|
||||
);
|
||||
} catch (err) {
|
||||
const requiresAuth = isAuthenticationRequiredError(err);
|
||||
|
||||
@@ -1142,6 +1188,30 @@ export class QwenAgentManager {
|
||||
this.callbacks.onModelInfo(modelInfo);
|
||||
}
|
||||
|
||||
// Extract and emit available models
|
||||
const modelState = extractSessionModelState(newSessionResult);
|
||||
console.log(
|
||||
'[QwenAgentManager] Extracted model state from session/new:',
|
||||
modelState,
|
||||
);
|
||||
if (
|
||||
modelState?.availableModels &&
|
||||
modelState.availableModels.length > 0
|
||||
) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Emitting availableModels:',
|
||||
modelState.availableModels,
|
||||
);
|
||||
if (this.callbacks.onAvailableModels) {
|
||||
this.callbacks.onAvailableModels(modelState.availableModels);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
'[QwenAgentManager] No availableModels found in session/new response. Raw models field:',
|
||||
(newSessionResult as Record<string, unknown>)?.models,
|
||||
);
|
||||
}
|
||||
|
||||
const newSessionId = this.connection.currentSessionId;
|
||||
console.log(
|
||||
'[QwenAgentManager] New session created with ID:',
|
||||
@@ -1288,6 +1358,30 @@ export class QwenAgentManager {
|
||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for model changed updates (from ACP current_model_update)
|
||||
*/
|
||||
onModelChanged(callback: (model: ModelInfo) => void): void {
|
||||
this.callbacks.onModelChanged = callback;
|
||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for available commands updates (from ACP available_commands_update)
|
||||
*/
|
||||
onAvailableCommands(callback: (commands: AvailableCommand[]) => void): void {
|
||||
this.callbacks.onAvailableCommands = callback;
|
||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for available models updates (from session/new response)
|
||||
*/
|
||||
onAvailableModels(callback: (models: ModelInfo[]) => void): void {
|
||||
this.callbacks.onAvailableModels = callback;
|
||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
|
||||
@@ -13,13 +13,17 @@
|
||||
import type { AcpConnection } from './acpConnection.js';
|
||||
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
|
||||
import { authMethod } from '../types/acpTypes.js';
|
||||
import { extractModelInfoFromNewSessionResult } from '../utils/acpModelInfo.js';
|
||||
import {
|
||||
extractModelInfoFromNewSessionResult,
|
||||
extractSessionModelState,
|
||||
} from '../utils/acpModelInfo.js';
|
||||
import type { ModelInfo } from '../types/acpTypes.js';
|
||||
|
||||
export interface QwenConnectionResult {
|
||||
sessionCreated: boolean;
|
||||
requiresAuth: boolean;
|
||||
modelInfo?: ModelInfo;
|
||||
availableModels?: ModelInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +52,7 @@ export class QwenConnectionHandler {
|
||||
let sessionCreated = false;
|
||||
let requiresAuth = false;
|
||||
let modelInfo: ModelInfo | undefined;
|
||||
let availableModels: ModelInfo[] | undefined;
|
||||
|
||||
// Build extra CLI arguments (only essential parameters)
|
||||
const extraArgs: string[] = [];
|
||||
@@ -79,6 +84,20 @@ export class QwenConnectionHandler {
|
||||
);
|
||||
modelInfo =
|
||||
extractModelInfoFromNewSessionResult(newSessionResult) || undefined;
|
||||
|
||||
// Extract available models from session/new response
|
||||
const modelState = extractSessionModelState(newSessionResult);
|
||||
if (
|
||||
modelState?.availableModels &&
|
||||
modelState.availableModels.length > 0
|
||||
) {
|
||||
availableModels = modelState.availableModels;
|
||||
console.log(
|
||||
'[QwenAgentManager] Extracted availableModels from session/new:',
|
||||
availableModels.map((m) => m.modelId),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[QwenAgentManager] New session created successfully');
|
||||
sessionCreated = true;
|
||||
} catch (sessionError) {
|
||||
@@ -105,7 +124,7 @@ export class QwenConnectionHandler {
|
||||
console.log(`\n========================================`);
|
||||
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
|
||||
console.log(`========================================\n`);
|
||||
return { sessionCreated, requiresAuth, modelInfo };
|
||||
return { sessionCreated, requiresAuth, modelInfo, availableModels };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
|
||||
import type { AcpSessionUpdate } from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
|
||||
|
||||
describe('QwenSessionUpdateHandler', () => {
|
||||
let handler: QwenSessionUpdateHandler;
|
||||
let mockCallbacks: QwenAgentCallbacks;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCallbacks = {
|
||||
onStreamChunk: vi.fn(),
|
||||
onThoughtChunk: vi.fn(),
|
||||
onToolCall: vi.fn(),
|
||||
onPlan: vi.fn(),
|
||||
onModeChanged: vi.fn(),
|
||||
onModelChanged: vi.fn(),
|
||||
onUsageUpdate: vi.fn(),
|
||||
onAvailableCommands: vi.fn(),
|
||||
};
|
||||
handler = new QwenSessionUpdateHandler(mockCallbacks);
|
||||
});
|
||||
|
||||
describe('current_model_update handling', () => {
|
||||
it('calls onModelChanged callback with model info', () => {
|
||||
const modelUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'current_model_update',
|
||||
model: {
|
||||
modelId: 'qwen3-coder-plus',
|
||||
name: 'Qwen3 Coder Plus',
|
||||
description: 'A powerful coding model',
|
||||
},
|
||||
},
|
||||
} as AcpSessionUpdate;
|
||||
|
||||
handler.handleSessionUpdate(modelUpdate);
|
||||
|
||||
expect(mockCallbacks.onModelChanged).toHaveBeenCalledWith({
|
||||
modelId: 'qwen3-coder-plus',
|
||||
name: 'Qwen3 Coder Plus',
|
||||
description: 'A powerful coding model',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles model update with _meta field', () => {
|
||||
const modelUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'current_model_update',
|
||||
model: {
|
||||
modelId: 'test-model',
|
||||
name: 'Test Model',
|
||||
_meta: { contextLimit: 128000 },
|
||||
},
|
||||
},
|
||||
} as AcpSessionUpdate;
|
||||
|
||||
handler.handleSessionUpdate(modelUpdate);
|
||||
|
||||
expect(mockCallbacks.onModelChanged).toHaveBeenCalledWith({
|
||||
modelId: 'test-model',
|
||||
name: 'Test Model',
|
||||
_meta: { contextLimit: 128000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call callback when onModelChanged is not set', () => {
|
||||
const handlerWithoutCallback = new QwenSessionUpdateHandler({});
|
||||
|
||||
const modelUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'current_model_update',
|
||||
model: {
|
||||
modelId: 'qwen3-coder',
|
||||
name: 'Qwen3 Coder',
|
||||
},
|
||||
},
|
||||
} as AcpSessionUpdate;
|
||||
|
||||
// Should not throw
|
||||
expect(() =>
|
||||
handlerWithoutCallback.handleSessionUpdate(modelUpdate),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('current_mode_update handling', () => {
|
||||
it('calls onModeChanged callback with mode id', () => {
|
||||
const modeUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'current_mode_update',
|
||||
modeId: 'auto-edit' as ApprovalModeValue,
|
||||
},
|
||||
} as AcpSessionUpdate;
|
||||
|
||||
handler.handleSessionUpdate(modeUpdate);
|
||||
|
||||
expect(mockCallbacks.onModeChanged).toHaveBeenCalledWith('auto-edit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent_message_chunk handling', () => {
|
||||
it('calls onStreamChunk callback with text content', () => {
|
||||
const messageUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'Hello, world!',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
handler.handleSessionUpdate(messageUpdate);
|
||||
|
||||
expect(mockCallbacks.onStreamChunk).toHaveBeenCalledWith('Hello, world!');
|
||||
});
|
||||
|
||||
it('emits usage metadata when present', () => {
|
||||
const messageUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'Response',
|
||||
},
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
totalTokens: 150,
|
||||
},
|
||||
durationMs: 1234,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
handler.handleSessionUpdate(messageUpdate);
|
||||
|
||||
expect(mockCallbacks.onUsageUpdate).toHaveBeenCalledWith({
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
totalTokens: 150,
|
||||
},
|
||||
durationMs: 1234,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool_call handling', () => {
|
||||
it('calls onToolCall callback with tool call data', () => {
|
||||
const toolCallUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
kind: 'read',
|
||||
title: 'Read file',
|
||||
status: 'pending',
|
||||
rawInput: { path: '/test/file.ts' },
|
||||
},
|
||||
};
|
||||
|
||||
handler.handleSessionUpdate(toolCallUpdate);
|
||||
|
||||
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith({
|
||||
toolCallId: 'call-123',
|
||||
kind: 'read',
|
||||
title: 'Read file',
|
||||
status: 'pending',
|
||||
rawInput: { path: '/test/file.ts' },
|
||||
content: undefined,
|
||||
locations: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan handling', () => {
|
||||
it('calls onPlan callback with plan entries', () => {
|
||||
const planUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'plan',
|
||||
entries: [
|
||||
{ content: 'Step 1', priority: 'high', status: 'pending' },
|
||||
{ content: 'Step 2', priority: 'medium', status: 'pending' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
handler.handleSessionUpdate(planUpdate);
|
||||
|
||||
expect(mockCallbacks.onPlan).toHaveBeenCalledWith([
|
||||
{ content: 'Step 1', priority: 'high', status: 'pending' },
|
||||
{ content: 'Step 2', priority: 'medium', status: 'pending' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to stream chunk when onPlan is not set', () => {
|
||||
const handlerWithStream = new QwenSessionUpdateHandler({
|
||||
onStreamChunk: vi.fn(),
|
||||
});
|
||||
|
||||
const planUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'plan',
|
||||
entries: [{ content: 'Task 1', priority: 'high', status: 'pending' }],
|
||||
},
|
||||
};
|
||||
|
||||
handlerWithStream.handleSessionUpdate(planUpdate);
|
||||
|
||||
expect(handlerWithStream['callbacks'].onStreamChunk).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('available_commands_update handling', () => {
|
||||
it('calls onAvailableCommands callback with commands', () => {
|
||||
const commandsUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{
|
||||
name: 'compress',
|
||||
description: 'Compress the context',
|
||||
input: null,
|
||||
},
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize the project',
|
||||
input: null,
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
description: 'Generate project summary',
|
||||
input: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as AcpSessionUpdate;
|
||||
|
||||
handler.handleSessionUpdate(commandsUpdate);
|
||||
|
||||
expect(mockCallbacks.onAvailableCommands).toHaveBeenCalledWith([
|
||||
{ name: 'compress', description: 'Compress the context', input: null },
|
||||
{ name: 'init', description: 'Initialize the project', input: null },
|
||||
{
|
||||
name: 'summary',
|
||||
description: 'Generate project summary',
|
||||
input: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles commands with input hint', () => {
|
||||
const commandsUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search for files',
|
||||
input: { hint: 'Enter search query' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as AcpSessionUpdate;
|
||||
|
||||
handler.handleSessionUpdate(commandsUpdate);
|
||||
|
||||
expect(mockCallbacks.onAvailableCommands).toHaveBeenCalledWith([
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search for files',
|
||||
input: { hint: 'Enter search query' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not call callback when onAvailableCommands is not set', () => {
|
||||
const handlerWithoutCallback = new QwenSessionUpdateHandler({});
|
||||
|
||||
const commandsUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{ name: 'compress', description: 'Compress', input: null },
|
||||
],
|
||||
},
|
||||
} as AcpSessionUpdate;
|
||||
|
||||
// Should not throw
|
||||
expect(() =>
|
||||
handlerWithoutCallback.handleSessionUpdate(commandsUpdate),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles empty commands list', () => {
|
||||
const commandsUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [],
|
||||
},
|
||||
} as AcpSessionUpdate;
|
||||
|
||||
handler.handleSessionUpdate(commandsUpdate);
|
||||
|
||||
expect(mockCallbacks.onAvailableCommands).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCallbacks', () => {
|
||||
it('updates callbacks and uses new ones', () => {
|
||||
const newOnModelChanged = vi.fn();
|
||||
handler.updateCallbacks({
|
||||
...mockCallbacks,
|
||||
onModelChanged: newOnModelChanged,
|
||||
});
|
||||
|
||||
const modelUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'current_model_update',
|
||||
model: {
|
||||
modelId: 'new-model',
|
||||
name: 'New Model',
|
||||
},
|
||||
},
|
||||
} as AcpSessionUpdate;
|
||||
|
||||
handler.handleSessionUpdate(modelUpdate);
|
||||
|
||||
expect(newOnModelChanged).toHaveBeenCalled();
|
||||
expect(mockCallbacks.onModelChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates onAvailableCommands callback', () => {
|
||||
const newOnAvailableCommands = vi.fn();
|
||||
handler.updateCallbacks({
|
||||
...mockCallbacks,
|
||||
onAvailableCommands: newOnAvailableCommands,
|
||||
});
|
||||
|
||||
const commandsUpdate: AcpSessionUpdate = {
|
||||
sessionId: 'test-session',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{ name: 'test', description: 'Test command', input: null },
|
||||
],
|
||||
},
|
||||
} as AcpSessionUpdate;
|
||||
|
||||
handler.handleSessionUpdate(commandsUpdate);
|
||||
|
||||
expect(newOnAvailableCommands).toHaveBeenCalled();
|
||||
expect(mockCallbacks.onAvailableCommands).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,12 @@
|
||||
* Handles session updates from ACP and dispatches them to appropriate callbacks
|
||||
*/
|
||||
|
||||
import type { AcpSessionUpdate, SessionUpdateMeta } from '../types/acpTypes.js';
|
||||
import type {
|
||||
AcpSessionUpdate,
|
||||
SessionUpdateMeta,
|
||||
ModelInfo,
|
||||
AvailableCommand,
|
||||
} from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import type {
|
||||
QwenAgentCallbacks,
|
||||
@@ -160,6 +165,40 @@ export class QwenSessionUpdateHandler {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'current_model_update': {
|
||||
// Notify UI about model change
|
||||
try {
|
||||
const model = (update as unknown as { model?: ModelInfo }).model;
|
||||
if (model && this.callbacks.onModelChanged) {
|
||||
this.callbacks.onModelChanged(model);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[SessionUpdateHandler] Failed to handle model update',
|
||||
err,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'available_commands_update': {
|
||||
// Notify UI about available commands
|
||||
try {
|
||||
const commands = (
|
||||
update as unknown as { availableCommands?: AvailableCommand[] }
|
||||
).availableCommands;
|
||||
if (commands && this.callbacks.onAvailableCommands) {
|
||||
this.callbacks.onAvailableCommands(commands);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[SessionUpdateHandler] Failed to handle available commands update',
|
||||
err,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log('[QwenAgentManager] Unhandled session update type');
|
||||
break;
|
||||
|
||||
@@ -197,6 +197,31 @@ export interface CurrentModeUpdate extends BaseSessionUpdate {
|
||||
};
|
||||
}
|
||||
|
||||
// Current model update (sent by agent when model changes)
|
||||
export interface CurrentModelUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'current_model_update';
|
||||
model: ModelInfo;
|
||||
};
|
||||
}
|
||||
|
||||
// Available command definition
|
||||
export interface AvailableCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
input?: {
|
||||
hint?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Available commands update (sent by agent after session creation)
|
||||
export interface AvailableCommandsUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update';
|
||||
availableCommands: AvailableCommand[];
|
||||
};
|
||||
}
|
||||
|
||||
// Authenticate update (sent by agent during authentication process)
|
||||
export interface AuthenticateUpdateNotification {
|
||||
_meta: {
|
||||
@@ -211,7 +236,9 @@ export type AcpSessionUpdate =
|
||||
| ToolCallUpdate
|
||||
| ToolCallStatusUpdate
|
||||
| PlanUpdate
|
||||
| CurrentModeUpdate;
|
||||
| CurrentModeUpdate
|
||||
| CurrentModelUpdate
|
||||
| AvailableCommandsUpdate;
|
||||
|
||||
// Permission request (simplified version, use schema.RequestPermissionRequest for validation)
|
||||
export interface AcpPermissionRequest {
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { AcpPermissionRequest, ModelInfo } from './acpTypes.js';
|
||||
import type {
|
||||
AcpPermissionRequest,
|
||||
ModelInfo,
|
||||
AvailableCommand,
|
||||
} from './acpTypes.js';
|
||||
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -59,6 +63,9 @@ export interface QwenAgentCallbacks {
|
||||
onModeChanged?: (modeId: ApprovalModeValue) => void;
|
||||
onUsageUpdate?: (stats: UsageStatsPayload) => void;
|
||||
onModelInfo?: (info: ModelInfo) => void;
|
||||
onModelChanged?: (model: ModelInfo) => void;
|
||||
onAvailableCommands?: (commands: AvailableCommand[]) => void;
|
||||
onAvailableModels?: (models: ModelInfo[]) => void;
|
||||
}
|
||||
|
||||
export interface ToolCallUpdate {
|
||||
|
||||
@@ -16,4 +16,14 @@ export interface CompletionItem {
|
||||
value?: string;
|
||||
// Optional full path for files (used to build @filename -> full path mapping)
|
||||
path?: string;
|
||||
// Optional group name for grouping items in the completion menu
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped completion items for display
|
||||
*/
|
||||
export interface CompletionGroup {
|
||||
name: string;
|
||||
items: CompletionItem[];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,138 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractModelInfoFromNewSessionResult } from './acpModelInfo.js';
|
||||
import {
|
||||
extractModelInfoFromNewSessionResult,
|
||||
extractSessionModelState,
|
||||
} from './acpModelInfo.js';
|
||||
|
||||
describe('extractSessionModelState', () => {
|
||||
it('extracts full model state from NewSessionResponse.models', () => {
|
||||
const result = extractSessionModelState({
|
||||
sessionId: 's',
|
||||
models: {
|
||||
currentModelId: 'qwen3-coder-plus',
|
||||
availableModels: [
|
||||
{
|
||||
modelId: 'qwen3-coder-plus',
|
||||
name: 'Qwen3 Coder Plus',
|
||||
description: null,
|
||||
_meta: { contextLimit: 123 },
|
||||
},
|
||||
{
|
||||
modelId: 'qwen3-coder',
|
||||
name: 'Qwen3 Coder',
|
||||
description: 'Standard model',
|
||||
_meta: { contextLimit: 64 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
currentModelId: 'qwen3-coder-plus',
|
||||
availableModels: [
|
||||
{
|
||||
modelId: 'qwen3-coder-plus',
|
||||
name: 'Qwen3 Coder Plus',
|
||||
description: null,
|
||||
_meta: { contextLimit: 123 },
|
||||
},
|
||||
{
|
||||
modelId: 'qwen3-coder',
|
||||
name: 'Qwen3 Coder',
|
||||
description: 'Standard model',
|
||||
_meta: { contextLimit: 64 },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns all available models', () => {
|
||||
const result = extractSessionModelState({
|
||||
models: {
|
||||
currentModelId: 'model-a',
|
||||
availableModels: [
|
||||
{ modelId: 'model-a', name: 'Model A' },
|
||||
{ modelId: 'model-b', name: 'Model B' },
|
||||
{ modelId: 'model-c', name: 'Model C' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.availableModels).toHaveLength(3);
|
||||
expect(result?.availableModels.map((m) => m.modelId)).toEqual([
|
||||
'model-a',
|
||||
'model-b',
|
||||
'model-c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults to first model if currentModelId is missing', () => {
|
||||
const result = extractSessionModelState({
|
||||
models: {
|
||||
availableModels: [
|
||||
{ modelId: 'first', name: 'First Model' },
|
||||
{ modelId: 'second', name: 'Second Model' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.currentModelId).toBe('first');
|
||||
});
|
||||
|
||||
it('handles legacy array format', () => {
|
||||
const result = extractSessionModelState({
|
||||
models: [
|
||||
{ modelId: 'legacy-1', name: 'Legacy 1' },
|
||||
{ modelId: 'legacy-2', name: 'Legacy 2' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
currentModelId: 'legacy-1',
|
||||
availableModels: [
|
||||
{ modelId: 'legacy-1', name: 'Legacy 1' },
|
||||
{ modelId: 'legacy-2', name: 'Legacy 2' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out invalid model entries', () => {
|
||||
const result = extractSessionModelState({
|
||||
models: {
|
||||
currentModelId: 'valid',
|
||||
availableModels: [
|
||||
{ name: '', modelId: '' }, // invalid
|
||||
{ modelId: 'valid', name: 'Valid Model' },
|
||||
{}, // invalid
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.availableModels).toHaveLength(1);
|
||||
expect(result?.availableModels[0].modelId).toBe('valid');
|
||||
});
|
||||
|
||||
it('returns null when models field is missing', () => {
|
||||
expect(extractSessionModelState({})).toBeNull();
|
||||
expect(extractSessionModelState(null)).toBeNull();
|
||||
expect(extractSessionModelState({ sessionId: 's' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when availableModels is empty after filtering', () => {
|
||||
const result = extractSessionModelState({
|
||||
models: {
|
||||
currentModelId: 'none',
|
||||
availableModels: [{ name: '', modelId: '' }, { name: '' }],
|
||||
},
|
||||
});
|
||||
|
||||
// When all models are invalid, availableModels will be empty
|
||||
// The function should still return a state with empty availableModels
|
||||
expect(result?.availableModels).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractModelInfoFromNewSessionResult', () => {
|
||||
it('extracts from NewSessionResponse.models (SessionModelState)', () => {
|
||||
|
||||
@@ -69,6 +69,69 @@ const normalizeModelInfo = (value: unknown): ModelInfo | null => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* SessionModelState as returned from ACP session/new.
|
||||
*/
|
||||
export interface SessionModelState {
|
||||
availableModels: ModelInfo[];
|
||||
currentModelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract complete model state from ACP `session/new` result.
|
||||
*
|
||||
* Returns both the list of available models and the current model ID.
|
||||
*/
|
||||
export const extractSessionModelState = (
|
||||
result: unknown,
|
||||
): SessionModelState | null => {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const obj = result as Record<string, unknown>;
|
||||
const models = obj['models'];
|
||||
|
||||
// ACP draft: NewSessionResponse.models is a SessionModelState object.
|
||||
if (models && typeof models === 'object' && !Array.isArray(models)) {
|
||||
const state = models as Record<string, unknown>;
|
||||
const availableModels = state['availableModels'];
|
||||
const currentModelId = state['currentModelId'];
|
||||
|
||||
if (Array.isArray(availableModels)) {
|
||||
const normalizedModels = availableModels
|
||||
.map(normalizeModelInfo)
|
||||
.filter((m): m is ModelInfo => Boolean(m));
|
||||
|
||||
const modelId =
|
||||
typeof currentModelId === 'string' && currentModelId.length > 0
|
||||
? currentModelId
|
||||
: normalizedModels[0]?.modelId || '';
|
||||
|
||||
return {
|
||||
availableModels: normalizedModels,
|
||||
currentModelId: modelId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: some implementations returned `models` as a raw array.
|
||||
if (Array.isArray(models)) {
|
||||
const normalizedModels = models
|
||||
.map(normalizeModelInfo)
|
||||
.filter((m): m is ModelInfo => Boolean(m));
|
||||
|
||||
if (normalizedModels.length > 0) {
|
||||
return {
|
||||
availableModels: normalizedModels,
|
||||
currentModelId: normalizedModels[0].modelId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract model info from ACP `session/new` result.
|
||||
*
|
||||
|
||||
@@ -42,7 +42,8 @@ import {
|
||||
} from './components/messages/index.js';
|
||||
import { InputForm } from './components/layout/InputForm.js';
|
||||
import { SessionSelector } from './components/layout/SessionSelector.js';
|
||||
import { FileIcon, UserIcon } from './components/icons/index.js';
|
||||
import { FileIcon } from './components/icons/index.js';
|
||||
import type { AvailableCommand } from '../types/acpTypes.js';
|
||||
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js';
|
||||
@@ -77,6 +78,11 @@ export const App: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true); // Track if we're still initializing/loading
|
||||
const [modelInfo, setModelInfo] = useState<ModelInfo | null>(null);
|
||||
const [usageStats, setUsageStats] = useState<UsageStatsPayload | null>(null);
|
||||
const [availableCommands, setAvailableCommands] = useState<
|
||||
AvailableCommand[]
|
||||
>([]);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(
|
||||
null,
|
||||
) as React.RefObject<HTMLDivElement>;
|
||||
@@ -146,23 +152,58 @@ export const App: React.FC = () => {
|
||||
|
||||
return allItems;
|
||||
} else {
|
||||
// Handle slash commands
|
||||
const commands: CompletionItem[] = [
|
||||
// Handle slash commands with grouping
|
||||
// Model group - special items without / prefix
|
||||
const modelGroupItems: CompletionItem[] = [
|
||||
{
|
||||
id: 'login',
|
||||
label: '/login',
|
||||
description: 'Login to Qwen Code',
|
||||
id: 'model',
|
||||
label: 'Switch model...',
|
||||
description: modelInfo?.name || 'Default',
|
||||
type: 'command',
|
||||
icon: <UserIcon />,
|
||||
group: 'Model',
|
||||
},
|
||||
];
|
||||
|
||||
return commands.filter((cmd) =>
|
||||
cmd.label.toLowerCase().includes(query.toLowerCase()),
|
||||
// Account group
|
||||
const accountGroupItems: CompletionItem[] = [
|
||||
{
|
||||
id: 'login',
|
||||
label: 'Login',
|
||||
description: 'Login to Qwen Code',
|
||||
type: 'command',
|
||||
group: 'Account',
|
||||
},
|
||||
];
|
||||
|
||||
// Slash Commands group - commands from server (available_commands_update)
|
||||
const slashCommandItems: CompletionItem[] = availableCommands.map(
|
||||
(cmd) => ({
|
||||
id: cmd.name,
|
||||
label: `/${cmd.name}`,
|
||||
description: cmd.description,
|
||||
type: 'command' as const,
|
||||
group: 'Slash Commands',
|
||||
}),
|
||||
);
|
||||
|
||||
// Combine all commands
|
||||
const allCommands = [
|
||||
...modelGroupItems,
|
||||
...accountGroupItems,
|
||||
...slashCommandItems,
|
||||
];
|
||||
|
||||
// Filter by query
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allCommands.filter(
|
||||
(cmd) =>
|
||||
cmd.label.toLowerCase().includes(lowerQuery) ||
|
||||
(cmd.description &&
|
||||
cmd.description.toLowerCase().includes(lowerQuery)),
|
||||
);
|
||||
}
|
||||
},
|
||||
[fileContext],
|
||||
[fileContext, availableCommands, modelInfo?.name],
|
||||
);
|
||||
|
||||
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
|
||||
@@ -301,6 +342,12 @@ export const App: React.FC = () => {
|
||||
setModelInfo: (info) => {
|
||||
setModelInfo(info);
|
||||
},
|
||||
setAvailableCommands: (commands) => {
|
||||
setAvailableCommands(commands);
|
||||
},
|
||||
setAvailableModels: (models) => {
|
||||
setAvailableModels(models);
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
|
||||
@@ -451,14 +498,94 @@ export const App: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Slash commands can execute immediately
|
||||
// Commands can execute immediately
|
||||
if (item.type === 'command') {
|
||||
const command = (item.label || '').trim();
|
||||
if (command === '/login') {
|
||||
const itemId = item.id;
|
||||
|
||||
// Helper to clear trigger text from input
|
||||
const clearTriggerText = () => {
|
||||
const text = inputElement.textContent || '';
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
// Fallback: just clear everything
|
||||
inputElement.textContent = '';
|
||||
setInputText('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find and remove the slash command trigger
|
||||
const range = selection.getRangeAt(0);
|
||||
let cursorPos = text.length;
|
||||
if (range.startContainer === inputElement) {
|
||||
const childIndex = range.startOffset;
|
||||
let offset = 0;
|
||||
for (
|
||||
let i = 0;
|
||||
i < childIndex && i < inputElement.childNodes.length;
|
||||
i++
|
||||
) {
|
||||
offset += inputElement.childNodes[i].textContent?.length || 0;
|
||||
}
|
||||
cursorPos = offset || text.length;
|
||||
} else if (range.startContainer.nodeType === Node.TEXT_NODE) {
|
||||
const walker = document.createTreeWalker(
|
||||
inputElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
);
|
||||
let offset = 0;
|
||||
let found = false;
|
||||
let node: Node | null = walker.nextNode();
|
||||
while (node) {
|
||||
if (node === range.startContainer) {
|
||||
offset += range.startOffset;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
offset += node.textContent?.length || 0;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
cursorPos = found ? offset : text.length;
|
||||
}
|
||||
|
||||
const textBeforeCursor = text.substring(0, cursorPos);
|
||||
const slashPos = textBeforeCursor.lastIndexOf('/');
|
||||
if (slashPos >= 0) {
|
||||
const newText =
|
||||
text.substring(0, slashPos) + text.substring(cursorPos);
|
||||
inputElement.textContent = newText;
|
||||
setInputText(newText);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle special commands by id
|
||||
if (itemId === 'login') {
|
||||
clearTriggerText();
|
||||
vscode.postMessage({ type: 'login', data: {} });
|
||||
completion.closeCompletion();
|
||||
return;
|
||||
}
|
||||
if (itemId === 'model') {
|
||||
clearTriggerText();
|
||||
setShowModelSelector(true);
|
||||
completion.closeCompletion();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle server-provided slash commands by sending them as messages
|
||||
// CLI will detect slash commands in session/prompt and execute them
|
||||
const serverCmd = availableCommands.find((c) => c.name === itemId);
|
||||
if (serverCmd) {
|
||||
// Clear the trigger text since we're sending the command
|
||||
clearTriggerText();
|
||||
// Send the slash command as a user message
|
||||
vscode.postMessage({
|
||||
type: 'sendMessage',
|
||||
data: { text: `/${serverCmd.name}` },
|
||||
});
|
||||
completion.closeCompletion();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If selecting a file, add @filename -> fullpath mapping
|
||||
@@ -543,7 +670,25 @@ export const App: React.FC = () => {
|
||||
// Close the completion menu
|
||||
completion.closeCompletion();
|
||||
},
|
||||
[completion, inputFieldRef, setInputText, fileContext, vscode],
|
||||
[
|
||||
completion,
|
||||
inputFieldRef,
|
||||
setInputText,
|
||||
fileContext,
|
||||
vscode,
|
||||
availableCommands,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle model selection
|
||||
const handleModelSelect = useCallback(
|
||||
(modelId: string) => {
|
||||
vscode.postMessage({
|
||||
type: 'setModel',
|
||||
data: { modelId },
|
||||
});
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
|
||||
// Handle attach context click
|
||||
@@ -866,6 +1011,11 @@ export const App: React.FC = () => {
|
||||
completionItems={completion.items}
|
||||
onCompletionSelect={handleCompletionSelect}
|
||||
onCompletionClose={completion.closeCompletion}
|
||||
showModelSelector={showModelSelector}
|
||||
availableModels={availableModels}
|
||||
currentModelId={modelInfo?.modelId}
|
||||
onSelectModel={handleModelSelect}
|
||||
onCloseModelSelector={() => setShowModelSelector(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -132,6 +132,34 @@ export class WebViewProvider {
|
||||
});
|
||||
});
|
||||
|
||||
// Surface model changes (from ACP current_model_update or set_model response)
|
||||
this.agentManager.onModelChanged((model) => {
|
||||
this.sendMessageToWebView({
|
||||
type: 'modelChanged',
|
||||
data: { model },
|
||||
});
|
||||
});
|
||||
|
||||
// Surface available commands (from ACP available_commands_update)
|
||||
this.agentManager.onAvailableCommands((commands) => {
|
||||
this.sendMessageToWebView({
|
||||
type: 'availableCommands',
|
||||
data: { commands },
|
||||
});
|
||||
});
|
||||
|
||||
// Surface available models (from session/new response)
|
||||
this.agentManager.onAvailableModels((models) => {
|
||||
console.log(
|
||||
'[WebViewProvider] onAvailableModels received, sending to webview:',
|
||||
models,
|
||||
);
|
||||
this.sendMessageToWebView({
|
||||
type: 'availableModels',
|
||||
data: { models },
|
||||
});
|
||||
});
|
||||
|
||||
// Setup end-turn handler from ACP stopReason notifications
|
||||
this.agentManager.onEndTurn((reason) => {
|
||||
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere
|
||||
|
||||
@@ -186,3 +186,54 @@ export const SelectionIcon: React.FC<IconProps> = ({
|
||||
<path d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Check icon (16x16)
|
||||
* Used for selected items
|
||||
*/
|
||||
export const CheckIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Model icon (16x16)
|
||||
* Used for model selection command
|
||||
*/
|
||||
export const ModelIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8 1a.75.75 0 0 1 .75.75V6h4.5a.75.75 0 0 1 0 1.5h-4.5v4.25a.75.75 0 0 1-1.5 0V7.5h-4.5a.75.75 0 0 1 0-1.5h4.5V1.75A.75.75 0 0 1 8 1Z" />
|
||||
<path d="M2 14.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,8 @@ export {
|
||||
UserIcon,
|
||||
SymbolIcon,
|
||||
SelectionIcon,
|
||||
CheckIcon,
|
||||
ModelIcon,
|
||||
} from './StatusIcons.js';
|
||||
|
||||
// Special icons
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import type { CompletionItem } from '../../../types/completionItemTypes.js';
|
||||
|
||||
interface CompletionMenuProps {
|
||||
@@ -16,6 +16,28 @@ interface CompletionMenuProps {
|
||||
selectedIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items by their group property
|
||||
*/
|
||||
const groupItems = (
|
||||
items: CompletionItem[],
|
||||
): Array<{ group: string | null; items: CompletionItem[] }> => {
|
||||
const groups: Map<string | null, CompletionItem[]> = new Map();
|
||||
|
||||
for (const item of items) {
|
||||
const groupKey = item.group || null;
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, []);
|
||||
}
|
||||
groups.get(groupKey)!.push(item);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([group, groupItems]) => ({
|
||||
group,
|
||||
items: groupItems,
|
||||
}));
|
||||
};
|
||||
|
||||
export const CompletionMenu: React.FC<CompletionMenuProps> = ({
|
||||
items,
|
||||
onSelect,
|
||||
@@ -24,9 +46,16 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
|
||||
selectedIndex = 0,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [selected, setSelected] = useState(selectedIndex);
|
||||
// Mount state to drive a simple Tailwind transition (replaces CSS keyframes)
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Track if selection change was from keyboard (should scroll) vs mouse (should not scroll)
|
||||
const isKeyboardNavigation = useRef(false);
|
||||
|
||||
// Group items for display
|
||||
const groupedItems = useMemo(() => groupItems(items), [items]);
|
||||
const hasGroups = groupedItems.some((g) => g.group !== null);
|
||||
|
||||
useEffect(() => setSelected(selectedIndex), [selectedIndex]);
|
||||
useEffect(() => setMounted(true), []);
|
||||
@@ -45,10 +74,12 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
isKeyboardNavigation.current = true;
|
||||
setSelected((prev) => Math.min(prev + 1, items.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
isKeyboardNavigation.current = true;
|
||||
setSelected((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
@@ -75,11 +106,28 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
|
||||
}, [items, selected, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedEl = containerRef.current?.querySelector(
|
||||
// Only scroll into view for keyboard navigation, not mouse hover
|
||||
if (!isKeyboardNavigation.current) {
|
||||
return;
|
||||
}
|
||||
isKeyboardNavigation.current = false;
|
||||
|
||||
const selectedEl = listRef.current?.querySelector(
|
||||
`[data-index="${selected}"]`,
|
||||
);
|
||||
if (selectedEl) {
|
||||
selectedEl.scrollIntoView({ block: 'nearest' });
|
||||
if (selectedEl && listRef.current) {
|
||||
// Use scrollIntoView only within the list container to avoid page scroll
|
||||
const listRect = listRef.current.getBoundingClientRect();
|
||||
const elRect = selectedEl.getBoundingClientRect();
|
||||
|
||||
// Check if element is outside the visible area of the list
|
||||
if (elRect.top < listRect.top) {
|
||||
// Element is above visible area, scroll up
|
||||
selectedEl.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||
} else if (elRect.bottom > listRect.bottom) {
|
||||
// Element is below visible area, scroll down
|
||||
selectedEl.scrollIntoView({ block: 'end', behavior: 'instant' });
|
||||
}
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
@@ -87,6 +135,9 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Track global index for keyboard navigation
|
||||
let globalIndex = 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -104,67 +155,83 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
|
||||
{/* Optional top spacer for visual separation from the input */}
|
||||
<div className="h-1" />
|
||||
<div
|
||||
ref={listRef}
|
||||
className={[
|
||||
// Semantic
|
||||
'completion-menu-list',
|
||||
// Scroll area
|
||||
'flex max-h-[300px] flex-col overflow-y-auto',
|
||||
// Spacing driven by theme vars
|
||||
'p-[var(--app-list-padding)] pb-2 gap-[var(--app-list-gap)]',
|
||||
'p-[var(--app-list-padding)] pb-2',
|
||||
].join(' ')}
|
||||
>
|
||||
{title && (
|
||||
{title && !hasGroups && (
|
||||
<div className="completion-menu-section-label px-3 py-1 text-[var(--app-primary-foreground)] opacity-50 text-[0.9em]">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, index) => {
|
||||
const isActive = index === selected;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
data-index={index}
|
||||
role="menuitem"
|
||||
onClick={() => onSelect(item)}
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
className={[
|
||||
// Semantic
|
||||
'completion-menu-item',
|
||||
// Hit area
|
||||
'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]',
|
||||
'p-[var(--app-list-item-padding)]',
|
||||
// Active background
|
||||
isActive ? 'bg-[var(--app-list-active-background)]' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="completion-menu-item-row flex items-center justify-between gap-2">
|
||||
{item.icon && (
|
||||
<span className="completion-menu-item-icon inline-flex h-4 w-4 items-center justify-center text-[var(--vscode-symbolIcon-fileForeground,#cccccc)]">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={[
|
||||
'completion-menu-item-label flex-1 truncate',
|
||||
isActive
|
||||
? 'text-[var(--app-list-active-foreground)]'
|
||||
: 'text-[var(--app-primary-foreground)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.description && (
|
||||
<span
|
||||
className="completion-menu-item-desc max-w-[50%] truncate text-[0.9em] text-[var(--app-secondary-foreground)] opacity-70"
|
||||
title={item.description}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
{groupedItems.map((group, groupIdx) => (
|
||||
<div
|
||||
key={group.group || `ungrouped-${groupIdx}`}
|
||||
className="completion-menu-group"
|
||||
>
|
||||
{hasGroups && group.group && (
|
||||
<div className="completion-menu-section-label px-3 py-1.5 text-[var(--app-secondary-foreground)] text-[0.8em] uppercase tracking-wider">
|
||||
{group.group}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-[var(--app-list-gap)]">
|
||||
{group.items.map((item) => {
|
||||
const currentIndex = globalIndex++;
|
||||
const isActive = currentIndex === selected;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
data-index={currentIndex}
|
||||
role="menuitem"
|
||||
onClick={() => onSelect(item)}
|
||||
onMouseEnter={() => setSelected(currentIndex)}
|
||||
className={[
|
||||
// Semantic
|
||||
'completion-menu-item',
|
||||
// Hit area
|
||||
'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]',
|
||||
'p-[var(--app-list-item-padding)]',
|
||||
// Active background
|
||||
isActive ? 'bg-[var(--app-list-active-background)]' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="completion-menu-item-row flex items-center justify-between gap-2">
|
||||
{item.icon && (
|
||||
<span className="completion-menu-item-icon inline-flex h-4 w-4 items-center justify-center text-[var(--vscode-symbolIcon-fileForeground,#cccccc)]">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={[
|
||||
'completion-menu-item-label flex-1 truncate',
|
||||
isActive
|
||||
? 'text-[var(--app-list-active-foreground)]'
|
||||
: 'text-[var(--app-primary-foreground)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.description && (
|
||||
<span
|
||||
className="completion-menu-item-desc max-w-[50%] truncate text-[0.9em] text-[var(--app-secondary-foreground)] opacity-70"
|
||||
title={item.description}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
StopIcon,
|
||||
} from '../icons/index.js';
|
||||
import { CompletionMenu } from '../layout/CompletionMenu.js';
|
||||
import { ModelSelector } from '../layout/ModelSelector.js';
|
||||
import type { CompletionItem } from '../../../types/completionItemTypes.js';
|
||||
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
|
||||
import type { ModelInfo } from '../../../types/acpTypes.js';
|
||||
import { ContextIndicator } from './ContextIndicator.js';
|
||||
|
||||
interface InputFormProps {
|
||||
@@ -58,6 +60,12 @@ interface InputFormProps {
|
||||
completionItems?: CompletionItem[];
|
||||
onCompletionSelect?: (item: CompletionItem) => void;
|
||||
onCompletionClose?: () => void;
|
||||
// Model selector props
|
||||
showModelSelector?: boolean;
|
||||
availableModels?: ModelInfo[];
|
||||
currentModelId?: string | null;
|
||||
onSelectModel?: (modelId: string) => void;
|
||||
onCloseModelSelector?: () => void;
|
||||
}
|
||||
|
||||
// Get edit mode display info using helper function
|
||||
@@ -118,6 +126,11 @@ export const InputForm: React.FC<InputFormProps> = ({
|
||||
completionItems,
|
||||
onCompletionSelect,
|
||||
onCompletionClose,
|
||||
showModelSelector,
|
||||
availableModels,
|
||||
currentModelId,
|
||||
onSelectModel,
|
||||
onCloseModelSelector,
|
||||
}) => {
|
||||
const editModeInfo = getEditModeInfo(editMode);
|
||||
const composerDisabled = isStreaming || isWaitingForResponse;
|
||||
@@ -174,6 +187,19 @@ export const InputForm: React.FC<InputFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showModelSelector &&
|
||||
availableModels &&
|
||||
onSelectModel &&
|
||||
onCloseModelSelector && (
|
||||
<ModelSelector
|
||||
visible={showModelSelector}
|
||||
models={availableModels}
|
||||
currentModelId={currentModelId ?? null}
|
||||
onSelectModel={onSelectModel}
|
||||
onClose={onCloseModelSelector}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={inputFieldRef}
|
||||
contentEditable="plaintext-only"
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import type { ModelInfo } from '../../../types/acpTypes.js';
|
||||
import { PlanCompletedIcon } from '../icons/index.js';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
visible: boolean;
|
||||
models: ModelInfo[];
|
||||
currentModelId: string | null;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ModelSelector: FC<ModelSelectorProps> = ({
|
||||
visible,
|
||||
models,
|
||||
currentModelId,
|
||||
onSelectModel,
|
||||
onClose,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Reset selection when models change or when opened
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Find current model index or default to 0
|
||||
const currentIndex = models.findIndex(
|
||||
(m) => m.modelId === currentModelId,
|
||||
);
|
||||
setSelected(currentIndex >= 0 ? currentIndex : 0);
|
||||
setMounted(true);
|
||||
} else {
|
||||
setMounted(false);
|
||||
}
|
||||
}, [visible, models, currentModelId]);
|
||||
|
||||
// Handle clicking outside to close and keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setSelected((prev) => Math.min(prev + 1, models.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setSelected((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (models[selected]) {
|
||||
onSelectModel(models[selected].modelId);
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [visible, models, selected, onSelectModel, onClose]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedEl = containerRef.current?.querySelector(
|
||||
`[data-index="${selected}"]`,
|
||||
);
|
||||
if (selectedEl) {
|
||||
selectedEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(modelId: string) => {
|
||||
onSelectModel(modelId);
|
||||
onClose();
|
||||
},
|
||||
[onSelectModel, onClose],
|
||||
);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="menu"
|
||||
className={[
|
||||
'model-selector',
|
||||
// Positioning - bottom drawer style like CompletionMenu
|
||||
'absolute bottom-full left-0 right-0 mb-2 flex flex-col overflow-hidden',
|
||||
'rounded-large border bg-[var(--app-menu-background)]',
|
||||
'border-[var(--app-input-border)] max-h-[50vh] z-[1000]',
|
||||
// Mount animation
|
||||
mounted ? 'animate-completion-menu-enter' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-1.5 text-[var(--app-secondary-foreground)] text-[0.8em] uppercase tracking-wider">
|
||||
Select a model
|
||||
</div>
|
||||
|
||||
{/* Model list */}
|
||||
<div className="flex max-h-[300px] flex-col overflow-y-auto p-[var(--app-list-padding)] pb-2">
|
||||
{models.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-[var(--app-secondary-foreground)] text-sm">
|
||||
No models available. Check console for details.
|
||||
</div>
|
||||
) : (
|
||||
models.map((model, index) => {
|
||||
const isActive = index === selected;
|
||||
const isCurrentModel = model.modelId === currentModelId;
|
||||
return (
|
||||
<div
|
||||
key={model.modelId}
|
||||
data-index={index}
|
||||
role="menuitem"
|
||||
onClick={() => handleModelSelect(model.modelId)}
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
className={[
|
||||
'model-selector-item',
|
||||
'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]',
|
||||
'p-[var(--app-list-item-padding)]',
|
||||
isActive ? 'bg-[var(--app-list-active-background)]' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={[
|
||||
'block truncate',
|
||||
isActive
|
||||
? 'text-[var(--app-list-active-foreground)]'
|
||||
: 'text-[var(--app-primary-foreground)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{model.name}
|
||||
</span>
|
||||
{model.description && (
|
||||
<span className="block truncate text-[0.85em] text-[var(--app-secondary-foreground)] opacity-70">
|
||||
{model.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isCurrentModel && (
|
||||
<span className="flex-shrink-0 text-[var(--app-list-active-foreground)]">
|
||||
<PlanCompletedIcon size={16} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +34,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
'openNewChatTab',
|
||||
// Settings-related messages
|
||||
'setApprovalMode',
|
||||
'setModel',
|
||||
].includes(messageType);
|
||||
}
|
||||
|
||||
@@ -125,6 +126,14 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
);
|
||||
break;
|
||||
|
||||
case 'setModel':
|
||||
await this.handleSetModel(
|
||||
message.data as {
|
||||
modelId?: string;
|
||||
},
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
'[SessionMessageHandler] Unknown message type:',
|
||||
@@ -1034,4 +1043,24 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model via agent (ACP session/set_model)
|
||||
*/
|
||||
private async handleSetModel(data?: { modelId?: string }): Promise<void> {
|
||||
try {
|
||||
const modelId = data?.modelId;
|
||||
if (!modelId) {
|
||||
throw new Error('Model ID is required');
|
||||
}
|
||||
await this.agentManager.setModelFromUi(modelId);
|
||||
// No explicit response needed; WebView listens for modelChanged
|
||||
} catch (error) {
|
||||
console.error('[SessionMessageHandler] Failed to set model:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to set model: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
} from '../../types/chatTypes.js';
|
||||
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
||||
import type { PlanEntry } from '../../types/chatTypes.js';
|
||||
import type { ModelInfo } from '../../types/acpTypes.js';
|
||||
import type { ModelInfo, AvailableCommand } from '../../types/acpTypes.js';
|
||||
|
||||
const FORCE_CLEAR_STREAM_END_REASONS = new Set([
|
||||
'user_cancelled',
|
||||
@@ -127,6 +127,10 @@ interface UseWebViewMessagesProps {
|
||||
setUsageStats?: (stats: UsageStatsPayload | undefined) => void;
|
||||
// Model info setter
|
||||
setModelInfo?: (info: ModelInfo | null) => void;
|
||||
// Available commands setter
|
||||
setAvailableCommands?: (commands: AvailableCommand[]) => void;
|
||||
// Available models setter
|
||||
setAvailableModels?: (models: ModelInfo[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +151,8 @@ export const useWebViewMessages = ({
|
||||
setIsAuthenticated,
|
||||
setUsageStats,
|
||||
setModelInfo,
|
||||
setAvailableCommands,
|
||||
setAvailableModels,
|
||||
}: UseWebViewMessagesProps) => {
|
||||
// VS Code API for posting messages back to the extension host
|
||||
const vscode = useVSCode();
|
||||
@@ -166,6 +172,8 @@ export const useWebViewMessages = ({
|
||||
setIsAuthenticated,
|
||||
setUsageStats,
|
||||
setModelInfo,
|
||||
setAvailableCommands,
|
||||
setAvailableModels,
|
||||
});
|
||||
|
||||
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe
|
||||
@@ -213,6 +221,8 @@ export const useWebViewMessages = ({
|
||||
setIsAuthenticated,
|
||||
setUsageStats,
|
||||
setModelInfo,
|
||||
setAvailableCommands,
|
||||
setAvailableModels,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -245,6 +255,56 @@ export const useWebViewMessages = ({
|
||||
break;
|
||||
}
|
||||
|
||||
case 'modelChanged': {
|
||||
try {
|
||||
const model = message.data?.model as ModelInfo | undefined;
|
||||
if (model) {
|
||||
handlers.setModelInfo?.(model);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore error when setting model
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'availableCommands': {
|
||||
try {
|
||||
const commands = message.data?.commands as
|
||||
| AvailableCommand[]
|
||||
| undefined;
|
||||
if (commands) {
|
||||
handlers.setAvailableCommands?.(commands);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore error when setting available commands
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'availableModels': {
|
||||
try {
|
||||
const models = message.data?.models as ModelInfo[] | undefined;
|
||||
console.log(
|
||||
'[useWebViewMessages] availableModels message received:',
|
||||
models,
|
||||
);
|
||||
if (models) {
|
||||
handlers.setAvailableModels?.(models);
|
||||
console.log(
|
||||
'[useWebViewMessages] setAvailableModels called with:',
|
||||
models,
|
||||
);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore error when setting available models
|
||||
console.error(
|
||||
'[useWebViewMessages] Error setting available models:',
|
||||
_error,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'usageStats': {
|
||||
const stats = message.data as UsageStatsPayload | undefined;
|
||||
handlers.setUsageStats?.(stats);
|
||||
|
||||
Reference in New Issue
Block a user