feat(shell): enable PTY by default and various enhancements

### Shell & Interactive Terminal Improvements
- PTY shell is now enabled by default instead of disabled
- Improved shell output rendering, process termination, and added fallback warning
- Background commands now properly capture subprocess PIDs on non-Windows

### Coding Plan Improvements
- Simplified auth message, added /model tip, improved system info display
- Reordered model list to prioritize glm-5, kimi-k2.5, MiniMax-M2.5
- Model selection is now preserved when updating if the model still exists

### Other Changes
- Added shared symlink utility; debug logs now have latest alias
- Unknown settings warnings go to debug log instead of user-facing warnings
- Fixed subagent confirmation state detection
- Removed debug UI from AgentCreationWizard

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-03-05 11:28:13 +08:00
parent 991ae9febc
commit b48e3caa75
31 changed files with 729 additions and 314 deletions

View file

@ -448,7 +448,7 @@ describe('Settings Loading and Merging', () => {
);
});
it('should warn about unknown top-level keys in a v2 settings file', () => {
it('should silently ignore unknown top-level keys in a v2 settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
@ -466,13 +466,7 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(getSettingsWarnings(settings)).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Unknown setting 'someUnknownKey' will be ignored",
),
]),
);
expect(getSettingsWarnings(settings)).toEqual([]);
});
it('should not warn for valid v2 container keys', () => {

View file

@ -14,6 +14,7 @@ import {
QWEN_DIR,
getErrorMessage,
Storage,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js';
@ -32,6 +33,8 @@ import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import { writeStderrLine } from '../utils/stdioHelpers.js';
const debugLogger = createDebugLogger('SETTINGS');
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
@ -564,7 +567,7 @@ function getSettingsFileKeyWarnings(
);
}
// Unknown top-level keys.
// Unknown top-level keys — log silently to debug output.
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
for (const key of Object.keys(settings)) {
if (key === SETTINGS_VERSION_KEY) {
@ -577,8 +580,8 @@ function getSettingsFileKeyWarnings(
continue;
}
warnings.push(
`Warning: Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
debugLogger.warn(
`Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
);
}

View file

@ -822,9 +822,9 @@ const SETTINGS_SCHEMA = {
label: 'Interactive Shell (PTY)',
category: 'Tools',
requiresRestart: true,
default: false,
default: true,
description:
'Use node-pty for an interactive shell experience. Fallback to child_process still applies.',
'Use node-pty for an interactive shell experience. Falls back to child_process if PTY is unavailable.',
showInDialog: true,
},
pager: {

View file

@ -64,6 +64,42 @@ export function generateCodingPlanTemplate(
contextWindowSize: 1000000,
},
},
{
id: 'glm-5',
name: '[Bailian Coding Plan] glm-5',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
extra_body: {
enable_thinking: true,
},
contextWindowSize: 202752,
},
},
{
id: 'kimi-k2.5',
name: '[Bailian Coding Plan] kimi-k2.5',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
extra_body: {
enable_thinking: true,
},
contextWindowSize: 262144,
},
},
{
id: 'MiniMax-M2.5',
name: '[Bailian Coding Plan] MiniMax-M2.5',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
extra_body: {
enable_thinking: true,
},
contextWindowSize: 1000000,
},
},
{
id: 'qwen3-coder-plus',
name: '[Bailian Coding Plan] qwen3-coder-plus',
@ -106,42 +142,6 @@ export function generateCodingPlanTemplate(
contextWindowSize: 202752,
},
},
{
id: 'glm-5',
name: '[Bailian Coding Plan] glm-5',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
extra_body: {
enable_thinking: true,
},
contextWindowSize: 202752,
},
},
{
id: 'MiniMax-M2.5',
name: '[Bailian Coding Plan] MiniMax-M2.5',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
extra_body: {
enable_thinking: true,
},
contextWindowSize: 1000000,
},
},
{
id: 'kimi-k2.5',
name: '[Bailian Coding Plan] kimi-k2.5',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
extra_body: {
enable_thinking: true,
},
contextWindowSize: 262144,
},
},
];
}

View file

@ -1457,6 +1457,10 @@ export default {
'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).',
'{{region}} configuration updated successfully.':
'{{region}}-Konfiguration erfolgreich aktualisiert.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert.',
'Tip: Use /model to switch between available Coding Plan models.':
'Tipp: Verwenden Sie /model, um zwischen verfügbaren Coding Plan-Modellen zu wechseln.',
};

View file

@ -1446,6 +1446,10 @@ export default {
'New model configurations are available for {{region}}. Update now?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
'{{region}} configuration updated successfully.':
'{{region}} configuration updated successfully.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.',
'Tip: Use /model to switch between available Coding Plan models.':
'Tip: Use /model to switch between available Coding Plan models.',
};

View file

@ -964,6 +964,10 @@ export default {
'{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました(バックアップ済み)。',
'{{region}} configuration updated successfully.':
'{{region}} の設定が正常に更新されました。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
'{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました。',
'Tip: Use /model to switch between available Coding Plan models.':
'ヒント: /model で利用可能な Coding Plan モデルを切り替えられます。',
};

View file

@ -1451,6 +1451,10 @@ export default {
'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).',
'{{region}} configuration updated successfully.':
'Configuração do {{region}} atualizada com sucesso.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json.',
'Tip: Use /model to switch between available Coding Plan models.':
'Dica: Use /model para alternar entre os modelos disponíveis do Coding Plan.',
};

