diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3608d961b14..dab4d198496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,9 @@ jobs: - name: 'Run sensitive keyword linter' run: 'node scripts/lint.js --sensitive-keywords' + - name: 'Run i18n check' + run: 'npm run check-i18n' + - name: 'Build CLI package' run: 'npm run build --workspace=packages/cli' diff --git a/.qwen/agents/test-engineer.md b/.qwen/agents/test-engineer.md index 61be283d5c3..395e281af03 100644 --- a/.qwen/agents/test-engineer.md +++ b/.qwen/agents/test-engineer.md @@ -18,7 +18,6 @@ tools: - run_shell_command - skill - web_fetch - - web_search --- # Test Engineer — Bug Reproduction & Verification diff --git a/docs/design/custom-api-key-auth-wizard-prd.md b/docs/design/custom-api-key-auth-wizard-prd.md new file mode 100644 index 00000000000..fa0c3ea82b4 --- /dev/null +++ b/docs/design/custom-api-key-auth-wizard-prd.md @@ -0,0 +1,864 @@ +# Custom API Key Auth Wizard PRD + +## Summary + +Improve the `/auth -> API Key -> Custom API Key` experience by replacing the current documentation-only screen with an in-terminal setup wizard for custom API providers. + +Qwen Code supports multiple API protocols through `authType` / `modelProviders` keys, including `openai`, `anthropic`, and `gemini`. Therefore, the custom setup wizard should start by asking users to select the protocol, then collect endpoint, key, and model information for that protocol. + +The wizard guides users through: + +```text +Select Protocol -> Enter Base URL -> Enter API Key -> Enter Model IDs -> Review JSON -> Save + authenticate +``` + +This keeps the custom API key setup inside Qwen Code, reduces the need to manually edit `settings.json`, and makes the final configuration transparent by showing the generated JSON before saving. + +## Background + +Today, selecting `Custom API Key` in `/auth` shows a static information screen: + +```text +Custom Configuration + +You can configure your API key and models in settings.json + +Refer to the documentation for setup instructions +https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/ + +Esc to go back +``` + +This requires users to leave the CLI, read documentation, understand `settings.json`, manually configure `modelProviders`, choose an `envKey`, add API keys, and then return to Qwen Code. Users have reported that this flow is difficult and disconnected from the rest of the `/auth` experience. + +The current ModelStudio Standard API key path already provides a guided setup flow: + +```text +Alibaba Cloud ModelStudio Standard API Key +└─ Select Region + └─ Enter API Key + └─ Enter Model IDs + └─ Save + authenticate +``` + +Custom API key setup should offer a similar guided experience, while also respecting that Qwen Code supports multiple provider protocols. + +## Problem Statement + +The custom API key path is currently a dead end inside `/auth`: + +```text +/auth +└─ Select Authentication Method + ├─ Alibaba Cloud Coding Plan + ├─ API Key + │ └─ Select API Key Type + │ ├─ Alibaba Cloud ModelStudio Standard API Key + │ │ ├─ Select Region + │ │ ├─ Enter API Key + │ │ ├─ Enter Model IDs + │ │ └─ Save + authenticate + │ │ + │ └─ Custom API Key + │ └─ Documentation-only screen + │ + └─ Qwen OAuth +``` + +This causes several usability issues: + +- Users cannot finish custom provider setup from `/auth`. +- Users need to understand low-level settings concepts before they can authenticate. +- Users may not know which fields are required: `authType`, `baseUrl`, `envKey`, `modelProviders`, `model.name`, and `security.auth.selectedType`. +- Users may accidentally conflict with existing environment variables or overwrite existing provider configuration. +- Users do not get immediate authentication feedback after editing settings manually. + +## Goals + +1. Let users configure a custom API provider completely inside `/auth`. +2. Support the main protocols Qwen Code supports in `modelProviders`: `openai`, `anthropic`, and `gemini`. +3. Keep the flow close to the existing ModelStudio Standard flow. +4. Treat `baseUrl` as the custom-provider equivalent of `region`. +5. Automatically generate a Qwen-managed private `envKey` from the selected protocol and input `baseUrl`. +6. Store the API key under `settings.json.env`, consistent with the current Qwen-managed credential pattern. +7. Avoid conflicts with user shell environment variables by using a Qwen-specific generated key name. +8. Show the generated JSON before saving so users can review the exact settings changes. +9. Preserve unrelated existing `modelProviders` entries. +10. Authenticate immediately after saving and show success or failure feedback. + +## Non-goals + +1. Do not require users to manually enter `envKey`. +2. Do not introduce provider name as a separate concept. +3. Do not add advanced `generationConfig`, `capabilities`, or per-model overrides to the wizard. +4. Do not remove the documentation link entirely; it should remain available for advanced configuration. +5. Do not change the existing Coding Plan or ModelStudio Standard API key flows. +6. Do not attempt to auto-detect protocol from `baseUrl` in the first version; users select the protocol explicitly. + +## Target Users + +- Users who bring their own custom API endpoint. +- Users configuring providers such as OpenAI-compatible APIs, Anthropic-compatible APIs, Gemini-compatible APIs, vLLM, Ollama, LM Studio, or internal gateways. +- Users who prefer setting up authentication from the CLI rather than manually editing `settings.json`. + +## Supported Protocols + +The wizard should initially expose these protocol options: + +```text +openai +anthropic +gemini +``` + +Each protocol maps directly to a `modelProviders` key and `security.auth.selectedType` value. + +| Protocol option | Auth type / modelProviders key | Notes | +| -------------------- | ------------------------------ | --------------------------------------------------------------------------------- | +| OpenAI-compatible | `openai` | OpenAI, OpenRouter, Fireworks, local OpenAI-compatible servers, internal gateways | +| Anthropic-compatible | `anthropic` | Anthropic-compatible endpoints | +| Gemini-compatible | `gemini` | Gemini-compatible endpoints | + +## User Experience Overview + +### Updated `/auth` tree + +```text +/auth +└─ Select Authentication Method + ├─ Alibaba Cloud Coding Plan + │ └─ Select Region + │ └─ Enter API Key + │ └─ Save + authenticate + │ + ├─ API Key + │ └─ Select API Key Type + │ ├─ Alibaba Cloud ModelStudio Standard API Key + │ │ ├─ Select Region + │ │ ├─ Enter API Key + │ │ ├─ Enter Model IDs + │ │ └─ Save + authenticate + │ │ + │ └─ Custom API Key + │ ├─ Select Protocol + │ ├─ Enter Base URL + │ ├─ Enter API Key + │ ├─ Enter Model IDs + │ ├─ Review generated JSON + │ └─ Save + authenticate + │ + └─ Qwen OAuth +``` + +### Custom API Key state machine + +```text +api-key-type-select + │ + └─ CUSTOM_API_KEY + │ + ▼ +custom-protocol-select + │ Enter + ▼ +custom-base-url-input + │ Enter + │ generate envKey from protocol + baseUrl + ▼ +custom-api-key-input + │ Enter + ▼ +custom-model-id-input + │ Enter + ▼ +custom-review-json + │ Enter + ▼ +save settings + refreshAuth(selectedProtocol) +``` + +### Escape behavior + +```text +custom-review-json + Esc -> custom-model-id-input + +custom-model-id-input + Esc -> custom-api-key-input + +custom-api-key-input + Esc -> custom-base-url-input + +custom-base-url-input + Esc -> custom-protocol-select + +custom-protocol-select + Esc -> api-key-type-select +``` + +## Detailed Interaction Design + +### Step 1: Select Protocol + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Custom API Key · Select Protocol │ +│ │ +│ ◉ OpenAI-compatible │ +│ OpenAI, OpenRouter, Fireworks, vLLM, Ollama, LM Studio │ +│ │ +│ ○ Anthropic-compatible │ +│ Anthropic-compatible endpoints │ +│ │ +│ ○ Gemini-compatible │ +│ Gemini-compatible endpoints │ +│ │ +│ Enter to select, ↑↓ to navigate, Esc to go back │ +└──────────────────────────────────────────────────────────────┘ +``` + +The selected protocol determines: + +- The `modelProviders` key to update. +- The `security.auth.selectedType` value to persist. +- The protocol label shown on later screens. +- The `refreshAuth()` auth type used after saving. + +### Step 2: Enter Base URL + +`baseUrl` is the custom-provider equivalent of region selection. It should come before API key entry because it determines which endpoint the API key belongs to. + +For OpenAI-compatible: + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Custom API Key · Base URL │ +│ │ +│ Protocol: OpenAI-compatible │ +│ │ +│ Enter the OpenAI-compatible API endpoint. │ +│ │ +│ Base URL: https://openrouter.ai/api/v1_ │ +│ │ +│ Examples: │ +│ OpenAI: https://api.openai.com/v1 │ +│ OpenRouter: https://openrouter.ai/api/v1 │ +│ Fireworks: https://api.fireworks.ai/inference/v1 │ +│ Ollama: http://localhost:11434/v1 │ +│ LM Studio: http://localhost:1234/v1 │ +│ │ +│ Enter to continue, Esc to go back │ +└──────────────────────────────────────────────────────────────┘ +``` + +For Anthropic-compatible: + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Custom API Key · Base URL │ +│ │ +│ Protocol: Anthropic-compatible │ +│ │ +│ Enter the Anthropic-compatible API endpoint. │ +│ │ +│ Base URL: https://api.anthropic.com/v1_ │ +│ │ +│ Enter to continue, Esc to go back │ +└──────────────────────────────────────────────────────────────┘ +``` + +For Gemini-compatible: + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Custom API Key · Base URL │ +│ │ +│ Protocol: Gemini-compatible │ +│ │ +│ Enter the Gemini-compatible API endpoint. │ +│ │ +│ Base URL: https://generativelanguage.googleapis.com_ │ +│ │ +│ Enter to continue, Esc to go back │ +└──────────────────────────────────────────────────────────────┘ +``` + +Validation: + +- Required. +- Must start with `http://` or `https://`. +- Trim leading and trailing whitespace. +- Preserve the normalized string as entered, except trimming. + +On valid submit: + +- Generate the Qwen-managed `envKey` from selected protocol and `baseUrl`. +- Move to API key input. + +### Step 3: Enter API Key + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Custom API Key · API Key │ +│ │ +│ Protocol: OpenAI-compatible │ +│ Endpoint: https://openrouter.ai/api/v1 │ +│ │ +│ Enter the API key for this endpoint. │ +│ │ +│ API key: sk-or-v1-••••••••••••••••_ │ +│ │ +│ Enter to continue, Esc to go back │ +└──────────────────────────────────────────────────────────────┘ +``` + +Validation: + +- Required. +- Trim leading and trailing whitespace. + +Notes: + +- The input may initially use the existing text input behavior for consistency with nearby flows. +- The review screen should mask the API key. + +### Step 4: Enter Model IDs + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Custom API Key · Model IDs │ +│ │ +│ Protocol: OpenAI-compatible │ +│ Endpoint: https://openrouter.ai/api/v1 │ +│ │ +│ Enter one or more model IDs, separated by commas. │ +│ │ +│ Model IDs: qwen/qwen3-coder,openai/gpt-4.1_ │ +│ │ +│ Enter to continue, Esc to go back │ +└──────────────────────────────────────────────────────────────┘ +``` + +Validation: + +- Required. +- Split by comma. +- Trim each model ID. +- Remove empty entries. +- Deduplicate entries while preserving order. +- At least one model ID must remain. + +Model naming: + +- `id` and `name` should be the same. +- No separate provider name is requested from the user. + +Example: + +```text +Input: +qwen/qwen3-coder, openai/gpt-4.1, qwen/qwen3-coder + +Normalized: +qwen/qwen3-coder, openai/gpt-4.1 +``` + +### Step 5: Review JSON + +Before saving, show the generated JSON snippet that will be written or merged into `settings.json`. + +OpenAI-compatible example: + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Custom API Key · Review │ +│ │ +│ The following JSON will be saved to settings.json: │ +│ │ +│ { │ +│ "env": { │ +│ "QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_OPENROUTER_AI_API_V1":│ +│ "sk-••••••••••••••••" │ +│ }, │ +│ "modelProviders": { │ +│ "openai": [ │ +│ { │ +│ "id": "qwen/qwen3-coder", │ +│ "name": "qwen/qwen3-coder", │ +│ "baseUrl": "https://openrouter.ai/api/v1", │ +│ "envKey": "QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_OPENROUTER_AI_API_V1"│ +│ } │ +│ ] │ +│ }, │ +│ "security": { │ +│ "auth": { │ +│ "selectedType": "openai" │ +│ } │ +│ }, │ +│ "model": { │ +│ "name": "qwen/qwen3-coder" │ +│ } │ +│ } │ +│ │ +│ Enter to save, Esc to go back │ +└──────────────────────────────────────────────────────────────┘ +``` + +Anthropic-compatible example: + +```json +{ + "env": { + "QWEN_CUSTOM_API_KEY_ANTHROPIC_HTTPS_API_ANTHROPIC_COM_V1": "sk-••••" + }, + "modelProviders": { + "anthropic": [ + { + "id": "claude-sonnet-4-5", + "name": "claude-sonnet-4-5", + "baseUrl": "https://api.anthropic.com/v1", + "envKey": "QWEN_CUSTOM_API_KEY_ANTHROPIC_HTTPS_API_ANTHROPIC_COM_V1" + } + ] + }, + "security": { + "auth": { + "selectedType": "anthropic" + } + }, + "model": { + "name": "claude-sonnet-4-5" + } +} +``` + +The displayed JSON should: + +- Use the selected protocol as the `modelProviders` key. +- Use the selected protocol as `security.auth.selectedType`. +- Use the actual generated `envKey`. +- Mask the API key. +- Use the user-entered `baseUrl`. +- Use `id === name` for each model. +- Show `model.name` set to the first normalized model ID. + +If the JSON is too wide for the current terminal, wrapping is acceptable. The goal is transparency, not copy-paste-perfect formatting. + +### Step 6: Save and Authenticate + +On Enter from the review screen: + +```text +save: + env[generatedEnvKey] = apiKey + modelProviders[selectedProtocol] = [ + ...new custom configs using generatedEnvKey, + ...existing configs whose envKey !== generatedEnvKey + ] + security.auth.selectedType = selectedProtocol + model.name = firstModelId + reloadModelProvidersConfig() + refreshAuth(selectedProtocol) +``` + +Success message: + +```text +Custom API Key authenticated successfully. Settings updated with generated env key and model provider config. +Tip: Use /model to switch between configured models. +``` + +Failure message should preserve the existing authentication failure pattern, with additional user-facing hints if possible: + +```text +Failed to authenticate. Message: + +Please check: +- Base URL is compatible with the selected protocol +- API key is valid for this endpoint +- Model ID exists for this provider +``` + +## Env Key Generation + +The wizard should not ask users to enter an `envKey`. + +Qwen-managed API keys are stored in `settings.json.env`, so the env key should be generated automatically under a Qwen-specific namespace. This avoids collisions with user-managed shell environment variables and prevents multiple custom endpoints from overwriting each other. + +### Format + +```text +QWEN_CUSTOM_API_KEY_${PROTOCOL}_${NORMALIZED_BASE_URL} +``` + +Including the protocol avoids collisions when the same endpoint is used under different protocol adapters. + +### Examples + +```text +Protocol: openai +Base URL: https://api.openai.com/v1 +-> QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_API_OPENAI_COM_V1 + +Protocol: openai +Base URL: https://openrouter.ai/api/v1 +-> QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_OPENROUTER_AI_API_V1 + +Protocol: anthropic +Base URL: https://api.anthropic.com/v1 +-> QWEN_CUSTOM_API_KEY_ANTHROPIC_HTTPS_API_ANTHROPIC_COM_V1 + +Protocol: gemini +Base URL: https://generativelanguage.googleapis.com +-> QWEN_CUSTOM_API_KEY_GEMINI_HTTPS_GENERATIVELANGUAGE_GOOGLEAPIS_COM + +Protocol: openai +Base URL: http://localhost:11434/v1 +-> QWEN_CUSTOM_API_KEY_OPENAI_HTTP_LOCALHOST_11434_V1 +``` + +### Normalization rule + +```text +protocol + -> trim + -> uppercase + -> replace every non A-Z / 0-9 character with _ + +baseUrl + -> trim + -> uppercase + -> replace every non A-Z / 0-9 character with _ + -> collapse consecutive _ characters + -> remove leading/trailing _ + +return QWEN_CUSTOM_API_KEY_${NORMALIZED_PROTOCOL}_${NORMALIZED_BASE_URL} +``` + +Pseudo-code: + +```ts +function generateCustomApiKeyEnvKey(protocol: string, baseUrl: string): string { + const normalize = (value: string) => + value + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + + return `QWEN_CUSTOM_API_KEY_${normalize(protocol)}_${normalize(baseUrl)}`; +} +``` + +## Settings Write Design + +Given user input: + +```text +Protocol: openai +Base URL: https://openrouter.ai/api/v1 +API key: sk-or-v1-xxx +Model IDs: qwen/qwen3-coder,openai/gpt-4.1 +``` + +The wizard should produce: + +```json +{ + "env": { + "QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_OPENROUTER_AI_API_V1": "sk-or-v1-xxx" + }, + "modelProviders": { + "openai": [ + { + "id": "qwen/qwen3-coder", + "name": "qwen/qwen3-coder", + "baseUrl": "https://openrouter.ai/api/v1", + "envKey": "QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_OPENROUTER_AI_API_V1" + }, + { + "id": "openai/gpt-4.1", + "name": "openai/gpt-4.1", + "baseUrl": "https://openrouter.ai/api/v1", + "envKey": "QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_OPENROUTER_AI_API_V1" + } + ] + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen/qwen3-coder" + } +} +``` + +For `anthropic`, the same structure is used, except: + +```text +modelProviders.anthropic +security.auth.selectedType = anthropic +refreshAuth(anthropic) +``` + +For `gemini`, the same structure is used, except: + +```text +modelProviders.gemini +security.auth.selectedType = gemini +refreshAuth(gemini) +``` + +### Persist scope + +Use the same persist-scope strategy as model selection and the existing API-key flows: + +```text +getPersistScopeForModelSelection(settings) +``` + +This keeps behavior consistent with existing `modelProviders` ownership rules. + +### Backup + +Before writing, back up the target settings file, consistent with existing Coding Plan and ModelStudio Standard flows. + +### Process env sync + +After writing `settings.json.env[generatedEnvKey]`, immediately sync: + +```text +process.env[generatedEnvKey] = apiKey +``` + +This ensures `refreshAuth(selectedProtocol)` can use the newly entered key in the same session. + +### Model provider merge rule + +For the generated env key: + +```text +generatedEnvKey = QWEN_CUSTOM_API_KEY_${PROTOCOL}_${NORMALIZED_BASE_URL} +``` + +Update `modelProviders[selectedProtocol]` as follows: + +```text +newConfigs = normalizedModelIds.map(modelId => ({ + id: modelId, + name: modelId, + baseUrl, + envKey: generatedEnvKey, +})) + +existingConfigs = settings.merged.modelProviders?.[selectedProtocol] ?? [] + +preservedConfigs = existingConfigs.filter(config => + config.envKey !== generatedEnvKey +) + +updatedConfigs = [ + ...newConfigs, + ...preservedConfigs, +] +``` + +Rationale: + +- Reconfiguring the same protocol + `baseUrl` replaces old models for that endpoint. +- Configuring a different protocol or `baseUrl` uses a different env key and does not overwrite previous custom endpoints. +- Coding Plan, ModelStudio Standard, and other user configs are preserved unless they use the same generated env key under the same protocol. +- New configs are placed first so the newly configured models are immediately visible and selected by default. + +## Error Handling + +### Protocol validation error + +The protocol must be one of: + +```text +openai +anthropic +gemini +``` + +### Base URL validation error + +```text +Base URL cannot be empty. +``` + +```text +Base URL must start with http:// or https://. +``` + +### API key validation error + +```text +API key cannot be empty. +``` + +### Model IDs validation error + +```text +Model IDs cannot be empty. +``` + +### Authentication failure + +Use the existing failure mechanism where possible, but the user-facing error should help users recover: + +```text +Failed to authenticate. Message: + +Please check: +- Base URL is compatible with the selected protocol +- API key is valid for this endpoint +- Model ID exists for this provider +``` + +## Documentation Link + +The wizard should still expose the existing model providers documentation for advanced users. + +Recommended placement: + +- On the review screen footer, or +- As secondary text on the base URL screen. + +Suggested copy: + +```text +Need advanced generationConfig or capabilities? See: +https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/ +``` + +## Implementation Notes + +Expected `AuthDialog` view levels: + +```ts +type ViewLevel = + | 'main' + | 'region-select' + | 'api-key-input' + | 'api-key-type-select' + | 'alibaba-standard-region-select' + | 'alibaba-standard-api-key-input' + | 'alibaba-standard-model-id-input' + | 'custom-protocol-select' + | 'custom-base-url-input' + | 'custom-api-key-input' + | 'custom-model-id-input' + | 'custom-review-json'; +``` + +Expected custom protocol type: + +```ts +type CustomApiProtocol = + | AuthType.USE_OPENAI + | AuthType.USE_ANTHROPIC + | AuthType.USE_GEMINI; +``` + +Expected new state in `AuthDialog`: + +```ts +const [customProtocol, setCustomProtocol] = useState( + AuthType.USE_OPENAI, +); +const [customProtocolIndex, setCustomProtocolIndex] = useState(0); +const [customBaseUrl, setCustomBaseUrl] = useState(''); +const [customBaseUrlError, setCustomBaseUrlError] = useState( + null, +); +const [customApiKey, setCustomApiKey] = useState(''); +const [customApiKeyError, setCustomApiKeyError] = useState(null); +const [customModelIds, setCustomModelIds] = useState(''); +const [customModelIdsError, setCustomModelIdsError] = useState( + null, +); +``` + +Expected new UI action: + +```ts +handleCustomApiKeySubmit: ( + protocol: CustomApiProtocol, + baseUrl: string, + apiKey: string, + modelIdsInput: string, +) => Promise; +``` + +Expected helper functions: + +```ts +generateCustomApiKeyEnvKey(protocol: string, baseUrl: string): string +normalizeCustomModelIds(modelIdsInput: string): string[] +maskApiKey(apiKey: string): string +``` + +## Acceptance Criteria + +### UX + +- Selecting `/auth -> API Key -> Custom API Key` opens the custom wizard instead of the documentation-only page. +- The first custom wizard step asks for protocol. +- The second step asks for Base URL and displays the selected protocol. +- The third step asks for API key and displays the selected protocol and endpoint. +- The fourth step asks for model IDs and displays the selected protocol and endpoint. +- The review step displays the generated JSON, including masked API key, selected protocol, and generated env key. +- Pressing Enter on the review step saves settings and attempts authentication. +- Pressing Esc navigates back one step at a time. + +### Settings + +- The API key is written to `settings.json.env[generatedEnvKey]`. +- `generatedEnvKey` is derived from selected protocol and `baseUrl` using the Qwen private namespace. +- `modelProviders[selectedProtocol]` receives one entry per normalized model ID. +- Each custom model entry uses `id === name`. +- `security.auth.selectedType` is set to the selected protocol. +- `model.name` is set to the first normalized model ID. +- Existing entries under `modelProviders[selectedProtocol]` with a different `envKey` are preserved. +- Existing entries under `modelProviders[selectedProtocol]` with the same generated `envKey` are replaced. +- Entries under other `modelProviders` protocol keys are preserved. + +### Authentication + +- The generated env key is synced to `process.env` before auth refresh. +- The app reloads model provider config before `refreshAuth(selectedProtocol)`. +- Successful auth closes the auth dialog and shows a success message. +- Failed auth keeps the user in the auth flow and shows an actionable error. + +### Tests + +- Add or update `AuthDialog` tests to cover the custom wizard path. +- Add tests for protocol selection. +- Add tests for env key generation from protocol and base URL. +- Add tests for model ID normalization and deduplication. +- Add tests for settings merge behavior: + - same generated env key replaces old custom entries under the same protocol; + - different env keys are preserved; + - other protocol keys are preserved; + - Coding Plan and ModelStudio Standard entries are preserved. +- Add tests for generated JSON preview content where practical. + +## Open Questions + +1. Should the API key input be masked during typing, or only masked on the review screen? +2. Should local endpoints such as `http://localhost:11434/v1` allow empty or placeholder API keys for servers that do not require authentication? +3. Should the generated JSON preview show only the patch being applied, or the resulting full relevant settings subtree after merge? +4. Should Vertex AI be included in this custom API key wizard, or remain outside because its auth setup differs from simple API-key providers? + +For the first version, recommended defaults are: + +- Support `openai`, `anthropic`, and `gemini`. +- Use existing input behavior during typing. +- Require non-empty API key for consistency with API-key auth flows. +- Show the patch-style JSON that will be saved or updated. +- Keep Vertex AI out of the custom API key wizard until a separate product decision is made. diff --git a/docs/design/session-title/session-title-design.md b/docs/design/session-title/session-title-design.md new file mode 100644 index 00000000000..7f439a39357 --- /dev/null +++ b/docs/design/session-title/session-title-design.md @@ -0,0 +1,376 @@ +# Session Title Design + +> A 3-7 word sentence-case session title generated by the fast model after +> the first assistant turn. Persisted in the session JSONL with a +> `titleSource: 'auto' | 'manual'` tag, surfaced in the session picker, +> and regeneratable on demand via `/rename --auto`. + +## Overview + +`/rename` (#3093) lets a user label a session so they can find it again in +the picker later, but until they run it the picker shows the first user +prompt — often truncated mid-sentence, or describing a framing question +rather than what the session actually became about. Manual renaming is +optional friction most users never do. + +The goal is to make session names _useful by default_: + +- **Descriptive** of what the session actually accomplished, not just the + opening line. 3-7 words, sentence case, git-commit-subject style. +- **Best-effort**: fires in the background after the first reply; if it + fails the user never sees an error. +- **Deferential to the user**: never clobber a `/rename` title the user + chose deliberately, even across CLI tabs on the same session. +- **Explicitly regeneratable** via `/rename --auto` for the "auto title + became stale / I want a fresh one" case. + +## Triggers + +| Trigger | Conditions | Implementation | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| **Auto** | After `recordAssistantTurn` fires. Skipped if an existing title is set, another attempt is in-flight, cap reached, non-interactive, env disabled, or no fast model. | `ChatRecordingService.maybeTriggerAutoTitle` — fire-and-forget | +| **Manual** | User runs `/rename --auto` | `renameCommand.ts` via `tryGenerateSessionTitle` | + +Both paths funnel into a single function — `tryGenerateSessionTitle(config, +signal)` — to guarantee identical prompt, schema, model selection, and +sanitization. The auto trigger is a best-effort background call; the +manual `/rename --auto` is a blocking user action that surfaces a +reason-specific error on failure. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ packages/core/src/services/ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ chatRecordingService.ts │ │ +│ │ │ │ +│ │ recordAssistantTurn() │ │ +│ │ │ │ │ +│ │ ↓ │ │ +│ │ maybeTriggerAutoTitle() │── 6 guards ──→ IIFE(autoTitleController) │ +│ │ │ │ │ │ +│ │ └── resume hydrate │ ↓ │ +│ │ via │ tryGenerateSessionTitle │ +│ │ getSessionTitle- │ (sessionTitle.ts) │ +│ │ Info │ │ │ +│ │ │ ↓ │ +│ └──────────────────────────┘ BaseLlmClient.generateJson │ +│ (fastModel + JSON schema) │ +│ │ │ +│ ┌──────────────────────────┐ ↓ │ +│ │ sessionService.ts │ sanitizeTitle + sanity checks │ +│ │ │ │ │ +│ │ getSessionTitleInfo() │◀── cross-process ↓ │ +│ │ uses │ re-read recordCustomTitle │ +│ │ readLastJsonString- │ before write (…, 'auto') │ +│ │ FieldsSync │ │ +│ │ (sessionStorageUtils) │ │ +│ └──────────────────────────┘ │ +│ │ +│ ┌─────────────────────┐ │ +│ │ utils/terminalSafe │ │ +│ │ stripTerminalCtrl- │ │ +│ │ Sequences │ │ +│ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────┐ +│ packages/cli/src/ui/ │ +│ │ +│ commands/renameCommand.ts ─── /rename → manual │ +│ ─── /rename → kebab │ +│ ─── /rename --auto → auto │ +│ ─── /rename -- --literal → manual │ +│ ─── /rename --unknown-flag → error │ +│ │ +│ components/SessionPicker.tsx ── dims rows where │ +│ session.titleSource === 'auto' │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Files + +| File | Responsibility | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `packages/core/src/services/sessionTitle.ts` | One-shot LLM call + history filter + sanitize. Exports `tryGenerateSessionTitle`. | +| `packages/core/src/services/chatRecordingService.ts` | `maybeTriggerAutoTitle` trigger, guards, cross-process re-read, abort-on-finalize. | +| `packages/core/src/services/sessionService.ts` | `getSessionTitleInfo` public accessor; `renameSession` accepts `titleSource`. | +| `packages/core/src/utils/sessionStorageUtils.ts` | `extractLastJsonStringFields` + `readLastJsonStringFieldsSync` atomic pair reader. | +| `packages/core/src/utils/terminalSafe.ts` | `stripTerminalControlSequences` shared by sentence-case and kebab paths. | +| `packages/cli/src/ui/commands/renameCommand.ts` | `/rename --auto`, sentinel parser, failure-reason message map. | +| `packages/cli/src/ui/components/SessionPicker.tsx` | Dim styling for `titleSource === 'auto'`. | + +## Prompt Design + +### System Prompt + +Replaces the main agent's system prompt for this single call so the model +only tries to label the session, not behave as a coding assistant. + +Bullets below correspond 1:1 with `TITLE_SYSTEM_PROMPT`: + +- 3-7 words, sentence case (only first word and proper nouns capitalized). +- No trailing punctuation, no markdown, no quotes. +- Match the dominant language of the conversation; for Chinese, budget + roughly 12-20 characters. +- Be specific about the user's actual goal — name the feature, bug, or + subject area. Avoid vague catch-alls like "Code changes" or "Help + request". +- Four good examples (three English + one Chinese) and four bad examples + (too vague / too long / wrong case / trailing punctuation). +- Return only a JSON object with a single `title` key. + +### Structured Output (JSON schema) + +Instead of wrapping output in tags (as session-recap does), we use +`BaseLlmClient.generateJson` with a function-calling schema: + +```ts +const TITLE_SCHEMA = { + type: 'object', + properties: { + title: { + type: 'string', + description: + 'A concise sentence-case session title, 3-7 words, no trailing punctuation.', + }, + }, + required: ['title'], +}; +``` + +Why function calling rather than free text + tag extraction: + +1. Cross-provider reliability — OpenAI-compatible endpoints, Gemini, and + Qwen's native tool-calling all implement function calling; tag parsing + would rely on every model respecting a text convention. +2. No reasoning-preamble leakage — the function call arguments come back + structured, so a "thinking" paragraph before the answer can't bleed + into the title. +3. Simpler post-processing — a single `typeof result.title === 'string'` + check plus `sanitizeTitle` covers every realistic model drift. + +The model may still return something the schema allows but the UX +rejects (empty string, whitespace-only, 500 chars, markdown fencing, +control chars). `sanitizeTitle` handles all of these and returns `''` → +service returns `{ok: false, reason: 'empty_result'}`. + +### Call Parameters + +| Parameter | Value | Reason | +| ----------------- | ------------------------------ | ----------------------------------------------------------------------------------------------- | +| `model` | `getFastModel()` — no fallback | Auto-titling on main-model tokens is too expensive to be silent. | +| `schema` | `TITLE_SCHEMA` | Forces `{title: string}`; filters shape drift at the transport layer. | +| `maxOutputTokens` | `100` | More than enough for 7 words plus schema overhead. | +| `temperature` | `0.2` | Mostly deterministic — session titles benefit from stability across regeneration. | +| `maxAttempts` | `1` | Titles are best-effort cosmetic metadata; retries would queue behind user-visible main traffic. | + +Contrast with session-recap, which falls back to the main model. Title +generation is triggered automatically and often; silently spending +main-model tokens without a user opt-in is a real bill surprise. Manual +`/rename --auto` explicitly fails with `no_fast_model` rather than +fallback — forcing the user to make the fast-model choice consciously. + +## History Filtering + +`geminiClient.getChat().getHistory()` returns `Content[]` that includes +tool calls, tool responses (often 10K+ tokens of file content), and model +thought parts. Feeding that raw into the title LLM would bias the label +toward implementation noise like "Called grep on auth module". + +`filterToDialog` keeps only `user` / `model` entries with non-empty text +and no `thought` / `thoughtSignature` parts. `takeRecentDialog` slices to +the last 20 messages and refuses to start on a dangling model/tool +response. `flattenToTail` converts to "Role: text" lines and slices the +last 1000 characters. + +### The 1000-character tail slice + +A session that starts with `help me debug X` but pivots to refactoring Y +should be titled about Y. Titling by the head locks in the opening +framing; titling by the tail captures what the session became. + +### UTF-16 surrogate handling + +`.slice(-1000)` on a UTF-16 code-unit boundary can orphan a high or low +surrogate if a CJK supplementary char or emoji gets cut. Some providers +respond to the resulting invalid UTF-16 with a 400 — which, without +handling, would burn an attempt for no reason. `flattenToTail` drops a +leading orphaned low surrogate; `sanitizeTitle` scrubs any orphaned +surrogate after the max-length trim on the output path too. + +## Persistence + +### Record shape + +`CustomTitleRecordPayload` grows an optional `titleSource: 'auto' | +'manual'` field: + +```jsonc +{ + "type": "system", + "subtype": "custom_title", + "systemPayload": { + "customTitle": "Debug login button on mobile", + "titleSource": "auto", + }, +} +``` + +The field is optional, and absent-in-legacy records are treated as +`undefined`. `SessionPicker` dims rows only on a strict `=== 'auto'` +match — a pre-change user `/rename` title is never silently reclassified +as a model guess. + +### Resume hydration + +On resume, `ChatRecordingService` constructor calls +`sessionService.getSessionTitleInfo(sessionId)` to read **both** the +title and its source. Without hydrating the source, `finalize()`'s +re-append (which runs on every session lifecycle event) would rewrite +auto as manual on every resume cycle — silently stripping the dim +affordance. + +### Atomic pair read + +`extractLastJsonStringFields` returns `customTitle` and `titleSource` +from the **same matching line** in a single scan. Two separate +`readLastJsonStringFieldSync` calls could land on different records if +an older line has only the primary field, yielding a mismatched pair. +The extractor also requires a proper closing quote on the primary value, +so a crash-truncated trailing record can't win the latest-match race. + +### Full-file scan cap + +Phase-2 (when the tail-window fast path misses) streams the whole file +in 64KB chunks. Capped at `MAX_FULL_SCAN_BYTES = 64 MB` so a corrupt +multi-GB JSONL can't freeze the session picker on the main event loop. +The picker's latency envelope survives corruption. + +### Symlink defense + +Session reads open with `O_NOFOLLOW` (falls back to plain read-only on +Windows, where the constant is not exposed). Defense in depth so a +symlink planted in `~/.qwen/projects//chats/` can't redirect a +metadata read to an unrelated file. + +## Concurrency and Edge Cases + +### Trigger guard order + +`maybeTriggerAutoTitle` checks six conditions in this exact order — each +short-circuits the rest so the cheap ones run first: + +1. `currentCustomTitle` set → skip. Never overwrite manual / prior auto. +2. `autoTitleController !== undefined` → skip. One attempt at a time. +3. `autoTitleAttempts >= 3` → skip. Cap bounds total waste. +4. `!config.isInteractive()` → skip. Headless `qwen -p` / CI never spends + fast-model tokens on a one-shot session. +5. `autoTitleDisabledByEnv()` → skip. `QWEN_DISABLE_AUTO_TITLE=1` + explicit opt-out. +6. `!config.getFastModel()` → skip. No fast-model → no-op. + +### Why the cap is 3, not 1 + +The first assistant turn can be a pure tool-call with no user-visible +text (e.g. the model opens with a `grep`). `tryGenerateSessionTitle` +returns `{ok: false, reason: 'empty_history'}` in that case. Without a +retry window, an entire session's chance at a title would be burned on +turn 1 before the user said anything interesting. Cap of 3 covers the +common "first turn is noise" case while still bounding runaway retry on +a persistently failing fast model. + +### Cross-process manual-rename race + +Two CLI tabs on the same session file can diverge in memory. Tab A runs +`/rename foo` and writes `titleSource: manual`. Tab B's +`ChatRecordingService` has its own `currentCustomTitle = undefined` and +would naively overwrite with an auto title. + +After the LLM call resolves, the IIFE re-reads the JSONL via +`sessionService.getSessionTitleInfo`. If the file shows +`source: 'manual'`, the IIFE bails AND syncs its in-memory state so +subsequent turns respect the rename too. Cost: one 64KB tail read per +successful generation; negligible. + +### Abort propagation on `finalize()` + +`autoTitleController` doubles as the in-flight flag. `finalize()` (run +on session switch and process shutdown) calls +`autoTitleController.abort()` before re-appending the title record. The +LLM socket is cancelled promptly; session switch doesn't wait on a slow +fast-model call. The IIFE's `finally` block clears +`autoTitleController` only if it's still the active one, so a finalize +mid-flight doesn't race a concurrent `recordAssistantTurn`. + +### Manual `/rename` lands mid-flight + +Between the IIFE's `await` completing and the `recordCustomTitle('auto')` +call, the user could `/rename foo`. The IIFE re-checks +`this.currentTitleSource === 'manual'` and bails. The in-process check +AND the cross-process re-read both run; manual wins at both layers. + +## Configuration + +### User-facing knobs + +| Setting / env var | Default | Effect | +| --------------------------- | ------- | --------------------------------------------------------------------------------------------------- | +| `fastModel` | unset | Required for auto-titling. Unset → no-op (no main-model fallback). | +| `QWEN_DISABLE_AUTO_TITLE=1` | unset | Opt out of the auto trigger without unsetting `fastModel`. `/rename --auto` still works on request. | + +No `settings.json` toggle — the env var is the only user-visible +off-switch. Rationale: the feature is cosmetic and cheap; a settings +toggle would add a UI surface for something that can live as a one-time +env export for the few users who want to disable it. + +### Why auto doesn't fall back to the main model + +Auto-titling is triggered unconditionally after every assistant turn. +If a user without a fast model were silently charged main-model tokens +for every new session's title, the cost delta is invisible until the +monthly bill arrives. Failing quietly (no-op, no title, no cost) is the +safer default. `/rename --auto` surfaces `no_fast_model` as an +actionable error so the user can set one if they want to. + +## Observability + +`createDebugLogger('SESSION_TITLE')` emits `debugLogger.warn` from the +generator's catch block. Failures are fully transparent to the user — +auto-title is an auxiliary feature and never throws into the UI. + +Developers can grep for the `[SESSION_TITLE]` tag in the debug log +(`~/.qwen/debug/.txt`; `latest.txt` symlinks to the current +session). A working end-to-end call produces no log output; a failing +one gets one WARN line with the underlying error message. + +## Security Hardening + +The title value is rendered verbatim in the terminal (session picker) +AND persisted in a user-readable JSONL file. Both surfaces are attack +reachable if a compromised or prompt-injected fast model returns +hostile text. + +| Concern | Guard | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| ANSI / OSC-8 / CSI injection | `stripTerminalControlSequences` before both JSONL write and picker render. | +| Clickable-link smuggle via OSC-8 | Same — OSC sequences stripped as whole units, not just the ESC byte. | +| Invalid UTF-16 surrogates | Scrubbed in `flattenToTail` (LLM input) and `sanitizeTitle` (LLM output after max-length trim). | +| Subtype-line spoof via user message content | `lineContains: '"subtype":"custom_title"'` — user text that happens to contain the literal phrase can't shadow a real record. | +| Symlink redirect on session reads | `O_NOFOLLOW` (no-op on Windows where the constant is missing). | +| Truncated trailing JSONL record | `extractLastJsonStringFields` requires a closing quote before a record wins the latest-match race. | +| Pathological file size freezing the picker | `MAX_FULL_SCAN_BYTES = 64 MB` cap on Phase-2 full-file scan. | +| Paired CJK bracket decorators (`【Draft】`) | Stripped as a unit so a lone closing bracket doesn't dangle. | + +## Out of Scope + +| Item | Why not | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Auto-regenerate when the title goes stale | `/rename --auto` is the explicit user-triggered path. Silent mid-session title swaps would confuse users scrolling back through the picker. | +| WebUI / VSCode dim-styling parity | Those surfaces read `customTitle` already and will show auto titles as if manual. A follow-up can wire the `titleSource` through. | +| Settings-dialog toggle for auto generation | Env var is the single knob. Full settings UI is easy to add later if user demand surfaces. | +| i18n locale catalog entries for new strings | Consistent with existing `/rename` strings, which fall through to English. A repo-wide i18n pass is out of scope. | +| Migration to re-classify legacy records | Back-compat by design: absent `titleSource` is treated as manual. Rewriting old records would risk losing user intent. | +| Non-interactive auto-titling | `qwen -p` / CI scripts throw the session away; fast-model tokens for a title no one will ever resume is pure waste. | diff --git a/docs/developers/tools/introduction.md b/docs/developers/tools/introduction.md index 9c732555246..1dafb14c885 100644 --- a/docs/developers/tools/introduction.md +++ b/docs/developers/tools/introduction.md @@ -46,7 +46,6 @@ Qwen Code's built-in tools can be broadly categorized as follows: - **[File System Tools](./file-system.md):** For interacting with files and directories (reading, writing, listing, searching, etc.). - **[Shell Tool](./shell.md) (`run_shell_command`):** For executing shell commands. - **[Web Fetch Tool](./web-fetch.md) (`web_fetch`):** For retrieving content from URLs. -- **[Web Search Tool](./web-search.md) (`web_search`):** For searching the web. - **[Multi-File Read Tool](./multi-file.md) (`read_many_files`):** A specialized tool for reading content from multiple files or directories, often used by the `@` command. - **[Memory Tool](./memory.md) (`save_memory`):** For saving and recalling information across sessions. - **[Todo Write Tool](./todo-write.md) (`todo_write`):** For creating and managing structured task lists during coding sessions. @@ -58,5 +57,6 @@ Additionally, these tools incorporate: - **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the model and your local environment or other services like APIs. - **[MCP Quick Start Guide](../mcp-quick-start.md)**: Get started with MCP in 5 minutes with practical examples - **[MCP Example Configurations](../mcp-example-configs.md)**: Ready-to-use configurations for common scenarios + - **[Web Search via MCP](./web-search.md)**: Connect to web search services (Bailian, Tavily, GLM) through MCP - **[MCP Testing & Validation](../mcp-testing-validation.md)**: Test and validate your MCP server setups - **[Sandboxing](../sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk. diff --git a/docs/developers/tools/web-search.md b/docs/developers/tools/web-search.md index dd1fd7ec6b4..c55790891b2 100644 --- a/docs/developers/tools/web-search.md +++ b/docs/developers/tools/web-search.md @@ -1,185 +1,215 @@ -# Web Search Tool (`web_search`) +# Web Search -This document describes the `web_search` tool for performing web searches using multiple providers. +Qwen Code supports web search capabilities through **MCP (Model Context Protocol)** integrations. Rather than a built-in search tool, web search is provided by connecting to external MCP servers, giving you full flexibility to choose the search service that best fits your needs. -## Description +## ⚠️ Breaking Change: Built-in `web_search` Tool Removed -Use `web_search` to perform a web search and get information from the internet. The tool supports multiple search providers and returns a concise answer with source citations when available. +> **Affected versions:** `V0.0.7+` through the last release with built-in web search support. -### Supported Providers +The built-in `web_search` tool and all its associated configuration have been **removed**. If you were using any of the following, you should migrate to the MCP-based approach described in this document: -1. **DashScope** (Official) - Available when explicitly configured in settings (Qwen OAuth free tier auto-injection discontinued 2026-04-15) -2. **Tavily** - High-quality search API with built-in answer generation -3. **Google Custom Search** - Google's Custom Search JSON API +| Removed | What to do | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `webSearch` block in `settings.json` | Configure an MCP server in `mcpServers` instead (see below) | +| `advanced.tavilyApiKey` in `settings.json` | Use the [Tavily MCP server](#tavily-websearch) | +| `TAVILY_API_KEY` environment variable | Use the [Tavily MCP server](#tavily-websearch) | +| `DASHSCOPE_API_KEY` for web search | Use the [Alibaba Cloud Bailian WebSearch MCP](#alibaba-cloud-bailian-websearch-recommended) | +| `GLM_API_KEY` for web search | Use the [GLM WebSearch Prime MCP](#glm-websearch-prime-zhipuai) | +| `--tavily-api-key` / `--glm-api-key` / `--dashscope-api-key` CLI flags | Configure via `mcpServers` in `settings.json` | -### Arguments +### Migration Examples -`web_search` takes two arguments: +**Before (Tavily via built-in tool):** -- `query` (string, required): The search query -- `provider` (string, optional): Specific provider to use ("dashscope", "tavily", "google") - - If not specified, uses the default provider from configuration +```json +{ + "webSearch": { + "provider": [{ "type": "tavily", "apiKey": "tvly-xxx" }], + "default": "tavily" + } +} +``` + +**After (Tavily via MCP):** -## Configuration +```json +{ + "mcpServers": { + "tavily": { + "httpUrl": "https://mcp.tavily.com/mcp/?tavilyApiKey=tvly-xxx" + } + } +} +``` -### Method 1: Settings File (Recommended) +--- -Add to your `settings.json`: +**Before (DashScope via built-in tool):** ```json { "webSearch": { - "provider": [ - { "type": "dashscope" }, - { "type": "tavily", "apiKey": "tvly-xxxxx" }, - { - "type": "google", - "apiKey": "your-google-api-key", - "searchEngineId": "your-search-engine-id" - } - ], + "provider": [{ "type": "dashscope", "apiKey": "sk-xxx" }], "default": "dashscope" } } ``` -**Notes:** - -- DashScope doesn't require an API key (official, free service) -- **Qwen OAuth users:** DashScope is automatically added to your provider list, even if not explicitly configured -- Configure additional providers (Tavily, Google) if you want to use them alongside DashScope -- Set `default` to specify which provider to use by default (if not set, priority order: Tavily > Google > DashScope) +**After (Alibaba Cloud Bailian WebSearch via MCP):** -### Method 2: Environment Variables +```json +{ + "mcpServers": { + "WebSearch": { + "httpUrl": "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp", + "headers": { + "Authorization": "Bearer sk-xxx" + } + } + } +} +``` -Set environment variables in your shell or `.env` file: +--- -```bash -# Tavily -export TAVILY_API_KEY="tvly-xxxxx" +## Supported MCP Web Search Services -# Google -export GOOGLE_API_KEY="your-api-key" -export GOOGLE_SEARCH_ENGINE_ID="your-engine-id" -``` +### Alibaba Cloud Bailian WebSearch (Recommended) -### Method 3: Command Line Arguments +The official web search MCP service provided by Alibaba Cloud Bailian platform, powered by DashScope. -Pass API keys when running Qwen Code: +- **MCP Marketplace:** https://bailian.console.aliyun.com/cn-beijing?tab=mcp#/mcp-market/detail/WebSearch +- **Cost:** Paid (billed via Alibaba Cloud DashScope) +- **Get API Key:** https://help.aliyun.com/zh/model-studio/get-api-key +- **Best for:** Chinese-language queries, access to Chinese web content, integration with the Alibaba Cloud ecosystem -```bash -# Tavily -qwen --tavily-api-key tvly-xxxxx +#### Setup -# Google -qwen --google-api-key your-key --google-search-engine-id your-id +**Method 1: CLI command** -# Specify default provider -qwen --web-search-default tavily +```bash +qwen mcp add WebSearch \ + -t http \ + "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp" \ + -H "Authorization: Bearer ${DASHSCOPE_API_KEY}" ``` -### Backward Compatibility (Deprecated) - -⚠️ **DEPRECATED:** The legacy `tavilyApiKey` configuration is still supported for backward compatibility but is deprecated: +**Method 2: `settings.json`** ```json { - "advanced": { - "tavilyApiKey": "tvly-xxxxx" // ⚠️ Deprecated + "mcpServers": { + "WebSearch": { + "httpUrl": "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp", + "headers": { + "Authorization": "Bearer ${DASHSCOPE_API_KEY}" + } + } } } ``` -**Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration. +Replace `${DASHSCOPE_API_KEY}` with your actual API key, or set it as an environment variable so Qwen Code picks it up automatically. -## Disabling Web Search +--- -If you want to disable the web search functionality, you can exclude the `web_search` tool in your `settings.json`: +### Tavily WebSearch -```json -{ - "tools": { - "exclude": ["web_search"] - } -} -``` +A production-ready MCP server providing real-time web search, extract, map, and crawl capabilities. -**Note:** This setting requires a restart of Qwen Code to take effect. Once disabled, the `web_search` tool will not be available to the model, even if web search providers are configured. +- **Repository:** https://github.com/tavily-ai/tavily-mcp +- **Cost:** Paid (free tier available) +- **Get API Key:** https://app.tavily.com/home +- **Best for:** General-purpose web search with high-quality AI-generated answers -## Usage Examples +#### Available Tools -### Basic search (using default provider) +- `tavily_search` — Real-time web search +- `tavily_extract` — Intelligent data extraction from web pages +- `tavily_map` — Create a structured map of a website +- `tavily_crawl` — Systematically explore websites -``` -web_search(query="latest advancements in AI") -``` +#### Setup -### Search with specific provider +**Method 1: CLI command (Remote MCP)** -``` -web_search(query="latest advancements in AI", provider="tavily") +```bash +qwen mcp add tavily \ + -t http \ + "https://mcp.tavily.com/mcp/?tavilyApiKey=${TAVILY_API_KEY}" ``` -### Real-world examples +**Method 2: `settings.json` (Remote MCP)** -``` -web_search(query="weather in San Francisco today") -web_search(query="latest Node.js LTS version", provider="google") -web_search(query="best practices for React 19", provider="dashscope") +```json +{ + "mcpServers": { + "tavily": { + "httpUrl": "https://mcp.tavily.com/mcp/?tavilyApiKey=${TAVILY_API_KEY}" + } + } +} ``` -## Provider Details +Replace `${TAVILY_API_KEY}` with your actual API key, or set it as an environment variable. -### DashScope (Official) +**Method 3: `settings.json` (Local NPX)** -- **Cost:** Free (requires Qwen OAuth credentials) -- **Authentication:** Requires Qwen OAuth credentials -- **Configuration:** Must be explicitly configured in `settings.json` web search providers (auto-injection for Qwen OAuth users was removed when the free tier was discontinued on 2026-04-15) -- **Quota:** 200 requests/minute, 100 requests/day -- **Best for:** General queries when you have Qwen OAuth credentials +```json +{ + "mcpServers": { + "tavily-mcp": { + "command": "npx", + "args": ["-y", "tavily-mcp@latest"], + "env": { + "TAVILY_API_KEY": "your-api-key-here" + } + } + } +} +``` -### Tavily +--- -- **Cost:** Requires API key (paid service with free tier) -- **Sign up:** https://tavily.com -- **Features:** High-quality results with AI-generated answers -- **Best for:** Research, comprehensive answers with citations +### GLM WebSearch Prime (ZhipuAI) -### Google Custom Search +The official web search Remote MCP service provided by ZhipuAI (智谱AI), designed for GLM Coding Plan users. Provides real-time web search including news, stock prices, weather, and more. -- **Cost:** Free tier available (100 queries/day) -- **Setup:** - 1. Enable Custom Search API in Google Cloud Console - 2. Create a Custom Search Engine at https://programmablesearchengine.google.com -- **Features:** Google's search quality -- **Best for:** Specific, factual queries +- **Documentation:** https://docs.bigmodel.cn/cn/coding-plan/mcp/search-mcp-server +- **Cost:** Included in GLM Coding Plan subscription (Lite: 100 calls/month, Pro: 1,000/month, Max: 4,000/month) +- **Get API Key:** https://open.bigmodel.cn/apikey/platform +- **Best for:** Chinese-language queries, real-time information retrieval -## Important Notes +#### Available Tools -- **Response format:** Returns a concise answer with numbered source citations -- **Citations:** Source links are appended as a numbered list: [1], [2], etc. -- **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter -- **DashScope availability:** Automatically available for Qwen OAuth users, no configuration needed -- **Default provider selection:** The system automatically selects a default provider based on availability: - 1. Your explicit `default` configuration (highest priority) - 2. CLI argument `--web-search-default` - 3. First available provider by priority: Tavily > Google > DashScope +- `webSearchPrime` — Web search returning page title, URL, summary, site name, and favicon -## Troubleshooting +#### Setup -**Tool not available?** +**Method 1: CLI command** -- **For Qwen OAuth users:** The tool is automatically registered with DashScope provider, no configuration needed -- **For other authentication types:** Ensure at least one provider (Tavily or Google) is configured -- For Tavily/Google: Verify your API keys are correct +```bash +qwen mcp add web-search-prime \ + -t http \ + "https://open.bigmodel.cn/api/mcp/web_search_prime/mcp" \ + -H "Authorization: Bearer ${GLM_API_KEY}" +``` -**Provider-specific errors?** +**Method 2: `settings.json`** -- Use the `provider` parameter to try a different search provider -- Check your API quotas and rate limits -- Verify API keys are properly set in configuration +```json +{ + "mcpServers": { + "web-search-prime": { + "httpUrl": "https://open.bigmodel.cn/api/mcp/web_search_prime/mcp", + "headers": { + "Authorization": "Bearer ${GLM_API_KEY}" + } + } + } +} +``` -**Need help?** +Replace `${GLM_API_KEY}` with your actual ZhipuAI API key, or set it as an environment variable. -- Check your configuration: Run `qwen` and use the settings dialog -- View your current settings in `~/.qwen-code/settings.json` (macOS/Linux) or `%USERPROFILE%\.qwen-code\settings.json` (Windows) +--- diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 6dc6d1d021d..a848388fa39 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -430,11 +430,6 @@ LSP server configuration is done through `.lsp.json` files in your project root | `advanced.dnsResolutionOrder` | string | The DNS resolution order. | `undefined` | | `advanced.excludedEnvVars` | array of strings | Environment variables to exclude from project context. Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded. | `["DEBUG","DEBUG_MODE"]` | | `advanced.bugCommand` | object | Configuration for the bug report command. Overrides the default URL for the `/bug` command. Properties: `urlTemplate` (string): A URL that can contain `{title}` and `{info}` placeholders. Example: `"bugCommand": { "urlTemplate": "https://bug.example.com/new?title={title}&info={info}" }` | `undefined` | -| `advanced.tavilyApiKey` | string | API key for Tavily web search service. Used to enable the `web_search` tool functionality. | `undefined` | - -> [!note] -> -> **Note about advanced.tavilyApiKey:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format. #### mcpServers @@ -571,7 +566,6 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe | `CLI_TITLE` | Set to a string to customize the title of the CLI. | | | `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | | `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code uses an adaptive strategy: starts with 8K tokens and automatically retries with 64K if the response is truncated. Set this to a specific value (e.g., `16000`) to use a fixed limit instead. | Takes precedence over the capped default (8K) but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` | -| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` | | `QWEN_CODE_UNATTENDED_RETRY` | Set to `true` or `1` to enable persistent retry mode. When enabled, transient API capacity errors (HTTP 429 Rate Limit and 529 Overloaded) are retried indefinitely with exponential backoff (capped at 5 minutes per retry) and heartbeat keepalives every 30 seconds on stderr. | Designed for CI/CD pipelines and background automation where long-running tasks should survive temporary API outages. Must be set explicitly — `CI=true` alone does **not** activate this mode. See [Headless Mode](../features/headless#persistent-retry-mode) for details. Example: `export QWEN_CODE_UNATTENDED_RETRY=1` | | `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process. Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` | @@ -620,7 +614,6 @@ For sandbox image selection, precedence is: | `--version` | | Displays the version of the CLI. | | | | `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. | | `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` | -| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` | ## Context Files (Hierarchical Instructional Context) diff --git a/docs/users/features/sub-agents.md b/docs/users/features/sub-agents.md index 957262a6a68..d4473572efb 100644 --- a/docs/users/features/sub-agents.md +++ b/docs/users/features/sub-agents.md @@ -333,7 +333,6 @@ tools: - read_file - write_file - read_many_files - - web_search --- You are a technical documentation specialist. diff --git a/integration-tests/cli/web_search.test.ts b/integration-tests/cli/web_search.test.ts deleted file mode 100644 index 5ab0b436456..00000000000 --- a/integration-tests/cli/web_search.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import { - TestRig, - printDebugInfo, - validateModelOutput, -} from '../test-helper.js'; - -describe('web_search', () => { - it('should be able to search the web', async () => { - // Check if any web search provider is available - const hasTavilyKey = !!process.env['TAVILY_API_KEY']; - const hasGoogleKey = - !!process.env['GOOGLE_API_KEY'] && - !!process.env['GOOGLE_SEARCH_ENGINE_ID']; - - // Skip if no provider is configured - // Note: DashScope provider is automatically available for Qwen OAuth users, - // but we can't easily detect that in tests without actual OAuth credentials - if (!hasTavilyKey && !hasGoogleKey) { - console.warn( - 'Skipping web search test: No web search provider configured. ' + - 'Set TAVILY_API_KEY or GOOGLE_API_KEY+GOOGLE_SEARCH_ENGINE_ID environment variables.', - ); - return; - } - - const rig = new TestRig(); - // Configure web search in settings if provider keys are available - const webSearchSettings: Record = {}; - const providers: Array<{ - type: string; - apiKey?: string; - searchEngineId?: string; - }> = []; - - if (hasTavilyKey) { - providers.push({ type: 'tavily', apiKey: process.env['TAVILY_API_KEY'] }); - } - if (hasGoogleKey) { - providers.push({ - type: 'google', - apiKey: process.env['GOOGLE_API_KEY'], - searchEngineId: process.env['GOOGLE_SEARCH_ENGINE_ID'], - }); - } - - if (providers.length > 0) { - webSearchSettings.webSearch = { - provider: providers, - default: providers[0]?.type, - }; - } - - await rig.setup('should be able to search the web', { - settings: webSearchSettings, - }); - - let result; - try { - result = await rig.run(`what is the weather in London`); - } catch (error) { - // Network errors can occur in CI environments - if ( - error instanceof Error && - (error.message.includes('network') || error.message.includes('timeout')) - ) { - console.warn( - 'Skipping test due to network error:', - (error as Error).message, - ); - return; // Skip the test - } - throw error; // Re-throw if not a network error - } - - const foundToolCall = await rig.waitForToolCall('web_search'); - - // Add debugging information - if (!foundToolCall) { - const allTools = printDebugInfo(rig, result); - - // Check if the tool call failed due to network issues - const failedSearchCalls = allTools.filter( - (t) => t.toolRequest.name === 'web_search' && !t.toolRequest.success, - ); - if (failedSearchCalls.length > 0) { - console.warn( - 'web_search tool was called but failed, possibly due to network issues', - ); - console.warn( - 'Failed calls:', - failedSearchCalls.map((t) => t.toolRequest.args), - ); - return; // Skip the test if network issues - } - } - - expect(foundToolCall, 'Expected to find a call to web_search').toBeTruthy(); - - // Validate model output - will throw if no output, warn if missing expected content - const hasExpectedContent = validateModelOutput( - result, - ['weather', 'london'], - 'Web search test', - ); - - // If content was missing, log the search queries used - if (!hasExpectedContent) { - const searchCalls = rig - .readToolLogs() - .filter((t) => t.toolRequest.name === 'web_search'); - if (searchCalls.length > 0) { - console.warn( - 'Search queries used:', - searchCalls.map((t) => t.toolRequest.args), - ); - } - } - }); -}); diff --git a/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js b/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js index abb893b1c5d..5bcfd6d7102 100644 --- a/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js +++ b/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js @@ -530,7 +530,6 @@ const TOOL_DISPLAY_NAME_BY_NAME = { skill: 'Skill', exit_plan_mode: 'ExitPlanMode', web_fetch: 'WebFetch', - web_search: 'WebSearch', list_directory: 'ListFiles', }; @@ -546,7 +545,6 @@ const TOOL_KIND_BY_NAME = { rename: 'move', grep_search: 'search', glob: 'search', - web_search: 'search', list_directory: 'search', run_shell_command: 'execute', bash: 'execute', diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index 5ea241db7bc..2ee6e8a8d87 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -905,7 +905,6 @@ describe('Permission Control (E2E)', () => { 'grep_search', 'glob', 'list_directory', - 'web_search', 'web_fetch', ]; diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index dd3421b6c04..25a7d44fabc 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -91,10 +91,6 @@ export async function handleQwenAuth( openaiLoggingDir: undefined, proxy: undefined, includeDirectories: undefined, - tavilyApiKey: undefined, - googleApiKey: undefined, - googleSearchEngineId: undefined, - webSearchDefault: undefined, screenReader: undefined, inputFormat: undefined, outputFormat: undefined, diff --git a/packages/cli/src/commands/extensions/examples/agent/agents/diary.md b/packages/cli/src/commands/extensions/examples/agent/agents/diary.md index 8c0c76a9170..45eea1424d5 100644 --- a/packages/cli/src/commands/extensions/examples/agent/agents/diary.md +++ b/packages/cli/src/commands/extensions/examples/agent/agents/diary.md @@ -11,7 +11,6 @@ tools: - NotebookRead - WebFetch - TodoWrite - - WebSearch modelConfig: model: qwen3-coder-plus --- diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 01ad506b626..4c18efcc3d5 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1734,7 +1734,6 @@ describe('loadCliConfig with includeDirectories', () => { expect(config.getToolDiscoveryCommand()).toBeUndefined(); expect(config.getToolCallCommand()).toBeUndefined(); expect(config.getMcpServers()).toEqual({}); - expect(config.getWebSearchConfig()).toBeUndefined(); expect(config.isLspEnabled()).toBe(false); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d85d1194119..304f878ac98 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -67,7 +67,6 @@ export function isValidSessionId(value: string): boolean { } import { isWorkspaceTrusted } from './trustedFolders.js'; -import { buildWebSearchConfig } from './webSearch.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; const debugLogger = createDebugLogger('CONFIG'); @@ -138,10 +137,6 @@ export interface CliArgs { openaiLoggingDir: string | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; - tavilyApiKey: string | undefined; - googleApiKey: string | undefined; - googleSearchEngineId: string | undefined; - webSearchDefault: string | undefined; screenReader: boolean | undefined; inputFormat?: string | undefined; outputFormat: string | undefined; @@ -431,23 +426,6 @@ export async function parseArguments(): Promise { type: 'string', description: 'OpenAI base URL (for custom endpoints)', }) - .option('tavily-api-key', { - type: 'string', - description: 'Tavily API key for web search', - }) - .option('google-api-key', { - type: 'string', - description: 'Google Custom Search API key', - }) - .option('google-search-engine-id', { - type: 'string', - description: 'Google Custom Search Engine ID', - }) - .option('web-search-default', { - type: 'string', - description: - 'Default web search provider (dashscope, tavily, google)', - }) .option('screen-reader', { type: 'boolean', description: 'Enable screen reader mode for accessibility.', @@ -1206,9 +1184,6 @@ export async function loadCliConfig( ? [] : (settings.security?.allowedHttpHookUrls ?? []), cliVersion: await getCliVersion(), - webSearch: bareMode - ? undefined - : buildWebSearchConfig(argv, settings, selectedAuthType), ideMode, chatCompression: settings.model?.chatCompression, folderTrust, diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 252db02c0cc..5d4363b13f1 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -28,7 +28,6 @@ describe('SettingsSchema', () => { 'mcp', 'security', 'advanced', - 'webSearch', ]; expectedSettings.forEach((setting) => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8aa7517a62d..4ebb587ea90 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1590,37 +1590,9 @@ const SETTINGS_SCHEMA = { 'Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.', showInDialog: false, }, - tavilyApiKey: { - type: 'string', - label: 'Tavily API Key (Deprecated)', - category: 'Advanced', - requiresRestart: false, - default: undefined as string | undefined, - description: - '⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.', - showInDialog: false, - }, }, }, - webSearch: { - type: 'object', - label: 'Web Search', - category: 'Advanced', - requiresRestart: true, - default: undefined as - | { - provider: Array<{ - type: 'tavily' | 'google' | 'dashscope'; - apiKey?: string; - searchEngineId?: string; - }>; - default: string; - } - | undefined, - description: 'Configuration for web search providers.', - showInDialog: false, - }, agents: { type: 'object', label: 'Agents', diff --git a/packages/cli/src/config/webSearch.ts b/packages/cli/src/config/webSearch.ts deleted file mode 100644 index 4dc8adbbea7..00000000000 --- a/packages/cli/src/config/webSearch.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { WebSearchProviderConfig } from '@qwen-code/qwen-code-core'; -import type { Settings } from './settings.js'; - -/** - * CLI arguments related to web search configuration - */ -export interface WebSearchCliArgs { - tavilyApiKey?: string; - googleApiKey?: string; - googleSearchEngineId?: string; - webSearchDefault?: string; -} - -/** - * Web search configuration structure - */ -export interface WebSearchConfig { - provider: WebSearchProviderConfig[]; - default: string; -} - -/** - * Build webSearch configuration from multiple sources with priority: - * 1. settings.json (new format) - highest priority - * 2. Command line args + environment variables - * 3. Legacy tavilyApiKey (backward compatibility) - * - * @param argv - Command line arguments - * @param settings - User settings from settings.json - * @param authType - Authentication type (e.g., 'qwen-oauth') - * @returns WebSearch configuration or undefined if no providers available - */ -export function buildWebSearchConfig( - argv: WebSearchCliArgs, - settings: Settings, - _authType?: string, -): WebSearchConfig | undefined { - // Step 1: Collect providers from settings or command line/env - let providers: WebSearchProviderConfig[] = []; - let userDefault: string | undefined; - - if (settings.webSearch) { - // Use providers from settings.json - providers = [...settings.webSearch.provider]; - userDefault = settings.webSearch.default; - } else { - // Build providers from command line args and environment variables - const tavilyKey = - argv.tavilyApiKey || - settings.advanced?.tavilyApiKey || - process.env['TAVILY_API_KEY']; - if (tavilyKey) { - providers.push({ - type: 'tavily', - apiKey: tavilyKey, - } as WebSearchProviderConfig); - } - - const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY']; - const googleEngineId = - argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID']; - if (googleKey && googleEngineId) { - providers.push({ - type: 'google', - apiKey: googleKey, - searchEngineId: googleEngineId, - } as WebSearchProviderConfig); - } - } - - // Step 2: DashScope auto-injection for qwen-oauth was removed when the - // free tier was discontinued on 2026-04-15. Users who explicitly configure - // a dashscope provider in settings.json still get it (handled in Step 1). - - // Step 3: If no providers available, return undefined - if (providers.length === 0) { - return undefined; - } - - // Step 4: Determine default provider - // Priority: user explicit config > CLI arg > first available provider (tavily > google > dashscope) - const providerPriority: Array<'tavily' | 'google' | 'dashscope'> = [ - 'tavily', - 'google', - 'dashscope', - ]; - - // Determine default provider based on availability - let defaultProvider = userDefault || argv.webSearchDefault; - if (!defaultProvider) { - // Find first available provider by priority order - for (const providerType of providerPriority) { - if (providers.some((p) => p.type === providerType)) { - defaultProvider = providerType; - break; - } - } - // Fallback to first available provider if none found in priority list - if (!defaultProvider) { - defaultProvider = providers[0]?.type || 'dashscope'; - } - } - - return { - provider: providers, - default: defaultProvider, - }; -} diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 1b7367984bc..197b8dcb03b 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -595,10 +595,6 @@ describe('gemini.tsx main function kitty protocol', () => { openaiLoggingDir: undefined, proxy: undefined, includeDirectories: undefined, - tavilyApiKey: undefined, - googleApiKey: undefined, - googleSearchEngineId: undefined, - webSearchDefault: undefined, screenReader: undefined, inputFormat: undefined, outputFormat: undefined, diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 04b19544935..c2a427bdb70 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -575,6 +575,8 @@ export default { 'Updates all extensions or a named extension to the latest version.': 'Updates all extensions or a named extension to the latest version.', 'Update all extensions.': 'Update all extensions.', + 'The name of the extension to update.': + 'The name of the extension to update.', 'Either an extension name or --all must be provided': 'Either an extension name or --all must be provided', 'Lists installed extensions.': 'Lists installed extensions.', @@ -726,6 +728,7 @@ export default { 'User Settings': 'User Settings', 'System Settings': 'System Settings', Extensions: 'Extensions', + 'Session (temporary)': 'Session (temporary)', // Hooks - Status '✓ Enabled': '✓ Enabled', '✗ Disabled': '✗ Disabled', @@ -1896,6 +1899,8 @@ export default { // Coding Plan Authentication // ============================================================================ 'API key cannot be empty.': 'API key cannot be empty.', + 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.': + 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.', 'You can get your Coding Plan API key here': 'You can get your Coding Plan API key here', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': @@ -1973,6 +1978,8 @@ export default { 'Show context window usage breakdown.', 'Run /context detail for per-item breakdown.': 'Run /context detail for per-item breakdown.', + 'Show context window usage breakdown. Use "/context detail" for per-item breakdown.': + 'Show context window usage breakdown. Use "/context detail" for per-item breakdown.', 'body loaded': 'body loaded', memory: 'memory', '{{region}} configuration updated successfully.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index a1e7df51a7c..4c3c98ba604 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -578,6 +578,7 @@ export default { '(user)': '(用户)', '[not set]': '[未设置]', '[value stored in keychain]': '[值存储在钥匙串中]', + 'Value:': '值:', 'Manage extension settings.': '管理扩展设置。', 'You need to specify a command (set or list).': '您需要指定命令(set 或 list)。', @@ -1037,6 +1038,8 @@ export default { 'Command:': '命令:', 'Working Directory:': '工作目录:', 'Capabilities:': '功能:', + 'No server selected': '未选择服务器', + prompts: '提示', // MCP Tool List 'No tools available for this server.': '此服务器没有可用工具。', @@ -1049,7 +1052,9 @@ export default { '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail + required: '必需', Type: '类型', + Enum: '枚举', Parameters: '参数', 'No tool selected': '未选择工具', Annotations: '注解', diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 53da7cc32f0..031a9b61ef1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -14,7 +14,7 @@ import { type Mock, } from 'vitest'; import { render, cleanup } from 'ink-testing-library'; -import { AppContainer } from './AppContainer.js'; +import { AppContainer, dedupeNewestFirst } from './AppContainer.js'; import { type Config, makeFakeConfig, @@ -1405,3 +1405,28 @@ describe('AppContainer State Management', () => { }); }); }); + +describe('dedupeNewestFirst', () => { + it('returns empty array for empty input', () => { + expect(dedupeNewestFirst([])).toEqual([]); + }); + + it('preserves order when there are no duplicates', () => { + expect(dedupeNewestFirst(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']); + }); + + it('removes consecutive duplicates', () => { + expect(dedupeNewestFirst(['a', 'a', 'b'])).toEqual(['a', 'b']); + }); + + it('removes non-consecutive duplicates keeping the first (newest) occurrence', () => { + expect( + dedupeNewestFirst([ + 'first prompt', + 'third prompt', + 'second prompt', + 'first prompt', + ]), + ).toEqual(['first prompt', 'third prompt', 'second prompt']); + }); +}); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 89bb17befaf..a0786b5ff3f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -154,6 +154,20 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { }); } +// Exported for tests. Given a newest-first list of messages, return a list +// with duplicates removed, keeping the first (newest) occurrence of each. +export function dedupeNewestFirst(messages: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const msg of messages) { + if (!seen.has(msg)) { + seen.add(msg); + result.push(msg); + } + } + return result; +} + interface AppContainerProps { config: Config; settings: LoadedSettings; @@ -451,20 +465,15 @@ export const AppContainer = (props: AppContainerProps) => { ) .map((item) => item.text) .reverse(); + // Current-session messages are already newest-first; combining with past + // messages gives a newest-first list. dedupeNewestFirst keeps the first + // (newest) occurrence so resubmitting an old prompt promotes it to + // "most recent" rather than leaving a stale copy at an older position. const combinedMessages = [ ...currentSessionUserMessages, ...pastMessagesRaw, ]; - const deduplicatedMessages: string[] = []; - if (combinedMessages.length > 0) { - deduplicatedMessages.push(combinedMessages[0]); - for (let i = 1; i < combinedMessages.length; i++) { - if (combinedMessages[i] !== combinedMessages[i - 1]) { - deduplicatedMessages.push(combinedMessages[i]); - } - } - } - setUserMessages(deduplicatedMessages.reverse()); + setUserMessages(dedupeNewestFirst(combinedMessages).reverse()); }; fetchUserMessages(); }, [historyManager.history, logger]); diff --git a/packages/cli/src/ui/commands/renameCommand.test.ts b/packages/cli/src/ui/commands/renameCommand.test.ts index bc334c8b358..854b50d52aa 100644 --- a/packages/cli/src/ui/commands/renameCommand.test.ts +++ b/packages/cli/src/ui/commands/renameCommand.test.ts @@ -9,16 +9,31 @@ import { renameCommand } from './renameCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +const tryGenerateSessionTitleMock = vi.fn(); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const original = + (await importOriginal()) as typeof import('@qwen-code/qwen-code-core'); + return { + ...original, + tryGenerateSessionTitle: (...args: unknown[]) => + tryGenerateSessionTitleMock(...args), + }; +}); + describe('renameCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext(); + tryGenerateSessionTitleMock.mockReset(); }); it('should have the correct name and description', () => { expect(renameCommand.name).toBe('rename'); - expect(renameCommand.description).toBe('Rename the current conversation'); + expect(renameCommand.description).toBe( + 'Rename the current conversation. --auto lets the fast model pick a title.', + ); }); it('should return error when config is not available', async () => { @@ -103,7 +118,7 @@ describe('renameCommand', () => { const result = await renameCommand.action!(mockContext, 'my-feature'); - expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature'); + expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature', 'manual'); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -130,6 +145,7 @@ describe('renameCommand', () => { expect(mockRenameSession).toHaveBeenCalledWith( 'test-session-id', 'my-feature', + 'manual', ); expect(result).toEqual({ type: 'message', @@ -159,4 +175,270 @@ describe('renameCommand', () => { content: 'Failed to rename session.', }); }); + + describe('bare /rename model selection', () => { + // Pins the kebab-case path's model choice: bare `/rename` (no args) + // prefers fastModel when one is configured, falls back to the main + // model otherwise. Previous tests mocked `getHistory: []` which bailed + // before the model selection ran, leaving this regression-prone. + function mockConfigForKebab(opts: { fastModel?: string; model?: string }): { + config: unknown; + generateContent: ReturnType; + } { + const generateContent = vi.fn().mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'fix-login-bug' }] } }], + }); + const config = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn().mockReturnValue(true), + }), + getFastModel: vi.fn().mockReturnValue(opts.fastModel), + getModel: vi.fn().mockReturnValue(opts.model ?? 'main-model'), + getGeminiClient: vi.fn().mockReturnValue({ + getHistory: vi.fn().mockReturnValue([ + { role: 'user', parts: [{ text: 'fix the login bug' }] }, + { + role: 'model', + parts: [{ text: 'Looking at the handler now.' }], + }, + ]), + }), + getContentGenerator: vi.fn().mockReturnValue({ generateContent }), + }; + return { config, generateContent }; + } + + it('uses fastModel when configured', async () => { + const { config, generateContent } = mockConfigForKebab({ + fastModel: 'qwen-turbo', + model: 'main-model', + }); + mockContext = createMockCommandContext({ + services: { config: config as never }, + }); + + await renameCommand.action!(mockContext, ''); + + expect(generateContent).toHaveBeenCalledOnce(); + expect(generateContent.mock.calls[0][0].model).toBe('qwen-turbo'); + }); + + it('falls back to main model when fastModel is unset', async () => { + const { config, generateContent } = mockConfigForKebab({ + fastModel: undefined, + model: 'main-model', + }); + mockContext = createMockCommandContext({ + services: { config: config as never }, + }); + + await renameCommand.action!(mockContext, ''); + + expect(generateContent).toHaveBeenCalledOnce(); + expect(generateContent.mock.calls[0][0].model).toBe('main-model'); + }); + }); + + describe('--auto flag', () => { + it('refuses --auto when no fast model is configured', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue(undefined), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + '/rename --auto requires a fast model. Configure one with `/model --fast `.', + }); + expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled(); + }); + + it('refuses --auto combined with a positional name', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto my-name'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + '/rename --auto does not take a name. Use `/rename ` to set a name yourself.', + }); + expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled(); + }); + + it('writes an auto-sourced title on --auto success', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: true, + title: 'Fix login button on mobile', + modelUsed: 'qwen-turbo', + }); + const mockRecordCustomTitle = vi.fn().mockReturnValue(true); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: mockRecordCustomTitle, + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce(); + expect(mockRecordCustomTitle).toHaveBeenCalledWith( + 'Fix login button on mobile', + 'auto', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Session renamed to "Fix login button on mobile"', + }); + }); + + it('surfaces empty_history reason with actionable hint', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: false, + reason: 'empty_history', + }); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'No conversation to title yet — send at least one message first.', + }); + }); + + it('surfaces model_error reason distinctly', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: false, + reason: 'model_error', + }); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(result).toMatchObject({ + messageType: 'error', + }); + expect((result as { content: string }).content).toMatch( + /rate limit, auth, or network error/, + ); + }); + + it('rejects unknown flag with sentinel hint', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!( + mockContext, + '--my-label-with-dashes', + ); + + expect(result).toMatchObject({ messageType: 'error' }); + const content = (result as { content: string }).content; + expect(content).toMatch(/Unknown flag "--my-label-with-dashes"/); + expect(content).toMatch(/\/rename -- --my-label-with-dashes/); + expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled(); + }); + + it('surfaces aborted reason when user cancels', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: false, + reason: 'aborted', + }); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Title generation was cancelled.', + }); + }); + + it('falls back to SessionService.renameSession with auto source', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: true, + title: 'Audit auth middleware', + modelUsed: 'qwen-turbo', + }); + const mockRenameSession = vi.fn().mockResolvedValue(true); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: mockRenameSession, + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(mockRenameSession).toHaveBeenCalledWith( + 'test-session-id', + 'Audit auth middleware', + 'auto', + ); + expect(result).toMatchObject({ messageType: 'info' }); + }); + }); }); diff --git a/packages/cli/src/ui/commands/renameCommand.ts b/packages/cli/src/ui/commands/renameCommand.ts index e1a5943317f..5a4137bf6f7 100644 --- a/packages/cli/src/ui/commands/renameCommand.ts +++ b/packages/cli/src/ui/commands/renameCommand.ts @@ -5,10 +5,13 @@ */ import type { Content } from '@google/genai'; -import type { Config } from '@qwen-code/qwen-code-core'; import { getResponseText, SESSION_TITLE_MAX_LENGTH, + stripTerminalControlSequences, + tryGenerateSessionTitle, + type Config, + type SessionTitleFailureReason, } from '@qwen-code/qwen-code-core'; import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; @@ -38,9 +41,11 @@ function extractConversationText(history: Content[]): string { } /** - * Calls the LLM to generate a short session title from conversation history. + * Calls the LLM to generate a short kebab-case session title from conversation + * history. Used when `/rename` is invoked with no arguments — produces a + * filesystem-style name for sessions the user wants to keep long-term. */ -async function generateSessionTitle( +async function generateKebabTitle( config: Config, signal?: AbortSignal, ): Promise { @@ -51,9 +56,15 @@ async function generateSessionTitle( return null; } + // Prefer the fast model for title generation — it's much cheaper and + // faster than the main model, and title generation is a small bounded + // task that doesn't need main-model reasoning. Falls back to the main + // model when no fast model is configured so this path never fails to + // start. + const model = config.getFastModel() ?? config.getModel(); const response = await config.getContentGenerator().generateContent( { - model: config.getModel(), + model, contents: [ { role: 'user', @@ -79,8 +90,13 @@ async function generateSessionTitle( if (!text) { return null; } - // Clean up: take first line, remove quotes/backticks - const cleaned = text.split('\n')[0].replace(/["`']/g, '').trim(); + // Clean up: strip ANSI / control sequences via the shared helper + // (same security concern as the sentence-case path — the title renders + // directly in the picker), then take the first line and drop quotes. + const cleaned = stripTerminalControlSequences(text) + .split('\n')[0] + .replace(/["`']/g, '') + .trim(); return cleaned.length > 0 && cleaned.length <= MAX_TITLE_LENGTH ? cleaned : null; @@ -89,12 +105,88 @@ async function generateSessionTitle( } } +/** + * Translate a title-generation failure reason into a human-actionable + * message. Exists so `/rename --auto` doesn't collapse to a generic "could + * not generate" that leaves the user guessing about the cause. + */ +function autoFailureMessage(reason: SessionTitleFailureReason): string { + switch (reason) { + case 'no_fast_model': + return t( + '/rename --auto requires a fast model. Configure one with `/model --fast `.', + ); + case 'empty_history': + return t( + 'No conversation to title yet — send at least one message first.', + ); + case 'empty_result': + return t( + 'The fast model returned no usable title. Try `/rename ` to set one yourself.', + ); + case 'aborted': + return t('Title generation was cancelled.'); + case 'model_error': + return t( + 'The fast model could not generate a title (rate limit, auth, or network error). Check debug log or try again.', + ); + case 'no_client': + return t('Session is still initializing — try again in a moment.'); + default: + return t('Could not generate a title.'); + } +} + +/** + * Parse `--auto` out of the args. Kept simple rather than bringing in an + * argv parser — we only have one flag. + * + * Rules: + * - `--auto` (case-insensitive) sets auto=true. + * - `--` terminates flag parsing; everything after is positional, so users + * can legitimately name sessions starting with `--` via `/rename -- --foo`. + * - Any other `--xxx` before `--` bubbles up as `unknownFlag` for a clean + * error, rather than silently becoming part of the title (`--Auto` typo, + * `--help` expectation, etc.). + */ +function parseArgs(raw: string): { + auto: boolean; + positional: string; + unknownFlag?: string; +} { + const trimmed = raw.trim().replace(/[\r\n]+/g, ' '); + if (!trimmed) return { auto: false, positional: '' }; + const parts = trimmed.split(/\s+/); + let auto = false; + let unknownFlag: string | undefined; + let flagsDone = false; + const rest: string[] = []; + for (const p of parts) { + if (!flagsDone && p === '--') { + flagsDone = true; + continue; + } + if (!flagsDone && p.startsWith('--')) { + if (p.toLowerCase() === '--auto') { + auto = true; + continue; + } + if (!unknownFlag) unknownFlag = p; + continue; + } + rest.push(p); + } + return { auto, positional: rest.join(' '), unknownFlag }; +} + export const renameCommand: SlashCommand = { name: 'rename', altNames: ['tag'], kind: CommandKind.BUILT_IN, get description() { - return t('Rename the current conversation'); + return t( + 'Rename the current conversation. --auto lets the fast model pick a title.', + ); }, action: async (context, args): Promise => { const { config } = context.services; @@ -107,10 +199,87 @@ export const renameCommand: SlashCommand = { }; } - let name = args.trim().replace(/[\r\n]+/g, ' '); + const { auto, positional, unknownFlag } = parseArgs(args); + if (unknownFlag) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Unknown flag "{{flag}}". Supported: --auto. To use this as a literal name, run `/rename -- {{flag}}`.', + { flag: unknownFlag }, + ), + }; + } + let name = positional; + // Track where the title came from so the session picker can dim + // auto-generated titles; explicit user text stays 'manual'. + let titleSource: 'auto' | 'manual' = 'manual'; - // If no name provided, auto-generate one from conversation history - if (!name) { + if (auto) { + // Explicit user-triggered auto-title. This overwrites whatever title + // is currently set (manual or auto) because the user asked for it. + // Requires a configured fast model — we don't silently fall back to + // the main model here because `--auto` is a deliberate opt-in to the + // sentence-case fast-model flow, and surprising a user with a main- + // model call would defeat the purpose. + if (!config.getFastModel()) { + return { + type: 'message', + messageType: 'error', + content: t( + '/rename --auto requires a fast model. Configure one with `/model --fast `.', + ), + }; + } + if (positional) { + return { + type: 'message', + messageType: 'error', + content: t( + '/rename --auto does not take a name. Use `/rename ` to set a name yourself.', + ), + }; + } + const dots = ['.', '..', '...']; + let dotIndex = 0; + const baseText = t('Regenerating session title'); + context.ui.setPendingItem({ + type: 'info', + text: baseText + dots[dotIndex], + }); + const timer = setInterval(() => { + dotIndex = (dotIndex + 1) % dots.length; + context.ui.setPendingItem({ + type: 'info', + text: baseText + dots[dotIndex], + }); + }, 500); + // try/finally ensures the spinner stops even if tryGenerateSessionTitle + // ever throws (it currently swallows internally, but defensively so + // future regressions don't leak an interval timer). + let outcome: Awaited>; + try { + outcome = await tryGenerateSessionTitle( + config, + context.abortSignal ?? new AbortController().signal, + ); + } finally { + clearInterval(timer); + context.ui.setPendingItem(null); + } + if (!outcome.ok) { + return { + type: 'message', + messageType: 'error', + content: autoFailureMessage(outcome.reason), + }; + } + name = outcome.title; + titleSource = 'auto'; + } else if (!name) { + // Legacy no-arg behavior: kebab-case, generated via the main content + // generator with fallback to fastModel. Preserved as-is for users who + // prefer filesystem-style names. const dots = ['.', '..', '...']; let dotIndex = 0; const baseText = t('Generating session name'); @@ -125,9 +294,13 @@ export const renameCommand: SlashCommand = { text: baseText + dots[dotIndex], }); }, 500); - const generated = await generateSessionTitle(config, context.abortSignal); - clearInterval(timer); - context.ui.setPendingItem(null); + let generated: string | null; + try { + generated = await generateKebabTitle(config, context.abortSignal); + } finally { + clearInterval(timer); + context.ui.setPendingItem(null); + } if (!generated) { return { type: 'message', @@ -151,7 +324,7 @@ export const renameCommand: SlashCommand = { // Record the custom title in the current session's JSONL file const chatRecordingService = config.getChatRecordingService(); if (chatRecordingService) { - const ok = chatRecordingService.recordCustomTitle(name); + const ok = chatRecordingService.recordCustomTitle(name, titleSource); if (!ok) { return { type: 'message', @@ -163,7 +336,11 @@ export const renameCommand: SlashCommand = { // Fallback: write via SessionService for non-recording sessions const sessionId = config.getSessionId(); const sessionService = config.getSessionService(); - const success = await sessionService.renameSession(sessionId, name); + const success = await sessionService.renameSession( + sessionId, + name, + titleSource, + ); if (!success) { return { type: 'message', diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 263988686c3..c5bda6bea92 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -16,7 +16,6 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { StreamingState, type HistoryItemToolGroup } from '../types.js'; -import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { FeedbackDialog } from '../FeedbackDialog.js'; import { t } from '../../i18n/index.js'; @@ -104,8 +103,6 @@ export const Composer = () => { /> )} - {!uiState.isConfigInitialized && } - {uiState.isFeedbackDialogOpen && } diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 585f47ecec2..c405dbe3eef 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -78,6 +78,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => contextFileNames: [], showToolDescriptions: false, ideContextState: undefined, + isConfigInitialized: true, ...overrides, }) as UIState; @@ -149,6 +150,43 @@ describe('