View file

@ -1461,6 +1461,10 @@ export default {
'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).',
'{{region}} configuration updated successfully.':
'Конфигурация {{region}} успешно обновлена.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json.',
'Tip: Use /model to switch between available Coding Plan models.':
'Совет: Используйте /model для переключения между доступными моделями Coding Plan.',
};

View file

@ -1279,6 +1279,9 @@ export default {
'{{region}} 有新的模型配置可用。是否立即更新?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} 配置更新成功。模型已切换至 "{{model}}"。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json已备份。',
'{{region}} configuration updated successfully.': '{{region}} 配置更新成功。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。',
'Tip: Use /model to switch between available Coding Plan models.':
'提示:使用 /model 切换可用的 Coding Plan 模型。',
};

View file

@ -14,9 +14,7 @@ import type {
InsightProgressCallback,
} from '../types/StaticInsightTypes.js';
import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core';
const logger = createDebugLogger('StaticInsightGenerator');
import { updateSymlink, type Config } from '@qwen-code/qwen-code-core';
export class StaticInsightGenerator {
private dataProcessor: DataProcessor;
@ -54,40 +52,12 @@ export class StaticInsightGenerator {
return outputPath;
}
// Create or update the "latest" alias (symlink preferred, copy as fallback)
private async updateLatestAlias(
private async updateInsightSymlink(
outputDir: string,
targetPath: string,
): Promise<void> {
const latestPath = path.join(outputDir, 'insight.html');
const relativeTarget = path.relative(outputDir, targetPath);
// Remove existing file/symlink if it exists
try {
await fs.unlink(latestPath);
} catch {
// File doesn't exist, ignore
}
// Try symlink first (preferred - lightweight, always points to latest)
try {
await fs.symlink(relativeTarget, latestPath);
logger.debug('Created insight symlink:', relativeTarget);
return;
} catch (error) {
logger.debug(
'Failed to create insight symlink, falling back to copy:',
error,
);
}
// Fallback: copy file (works everywhere, uses more disk space)
try {
await fs.copyFile(targetPath, latestPath);
logger.debug('Created insight copy:', targetPath);
} catch (error) {
logger.debug('Failed to create insight latest alias:', error);
}
await updateSymlink(latestPath, targetPath);
}
// Generate the static insight HTML file
@ -116,8 +86,7 @@ export class StaticInsightGenerator {
// Write the HTML file
await fs.writeFile(outputPath, html, 'utf-8');
// Update latest alias (symlink preferred, copy as fallback)
await this.updateLatestAlias(outputDir, outputPath);
await this.updateInsightSymlink(outputDir, outputPath);
return outputPath;
}

View file

@ -389,13 +389,24 @@ export const useAuthCommand = (
{
type: MessageType.INFO,
text: t(
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.',
{ region: t('Alibaba Cloud Coding Plan') },
),
},
Date.now(),
);
// Hint about /model command
addItem(
{
type: MessageType.INFO,
text: t(
'Tip: Use /model to switch between available Coding Plan models.',
),
},
Date.now(),
);
// Log success
const authEvent = new AuthEvent(
AuthType.USE_OPENAI,

View file

@ -11,7 +11,7 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -32,7 +32,7 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -53,7 +53,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode true* │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -74,7 +74,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false* │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false* │
│ ▼ │
@ -95,7 +95,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode (Modified in System) false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -116,7 +116,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode (Modified in Workspace) false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -137,7 +137,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -158,7 +158,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false* │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -179,7 +179,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -200,7 +200,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode true* │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE true* │
│ ▼ │

View file

@ -120,45 +120,6 @@ export function AgentCreationWizard({
);
}, [state.currentStep, state.generationMethod]);
const renderDebugContent = useCallback(() => {
if (process.env['NODE_ENV'] !== 'development') {
return null;
}
return (
<Box borderStyle="single" borderColor={theme.status.warning} padding={1}>
<Box flexDirection="column">
<Text color={theme.status.warning} bold>
Debug Info:
</Text>
<Text color={theme.text.secondary}>Step: {state.currentStep}</Text>
<Text color={theme.text.secondary}>
Can Proceed: {state.canProceed ? 'Yes' : 'No'}
</Text>
<Text color={theme.text.secondary}>
Generating: {state.isGenerating ? 'Yes' : 'No'}
</Text>
<Text color={theme.text.secondary}>Location: {state.location}</Text>
<Text color={theme.text.secondary}>
Method: {state.generationMethod}
</Text>
{state.validationErrors.length > 0 && (
<Text color={theme.status.error}>
Errors: {state.validationErrors.join(', ')}
</Text>
)}
</Box>
</Box>
);
}, [
state.currentStep,
state.canProceed,
state.isGenerating,
state.location,
state.generationMethod,
state.validationErrors,
]);
const renderStepFooter = useCallback(() => {
const getNavigationInstructions = () => {
// Special case: During generation in description input step, only show cancel option
@ -331,7 +292,6 @@ export function AgentCreationWizard({
>
{renderStepHeader()}
{renderStepContent()}
{renderDebugContent()}
{renderStepFooter()}
</Box>
</Box>

View file

@ -481,6 +481,111 @@ describe('useCodingPlanUpdates', () => {
).toBe(true);
});
it('should show "model preserved" message when current model exists in new template', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'qwen3.5-plus',
baseUrl: chinaConfig.baseUrl,
envKey: CODING_PLAN_ENV_KEY,
},
],
};
// Simulate the user's current model being one that exists in the new template
mockConfig.getModel.mockReturnValue('qwen3.5-plus');
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
await waitFor(() => {
expect(mockSettings.setValue).toHaveBeenCalled();
});
// Should show plain success message without "switched"
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining('updated successfully'),
}),
expect.any(Number),
);
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining('switched'),
}),
expect.any(Number),
);
// Reset mock
mockConfig.getModel.mockReturnValue('qwen-max');
});
it('should show "model switched" message when current model is not in new template', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'removed-model',
baseUrl: chinaConfig.baseUrl,
envKey: CODING_PLAN_ENV_KEY,
},
],
};
// The user's current model no longer exists in the new template
mockConfig.getModel.mockReturnValue('removed-model');
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
await waitFor(() => {
expect(mockSettings.setValue).toHaveBeenCalled();
});
// Should show "model switched" message
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining('switched'),
}),
expect.any(Number),
);
// Reset mock
mockConfig.getModel.mockReturnValue('qwen-max');
});
it('should handle update errors gracefully', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,

View file

@ -42,6 +42,7 @@ export function useCodingPlanUpdates(
/**
* Execute the Coding Plan configuration update.
* Removes old Coding Plan configs and replaces them with new ones from the template.
* Preserves the user's current model selection if it still exists in the new template.
* Uses the region from settings.codingPlan.region (defaults to CHINA).
*/
const executeUpdate = useCallback(
@ -82,6 +83,12 @@ export function useCodingPlanUpdates(
...(nonCodingPlanConfigs as Array<Record<string, unknown>>),
] as Array<Record<string, unknown>>;
// Record the user's current model before the update
const previousModel = config.getModel();
const previousModelStillAvailable = newConfigs.some(
(cfg) => cfg.id === previousModel,
);
// Hot-reload model providers configuration first (in-memory only)
const updatedModelProviders = {
...(settings.merged.modelProviders as
@ -112,12 +119,34 @@ export function useCodingPlanUpdates(
const activeModel = config.getModel();
if (previousModelStillAvailable && activeModel === previousModel) {
addItem(
{
type: 'info',
text: t('{{region}} configuration updated successfully.', {
region: t('Alibaba Cloud Coding Plan'),
}),
},
Date.now(),
);
} else {
addItem(
{
type: 'info',
text: t(
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
{ region: t('Alibaba Cloud Coding Plan'), model: activeModel },
),
},
Date.now(),
);
}
addItem(
{
type: 'info',
text: t(
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
{ region: t('Alibaba Cloud Coding Plan'), model: activeModel },
'Tip: Use /model to switch between available Coding Plan models.',
),
},
Date.now(),

View file

@ -59,6 +59,7 @@ import {
type TrackedToolCall,
type TrackedCompletedToolCall,
type TrackedCancelledToolCall,
type TrackedExecutingToolCall,
type TrackedWaitingToolCall,
} from './useReactToolScheduler.js';
import { promises as fs } from 'node:fs';
@ -358,6 +359,23 @@ export const useGeminiStream = (
if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) {
return StreamingState.WaitingForConfirmation;
}
// Check if any executing subagent task has a pending confirmation
if (
toolCalls.some((tc) => {
if (tc.status !== 'executing') return false;
const liveOutput = (tc as TrackedExecutingToolCall).liveOutput;
return (
typeof liveOutput === 'object' &&
liveOutput !== null &&
'type' in liveOutput &&
liveOutput.type === 'task_execution' &&
'pendingConfirmation' in liveOutput &&
liveOutput.pendingConfirmation != null
);
})
) {
return StreamingState.WaitingForConfirmation;
}
if (
isResponding ||
toolCalls.some(

View file

@ -38,6 +38,7 @@ export interface SystemInfo {
export interface ExtendedSystemInfo extends SystemInfo {
memoryUsage: string;
baseUrl?: string;
apiKeyEnvKey?: string;
gitCommit?: string;
proxy?: string;
}
@ -154,12 +155,14 @@ export async function getExtendedSystemInfo(
// For bug reports, use sandbox name without prefix
const sandboxEnv = getSandboxEnv(true);
// Get base URL if using OpenAI auth
const baseUrl =
// Get base URL and apiKeyEnvKey if using OpenAI or Anthropic auth
const contentGeneratorConfig =
baseInfo.selectedAuthType === AuthType.USE_OPENAI ||
baseInfo.selectedAuthType === AuthType.USE_ANTHROPIC
? context.services.config?.getContentGeneratorConfig()?.baseUrl
? context.services.config?.getContentGeneratorConfig()
: undefined;
const baseUrl = contentGeneratorConfig?.baseUrl;
const apiKeyEnvKey = contentGeneratorConfig?.apiKeyEnvKey;
// Get git commit info
const gitCommit =
@ -172,6 +175,7 @@ export async function getExtendedSystemInfo(
sandboxEnv,
memoryUsage,
baseUrl,
apiKeyEnvKey,
gitCommit,
};
}

View file

@ -6,6 +6,7 @@
import type { ExtendedSystemInfo } from './systemInfo.js';
import { t } from '../i18n/index.js';
import { isCodingPlanConfig } from '../constants/codingPlan.js';
/**
* Field configuration for system information display
@ -30,6 +31,7 @@ export function getSystemInfoFields(
addField(fields, t('IDE Client'), info.ideClient);
addField(fields, t('OS'), formatOs(info));
addField(fields, t('Auth'), formatAuth(info));
addField(fields, t('Base URL'), formatBaseUrl(info));
addField(fields, t('Model'), info.modelVersion);
addField(fields, t('Session ID'), info.sessionId);
addField(fields, t('Sandbox'), info.sandboxEnv);
@ -86,15 +88,34 @@ function formatAuth(info: ExtendedSystemInfo): string {
if (!info.selectedAuthType) {
return '';
}
const authType = formatAuthType(info.selectedAuthType);
if (!info.baseUrl) {
return authType;
if (isCodingPlanConfig(info.baseUrl, info.apiKeyEnvKey)) {
return t('Alibaba Cloud Coding Plan');
}
return `${authType} (${info.baseUrl})`;
if (
info.selectedAuthType.startsWith('oauth') ||
info.selectedAuthType === 'qwen-oauth'
) {
return 'Qwen OAuth';
}
return `API Key - ${info.selectedAuthType}`;
}
function formatAuthType(authType: string): string {
return authType.startsWith('oauth') ? 'OAuth' : authType;
function formatBaseUrl(info: ExtendedSystemInfo): string {
if (!info.selectedAuthType || !info.baseUrl) {
return '';
}
if (
info.selectedAuthType.startsWith('oauth') ||
info.selectedAuthType === 'qwen-oauth'
) {
return '';
}
return info.baseUrl;
}
function formatProxy(proxy?: string): string {

View file

@ -617,7 +617,7 @@ export class Config {
this.webSearch = params.webSearch;
this.useRipgrep = params.useRipgrep ?? true;
this.useBuiltinRipgrep = params.useBuiltinRipgrep ?? true;
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? true;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
this.shellExecutionConfig = {
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,

View file

@ -74,6 +74,7 @@ export * from './utils/paths.js';
export * from './utils/schemaValidator.js';
export * from './utils/errors.js';
export * from './utils/debugLogger.js';
export * from './utils/symlink.js';
export * from './utils/getFolderStructure.js';
export * from './utils/memoryDiscovery.js';
export * from './utils/gitIgnoreParser.js';
@ -287,6 +288,7 @@ export * from './utils/tool-utils.js';
export * from './utils/workspaceContext.js';
export * from './utils/yaml-parser.js';
export * from './utils/jsonl-utils.js';
export * from './utils/symlink.js';
// ============================================================================
// OAuth & Authentication

View file

@ -19,6 +19,13 @@ const mockIsBinary = vi.hoisted(() => vi.fn());
const mockPlatform = vi.hoisted(() => vi.fn());
const mockGetPty = vi.hoisted(() => vi.fn());
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
const mockGetShellConfiguration = vi.hoisted(() =>
vi.fn().mockReturnValue({
executable: 'bash',
argsPrefix: ['-c'],
shell: 'bash',
}),
);
// Top-level Mocks
vi.mock('@lydell/node-pty', () => ({
@ -54,6 +61,9 @@ vi.mock('../utils/getPty.js', () => ({
vi.mock('../utils/terminalSerializer.js', () => ({
serializeTerminalToObject: mockSerializeTerminalToObject,
}));
vi.mock('../utils/shell-utils.js', () => ({
getShellConfiguration: mockGetShellConfiguration,
}));
const mockProcessKill = vi
.spyOn(process, 'kill')
@ -410,15 +420,25 @@ describe('ShellExecutionService', () => {
describe('Platform-Specific Behavior', () => {
it('should use cmd.exe on Windows', async () => {
mockPlatform.mockReturnValue('win32');
mockGetShellConfiguration.mockReturnValue({
executable: 'cmd.exe',
argsPrefix: ['/d', '/s', '/c'],
shell: 'cmd',
});
await simulateExecution('dir "foo bar"', (pty) =>
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
);
expect(mockPtySpawn).toHaveBeenCalledWith(
'cmd.exe',
'/c dir "foo bar"',
['/d', '/s', '/c', 'dir "foo bar"'],
expect.any(Object),
);
mockGetShellConfiguration.mockReturnValue({
executable: 'bash',
argsPrefix: ['-c'],
shell: 'bash',
});
});
it('should use bash on Linux', async () => {
@ -822,18 +842,28 @@ describe('ShellExecutionService child_process fallback', () => {
describe('Platform-Specific Behavior', () => {
it('should use cmd.exe and hide window on Windows', async () => {
mockPlatform.mockReturnValue('win32');
mockGetShellConfiguration.mockReturnValue({
executable: 'cmd.exe',
argsPrefix: ['/d', '/s', '/c'],
shell: 'cmd',
});
await simulateExecution('dir "foo bar"', (cp) =>
cp.emit('exit', 0, null),
);
expect(mockCpSpawn).toHaveBeenCalledWith(
'cmd.exe',
['/c', 'dir "foo bar"'],
['/d', '/s', '/c', 'dir "foo bar"'],
expect.objectContaining({
detached: false,
windowsHide: true,
}),
);
mockGetShellConfiguration.mockReturnValue({
executable: 'bash',
argsPrefix: ['-c'],
shell: 'bash',
});
});
it('should use bash and detached process group on Linux', async () => {

View file

@ -13,6 +13,7 @@ import os from 'node:os';
import type { IPty } from '@lydell/node-pty';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import { isBinary } from '../utils/textUtils.js';
import { getShellConfiguration } from '../utils/shell-utils.js';
import pkg from '@xterm/headless';
import {
serializeTerminalToObject,
@ -223,14 +224,12 @@ export class ShellExecutionService {
): ShellExecutionHandle {
try {
const isWindows = os.platform() === 'win32';
const shell = isWindows ? 'cmd.exe' : 'bash';
const shellArgs = isWindows
? ['/c', commandToExecute]
: ['-c', commandToExecute];
const { executable, argsPrefix } = getShellConfiguration();
const shellArgs = [...argsPrefix, commandToExecute];
// Note: CodeQL flags this as js/shell-command-injection-from-environment.
// This is intentional - CLI tool executes user-provided shell commands.
const child = cpSpawn(shell, shellArgs, {
const child = cpSpawn(executable, shellArgs, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: isWindows,
@ -419,13 +418,10 @@ export class ShellExecutionService {
try {
const cols = shellExecutionConfig.terminalWidth ?? 80;
const rows = shellExecutionConfig.terminalHeight ?? 30;
const isWindows = os.platform() === 'win32';
const shell = isWindows ? 'cmd.exe' : 'bash';
const args = isWindows
? `/c ${commandToExecute}`
: ['-c', commandToExecute];
const { executable, argsPrefix } = getShellConfiguration();
const args = [...argsPrefix, commandToExecute];
const ptyProcess = ptyInfo.module.spawn(shell, args, {
const ptyProcess = ptyInfo.module.spawn(executable, args, {
cwd,
name: 'xterm',
cols,
@ -435,6 +431,7 @@ export class ShellExecutionService {
QWEN_CODE: '1',
TERM: 'xterm-256color',
PAGER: shellExecutionConfig.pager ?? 'cat',
GIT_PAGER: shellExecutionConfig.pager ?? 'cat',
},
handleFlowControl: true,
});
@ -463,85 +460,107 @@ export class ShellExecutionService {
let hasStartedOutput = false;
let renderTimeout: NodeJS.Timeout | null = null;
const render = (finalRender = false) => {
if (renderTimeout) {
clearTimeout(renderTimeout);
const RENDER_THROTTLE_MS = 100;
const renderFn = () => {
if (!isStreamingRawContent) {
return;
}
const renderFn = () => {
if (!isStreamingRawContent) {
return;
}
if (!shellExecutionConfig.disableDynamicLineTrimming) {
if (!hasStartedOutput) {
const bufferText = getFullBufferText(headlessTerminal);
if (bufferText.trim().length === 0) {
return;
}
hasStartedOutput = true;
if (!shellExecutionConfig.disableDynamicLineTrimming) {
if (!hasStartedOutput) {
const bufferText = getFullBufferText(headlessTerminal);
if (bufferText.trim().length === 0) {
return;
}
hasStartedOutput = true;
}
}
let newOutput: AnsiOutput;
if (shellExecutionConfig.showColor) {
newOutput = serializeTerminalToObject(headlessTerminal);
} else {
const buffer = headlessTerminal.buffer.active;
const lines: AnsiOutput = [];
for (let y = 0; y < headlessTerminal.rows; y++) {
const line = buffer.getLine(buffer.viewportY + y);
const lineContent = line ? line.translateToString(true) : '';
lines.push([
{
text: lineContent,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
fg: '',
bg: '',
},
]);
}
newOutput = lines;
}
let lastNonEmptyLine = -1;
for (let i = newOutput.length - 1; i >= 0; i--) {
const line = newOutput[i];
if (
line
.map((segment) => segment.text)
.join('')
.trim().length > 0
) {
lastNonEmptyLine = i;
break;
}
}
const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
const finalOutput = shellExecutionConfig.disableDynamicLineTrimming
? newOutput
: trimmedOutput;
// Using stringify for a quick deep comparison.
if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
output = finalOutput;
onOutputEvent({
type: 'data',
chunk: finalOutput,
});
}
};
if (finalRender) {
renderFn();
let newOutput: AnsiOutput;
if (shellExecutionConfig.showColor) {
newOutput = serializeTerminalToObject(headlessTerminal);
} else {
renderTimeout = setTimeout(renderFn, 17);
const buffer = headlessTerminal.buffer.active;
const lines: AnsiOutput = [];
for (let y = 0; y < headlessTerminal.rows; y++) {
const line = buffer.getLine(buffer.viewportY + y);
const lineContent = line ? line.translateToString(true) : '';
lines.push([
{
text: lineContent,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
fg: '',
bg: '',
},
]);
}
newOutput = lines;
}
let lastNonEmptyLine = -1;
for (let i = newOutput.length - 1; i >= 0; i--) {
const line = newOutput[i];
if (
line
.map((segment) => segment.text)
.join('')
.trim().length > 0
) {
lastNonEmptyLine = i;
break;
}
}
const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
const finalOutput = shellExecutionConfig.disableDynamicLineTrimming
? newOutput
: trimmedOutput;
// Using stringify for a quick deep comparison.
if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
output = finalOutput;
onOutputEvent({
type: 'data',
chunk: finalOutput,
});
}
};
// Throttle: render immediately on first call, then at most
// once per RENDER_THROTTLE_MS during continuous output.
// A trailing render is scheduled to ensure the final state
// is always displayed.
let pendingTrailingRender = false;
const render = (finalRender = false) => {
if (finalRender) {
if (renderTimeout) {
clearTimeout(renderTimeout);
renderTimeout = null;
}
renderFn();
return;
}
if (!renderTimeout) {
// No active throttle — render now and start throttle window
renderFn();
renderTimeout = setTimeout(() => {
renderTimeout = null;
if (pendingTrailingRender) {
pendingTrailingRender = false;
render();
}
}, RENDER_THROTTLE_MS);
} else {
// Throttled — mark that we need a trailing render
pendingTrailingRender = true;
}
};
@ -610,7 +629,7 @@ export class ShellExecutionService {
abortSignal.removeEventListener('abort', abortHandler);
this.activePtys.delete(ptyProcess.pid);
processingChain.then(() => {
const finalize = () => {
render(true);
const finalBuffer = Buffer.concat(outputChunks);
@ -626,6 +645,18 @@ export class ShellExecutionService {
(ptyInfo?.name as 'node-pty' | 'lydell-node-pty') ??
'node-pty',
});
};
// Always try to flush pending terminal writes before
// finalizing so result.output is as complete as possible.
// Race against abort or a short timeout to avoid hanging.
const processingComplete = processingChain.then(() => 'processed');
const deadline = new Promise<'timeout'>((res) =>
setTimeout(() => res('timeout'), SIGKILL_TIMEOUT_MS),
);
void Promise.race([processingComplete, deadline]).then(() => {
finalize();
});
},
);
@ -636,11 +667,18 @@ export class ShellExecutionService {
ptyProcess.kill();
} else {
try {
// Kill the entire process group
process.kill(-ptyProcess.pid, 'SIGINT');
// Send SIGTERM first to allow graceful shutdown
process.kill(-ptyProcess.pid, 'SIGTERM');
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
if (!exited) {
// Escalate to SIGKILL if still running
process.kill(-ptyProcess.pid, 'SIGKILL');
}
} catch (_e) {
// Fallback to killing just the process if the group kill fails
ptyProcess.kill('SIGINT');
if (!exited) {
ptyProcess.kill();
}
}
}
}
@ -652,19 +690,28 @@ export class ShellExecutionService {
return { pid: ptyProcess.pid, result };
} catch (e) {
const error = e as Error;
return {
pid: undefined,
result: Promise.resolve({
error,
rawOutput: Buffer.from(''),
output: '',
exitCode: 1,
signal: null,
aborted: false,
if (error.message.includes('posix_spawnp failed')) {
onOutputEvent({
type: 'data',
chunk:
'[WARNING] PTY execution failed, falling back to child_process. This may be due to sandbox restrictions.\n',
});
throw e;
} else {
return {
pid: undefined,
executionMethod: 'none',
}),
};
result: Promise.resolve({
error,
rawOutput: Buffer.from(''),
output: '',
exitCode: 1,
signal: null,
aborted: false,
pid: undefined,
executionMethod: 'none',
}),
};
}
}
}

View file

@ -268,10 +268,10 @@ describe('ShellTool', () => {
resolveExecutionPromise(fullResult);
};
it('should wrap command on linux and parse pgrep output', async () => {
it('should wrap background command on linux and parse pgrep output', async () => {
const invocation = shellTool.build({
command: 'my-command &',
is_background: false,
command: 'my-command',
is_background: true,
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({ pid: 54321 });
@ -291,7 +291,7 @@ describe('ShellTool', () => {
false,
{},
);
expect(result.llmContent).toContain('Background PIDs: 54322');
expect(result.llmContent).toContain('PIDs: 54322');
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
});
@ -353,15 +353,11 @@ describe('ShellTool', () => {
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({ pid: 54321 });
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `{ npm test; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
// Foreground commands should not be wrapped with pgrep
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
'npm test',
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
@ -383,10 +379,9 @@ describe('ShellTool', () => {
resolveShellExecution();
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
// Foreground commands should not be wrapped with pgrep
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
'ls',
'/test/dir/subdir',
expect.any(Function),
expect.any(AbortSignal),
@ -733,7 +728,6 @@ describe('ShellTool', () => {
await promise;
// On Linux, commands are wrapped with pgrep functionality
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining('npm install'),
expect.any(String),
@ -762,7 +756,6 @@ describe('ShellTool', () => {
await promise;
// On Linux, commands are wrapped with pgrep functionality
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining('git commit'),
expect.any(String),
@ -828,7 +821,6 @@ describe('ShellTool', () => {
await promise;
// On Linux, commands are wrapped with pgrep functionality
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining('git commit -m "Initial commit"'),
expect.any(String),

View file

@ -181,15 +181,16 @@ export class ShellToolInvocation extends BaseToolInvocation<
finalCommand = finalCommand.trim().replace(/&+$/, '').trim();
}
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = isWindows
? finalCommand
: (() => {
// wrap command to append subprocess pids (via pgrep) to temporary file
let command = finalCommand.trim();
if (!command.endsWith('&')) command += ';';
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
})();
// On non-Windows background commands, wrap with pgrep to capture
// subprocess PIDs so we can report them to the user.
const commandToExecute =
!isWindows && shouldRunInBackground
? (() => {
let command = finalCommand.trim();
if (!command.endsWith('&')) command += ';';
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
})()
: finalCommand;
const cwd = this.params.directory || this.config.getTargetDir();
@ -240,7 +241,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
},
combinedSignal,
this.config.getShouldUseNodePtyShell(),
shouldRunInBackground
? false
: this.config.getShouldUseNodePtyShell(),
shellExecutionConfig ?? {},
);
@ -248,14 +251,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
setPidCallback(pid);
}
if (shouldRunInBackground) {
// For background tasks, return immediately with PID info
// Note: We cannot reliably detect startup errors for background processes
// since their stdio is typically detached/ignored
// On Windows, background commands rely on early return since there's
// no & backgrounding or pgrep. Awaiting would block until completion.
if (shouldRunInBackground && isWindows) {
const pidMsg = pid ? ` PID: ${pid}` : '';
const killHint = isWindows
? ' (Use taskkill /F /T /PID <pid> to stop)'
: ' (Use kill <pid> to stop)';
const killHint = ' (Use taskkill /F /T /PID <pid> to stop)';
return {
llmContent: `Background command started.${pidMsg}${killHint}`,
@ -265,27 +265,42 @@ export class ShellToolInvocation extends BaseToolInvocation<
const result = await resultPromise;
const backgroundPIDs: number[] = [];
if (os.platform() !== 'win32') {
if (fs.existsSync(tempFilePath)) {
const pgrepLines = fs
.readFileSync(tempFilePath, 'utf8')
.split(EOL)
.filter(Boolean);
for (const line of pgrepLines) {
if (!/^\d+$/.test(line)) {
debugLogger.warn(`pgrep: ${line}`);
if (shouldRunInBackground) {
// Read subprocess PIDs captured by the pgrep wrapper (non-Windows only)
const backgroundPIDs: number[] = [];
if (!isWindows) {
if (fs.existsSync(tempFilePath)) {
const pgrepLines = fs
.readFileSync(tempFilePath, 'utf8')
.split(EOL)
.filter(Boolean);
for (const line of pgrepLines) {
if (!/^\d+$/.test(line)) {
debugLogger.warn(`pgrep: ${line}`);
continue;
}
const bgPid = Number(line);
if (bgPid !== result.pid) {
backgroundPIDs.push(bgPid);
}
}
const pid = Number(line);
if (pid !== result.pid) {
backgroundPIDs.push(pid);
}
}
} else {
if (!signal.aborted) {
} else if (!signal.aborted) {
debugLogger.warn('missing pgrep output');
}
}
const bgPidMsg =
backgroundPIDs.length > 0
? ` PIDs: ${backgroundPIDs.join(', ')}`
: pid
? ` PID: ${pid}`
: '';
const killHint = ' (Use kill <pid> to stop)';
return {
llmContent: `Background command started.${bgPidMsg}${killHint}`,
returnDisplay: `Background command started.${bgPidMsg}${killHint}`,
};
}
let llmContent = '';
@ -327,9 +342,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
`Error: ${finalError}`, // Use the cleaned error string.
`Exit Code: ${result.exitCode ?? '(none)'}`,
`Signal: ${result.signal ?? '(none)'}`,
`Background PIDs: ${
backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)'
}`,
`Process Group PGID: ${result.pid ?? '(none)'}`,
].join('\n');
}

View file

@ -13,6 +13,7 @@ import {
type DebugLogSession,
} from './debugLogger.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { Storage } from '../config/storage.js';
vi.mock('node:fs', async (importOriginal) => {
@ -23,6 +24,9 @@ vi.mock('node:fs', async (importOriginal) => {
...actual.promises,
mkdir: vi.fn().mockResolvedValue(undefined),
appendFile: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn().mockResolvedValue(undefined),
symlink: vi.fn().mockResolvedValue(undefined),
copyFile: vi.fn().mockResolvedValue(undefined),
},
};
});
@ -154,6 +158,7 @@ describe('debugLogger', () => {
});
it('returns true when mkdir fails', async () => {
resetDebugLoggingState();
vi.mocked(fs.mkdir).mockRejectedValueOnce(new Error('Permission denied'));
const logger = createDebugLogger();
@ -196,6 +201,55 @@ describe('debugLogger', () => {
});
});
describe('latest debug log symlink', () => {
const expectedLatestPath = path.join(Storage.getGlobalDebugDir(), 'latest');
it('creates a symlink to the current session log file', async () => {
resetDebugLoggingState();
setDebugLogSession(mockSession);
await vi.runAllTimersAsync();
expect(fs.unlink).toHaveBeenCalledWith(expectedLatestPath);
expect(fs.symlink).toHaveBeenCalledWith(
'test-session-123.txt',
expectedLatestPath,
);
});
it('does not create symlink when session is cleared', async () => {
vi.clearAllMocks();
resetDebugLoggingState();
setDebugLogSession(null);
await vi.runAllTimersAsync();
expect(fs.symlink).not.toHaveBeenCalled();
});
it('does not fall back to copy when symlink fails', async () => {
resetDebugLoggingState();
vi.mocked(fs.symlink).mockRejectedValueOnce(new Error('EPERM'));
setDebugLogSession(mockSession);
await vi.runAllTimersAsync();
expect(fs.copyFile).not.toHaveBeenCalled();
});
it('does not create symlink when debug logging is disabled', async () => {
process.env['QWEN_DEBUG_LOG_FILE'] = '0';
vi.clearAllMocks();
resetDebugLoggingState();
setDebugLogSession(mockSession);
await vi.runAllTimersAsync();
expect(fs.symlink).not.toHaveBeenCalled();
});
});
describe('resetDebugLoggingState', () => {
it('resets the degraded state', async () => {
vi.mocked(fs.appendFile).mockRejectedValueOnce(new Error('Disk full'));

View file

@ -5,9 +5,11 @@
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { AsyncLocalStorage } from 'node:async_hooks';
import util from 'node:util';
import { Storage } from '../config/storage.js';
import { updateSymlink } from './symlink.js';
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
@ -115,6 +117,23 @@ export function resetDebugLoggingState(): void {
ensureDebugDirPromise = null;
}
const DEBUG_LATEST_ALIAS = 'latest';
function updateLatestDebugLogAlias(sessionId: string): void {
if (!isDebugLogFileEnabled()) {
return;
}
const aliasPath = path.join(Storage.getGlobalDebugDir(), DEBUG_LATEST_ALIAS);
const targetPath = Storage.getDebugLogPath(sessionId);
void ensureDebugDirExists()
.then(() => updateSymlink(aliasPath, targetPath, { fallbackCopy: false }))
.catch(() => {
// Best-effort; don't degrade overall logging
});
}
/**
* Sets the process-wide debug log session used by createDebugLogger().
*
@ -125,6 +144,9 @@ export function setDebugLogSession(
session: DebugLogSession | null | undefined,
) {
globalSession = session ?? null;
if (session) {
updateLatestDebugLogAlias(session.getSessionId());
}
}
/**

View file

@ -0,0 +1,60 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
export interface UpdateSymlinkOptions {
/**
* When true, falls back to copying the file if symlinks are not
* available (e.g. Windows without elevated privileges).
* Disable this for targets that keep changing after creation (like log
* files) where a one-time copy would be immediately stale.
*
* @default true
*/
fallbackCopy?: boolean;
}
/**
* Create or replace a symlink at {@link linkPath} pointing to
* {@link targetPath}.
*
* The symlink uses a relative target so it stays valid even when the
* parent directory is moved.
*
* All errors are swallowed the operation is strictly best-effort.
*/
export async function updateSymlink(
linkPath: string,
targetPath: string,
options?: UpdateSymlinkOptions,
): Promise<void> {
const { fallbackCopy = true } = options ?? {};
const linkDir = path.dirname(linkPath);
const relativeTarget = path.relative(linkDir, targetPath);
try {
await fs.unlink(linkPath);
} catch {
// File doesn't exist, ignore
}
try {
await fs.symlink(relativeTarget, linkPath);
return;
} catch {
// Symlink not supported, try fallback
}
if (fallbackCopy) {
try {
await fs.copyFile(targetPath, linkPath);
} catch {
// Best-effort; swallow error
}
}
}

24
test-scripts/heartbeat.sh Executable file
View file

@ -0,0 +1,24 @@
#!/bin/bash
# Heartbeat script for testing interactive shell PTY behavior
# - Emits a numbered line every second
# - Writes to both stdout and a log file for verification
# - Handles SIGTERM/SIGINT gracefully
LOG="/tmp/heartbeat_test.log"
echo "started at $(date)" > "$LOG"
cleanup() {
echo "received signal, shutting down" >> "$LOG"
echo "[heartbeat] shutting down"
exit 0
}
trap cleanup SIGTERM SIGINT
i=1
while true; do
echo "[heartbeat] tick $i ($(date +%H:%M:%S))"
echo "tick $i at $(date +%H:%M:%S)" >> "$LOG"
i=$((i + 1))
sleep 1
done

16
test-scripts/progress.sh Executable file
View file

@ -0,0 +1,16 @@
#!/bin/bash
# Progress bar script that overwrites the same line using \r
# Tests PTY's ability to handle carriage return / cursor movement
total=20
for ((i = 1; i <= total; i++)); do
pct=$((i * 100 / total))
filled=$((pct / 5))
empty=$((20 - filled))
bar=$(printf '%0.s#' $(seq 1 $filled 2>/dev/null))
space=$(printf '%0.s-' $(seq 1 $empty 2>/dev/null))
printf "\r[%s%s] %3d%% (%d/%d)" "$bar" "$space" "$pct" "$i" "$total"
sleep 0.5
done
echo ""
echo "Done!"

19
test-scripts/rapid-output.sh Executable file
View file

@ -0,0 +1,19 @@
#!/bin/bash
# Rapid output script for testing render throttle behavior
# Outputs 200 lines as fast as possible, then pauses, then outputs more
echo "=== Phase 1: Rapid burst (200 lines) ==="
for i in $(seq 1 200); do
echo "line $i: $(date +%H:%M:%S.%N)"
done
echo ""
echo "=== Phase 2: Pause 2s ==="
sleep 2
echo "=== Phase 3: Rapid burst with progress overwrite ==="
for i in $(seq 1 100); do
printf "\rProcessing item %3d/100..." "$i"
done
echo ""
echo "=== Done ==="