From 4d0ceb46fc677cf56959e8818e64eac1a9e03732 Mon Sep 17 00:00:00 2001 From: Activer <8515500@gmail.com> Date: Sun, 7 Jun 2026 10:31:34 +0800 Subject: [PATCH 01/13] fix(cowork): harden external agent cli integration --- .../agentEngine/externalCliRuntimeAdapter.ts | 296 ++++++- src/main/libs/claudeSettings.ts | 120 ++- src/main/libs/coworkOpenAICompatProxy.ts | 441 ++++++++++ src/main/libs/externalAgentConfigSync.ts | 750 +++++++++--------- src/main/libs/externalAgentEnvironment.ts | 144 +++- src/main/libs/externalAgentProviderStore.ts | 48 +- src/main/main.ts | 19 +- src/renderer/components/Settings.tsx | 6 +- .../cowork/AgentEnvironmentSetup.tsx | 6 +- .../cowork/CoworkEngineSelector.tsx | 195 ++++- src/renderer/config.ts | 4 +- src/renderer/services/config.ts | 72 +- src/renderer/services/i18n.ts | 4 +- src/shared/cowork/runtimeMetrics.ts | 20 +- src/shared/providers/constants.ts | 37 +- 15 files changed, 1656 insertions(+), 506 deletions(-) diff --git a/src/main/libs/agentEngine/externalCliRuntimeAdapter.ts b/src/main/libs/agentEngine/externalCliRuntimeAdapter.ts index ae79e926..25a99d75 100644 --- a/src/main/libs/agentEngine/externalCliRuntimeAdapter.ts +++ b/src/main/libs/agentEngine/externalCliRuntimeAdapter.ts @@ -22,8 +22,14 @@ import type { CoworkStore, } from '../../coworkStore'; import { t } from '../../i18n'; -import { type ApiConfigOverride,resolveRawApiConfig } from '../claudeSettings'; +import { type ApiConfigOverride,resolveCodexWesightApiConfig, resolveRawApiConfig } from '../claudeSettings'; import { getElectronNodeRuntimePath, getEnhancedEnvWithTmpdir } from '../coworkUtil'; +import { cleanupWesightManagedCodexConfig } from '../externalAgentConfigSync'; +import { + buildWindowsCommandShimArgs, + isWindowsCommandShim, + resolveCliCommand, +} from '../externalAgentEnvironment'; import { applyLocalClaudeCodeEnvForPrintMode, buildClaudeCodeConfigDiagnostics, @@ -54,6 +60,7 @@ const CLAUDE_NO_CONTENT_NOTICE_MS = 8_000; const CLAUDE_NO_CONTENT_TIMEOUT_MS = 120_000; const CODEX_NO_JSON_NOTICE_MS = 12_000; const CONTENT_TRUNCATED_HINT = '\n...[truncated to prevent memory pressure]'; +const STDERR_LOG_MAX_CHARS = 4_000; const WINDOWS_HIDE_INIT_SCRIPT_NAME = 'external_cli_windows_hide_init.cjs'; const WINDOWS_HIDE_INIT_SCRIPT_CONTENT = [ '\'use strict\';', @@ -127,6 +134,7 @@ const CodexCliEventType = { ResponseItem: 'response_item', EventMessage: 'event_msg', TurnFailed: 'turn.failed', + TurnCompleted: 'turn.completed', } as const; const CodexCliItemType = { @@ -141,9 +149,11 @@ type ActiveCliSession = { child: ChildProcessWithoutNullStreams; sessionId: string; cliSessionId: string | null; + startedAt: number; initialMessageCount: number; assistantMessageId: string | null; assistantContent: string; + assistantOutputStartedLogged: boolean; stderrTail: string; cliErrorMessage: string | null; sawEvent: boolean; @@ -156,6 +166,7 @@ type ActiveCliSession = { localClaudeConfig: LocalClaudeCodeEnvLoadResult | null; configSource: ExternalAgentConfigSource; codexGeneratedImageIds: Set; + completedFromEvent: boolean; }; type ExternalCliRuntimeAdapterDeps = { @@ -168,6 +179,7 @@ type SpawnCommandSpec = { command: string; args: string[]; source: string; + windowsVerbatimArguments?: boolean; }; type AssistantOutputStats = { @@ -176,6 +188,16 @@ type AssistantOutputStats = { bytes: number; }; +type CodexConfigLogSummary = { + source: 'temporary' | 'local'; + configPath: string; + modelProvider: string; + model: string; + providerName: string; + serverUrl: string; + wireApi: string; +}; + const isRecord = (value: unknown): value is Record => { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }; @@ -203,6 +225,14 @@ const firstString = (...values: unknown[]): string | null => { return null; }; +const chmodBestEffort = (targetPath: string, mode: number): void => { + try { + fs.chmodSync(targetPath, mode); + } catch { + // File permissions are a best-effort hardening layer across platforms. + } +}; + const ensureWindowsChildProcessHideInitScript = (): string | null => { if (process.platform !== 'win32') { return null; @@ -312,7 +342,7 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun active.child.kill('SIGTERM'); this.cleanupImagePaths(active.imagePaths); this.cleanupCodexHomeDir(active.codexHomeDir); - this.activeSessions.delete(sessionId); + this.releaseActiveSession(active); } this.store.updateSession(sessionId, { status: 'idle' }); this.emit('sessionStopped', sessionId); @@ -333,6 +363,12 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun return this.activeSessions.has(sessionId); } + private releaseActiveSession(active: ActiveCliSession): void { + if (this.activeSessions.get(active.sessionId) === active) { + this.activeSessions.delete(active.sessionId); + } + } + getSessionConfirmationMode(_sessionId: string): 'modal' | 'text' | null { return null; } @@ -397,6 +433,9 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun if (this.engine === CoworkAgentEngine.ClaudeCode && configSource === ExternalAgentConfigSource.LocalCli) { localClaudeConfig = applyLocalClaudeCodeEnvForPrintMode(env, selectedProvider); } + if (this.engine === CoworkAgentEngine.Codex && configSource === ExternalAgentConfigSource.LocalCli) { + cleanupWesightManagedCodexConfig(); + } if (this.engine === CoworkAgentEngine.ClaudeCode && process.platform === 'win32') { const windowsHideInitScript = ensureWindowsChildProcessHideInitScript(); if (windowsHideInitScript) { @@ -410,7 +449,15 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun this.applyQwenCodeRuntimeConfig(env, apiConfigOverride); } const command = this.getCommandName(); - const codexHomeDir = this.prepareCodexHomeForExecMode(env, selectedProvider, apiConfigOverride); + let codexHomeDir: string | null; + try { + codexHomeDir = this.prepareCodexHomeForExecMode(env, selectedProvider, apiConfigOverride); + } catch (error) { + this.cleanupImagePaths(imagePaths); + const message = error instanceof Error ? error.message : 'Failed to prepare Codex CLI configuration.'; + this.handleError(sessionId, message); + return; + } const args = this.buildCommandArgs( cwd, effectivePrompt, @@ -421,12 +468,13 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun apiConfigOverride, claudeCodePermissionMode, ); - const spawnSpec = this.resolveSpawnCommandSpec(command, args, env); + const spawnSpec = await this.resolveSpawnCommandSpec(command, args, env); if (this.engine === CoworkAgentEngine.ClaudeCode) { console.log('[ExternalCliRuntimeAdapter] starting Claude Code CLI.', { command: spawnSpec.command, cwd, configSource, + spawnSource: spawnSpec.source, localConfig: this.describeLocalClaudeConfig(localClaudeConfig, configSource), permissionMode: claudeCodePermissionMode, baseUrl: env.ANTHROPIC_BASE_URL || '(not set)', @@ -447,11 +495,14 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun ); } if (this.engine === CoworkAgentEngine.Codex) { + const codexConfig = this.summarizeCodexConfigForLog(env, codexHomeDir); console.log('[ExternalCliRuntimeAdapter] starting Codex CLI.', { command: spawnSpec.command, cwd, configSource, usesTemporaryCodexHome: Boolean(codexHomeDir), + codexServerUrl: codexConfig.serverUrl, + codexConfig, proxyEnv: this.summarizeProxyEnv(env), spawnSource: spawnSpec.source, argsWithoutPrompt: spawnSpec.args.slice(0, -1), @@ -463,15 +514,18 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: process.platform === 'win32', + windowsVerbatimArguments: spawnSpec.windowsVerbatimArguments, }); const active: ActiveCliSession = { child, sessionId, cliSessionId: currentSession?.claudeSessionId ?? null, + startedAt: Date.now(), initialMessageCount: currentSession?.messages.length ?? 0, assistantMessageId: null, assistantContent: '', + assistantOutputStartedLogged: false, stderrTail: '', cliErrorMessage: null, sawEvent: false, @@ -484,6 +538,7 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun localClaudeConfig, configSource, codexGeneratedImageIds: new Set(), + completedFromEvent: false, }; active.startupTimer = setTimeout(() => { if (active.sawEvent) return; @@ -521,7 +576,7 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun this.clearSessionTimers(active); this.cleanupImagePaths(active.imagePaths); this.cleanupCodexHomeDir(active.codexHomeDir); - this.activeSessions.delete(sessionId); + this.releaseActiveSession(active); this.handleError(sessionId, `${this.getEngineDisplayName()} failed to start: ${error.message}`); resolve(); }); @@ -536,9 +591,14 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun this.finalizeAssistant(active); this.cleanupImagePaths(active.imagePaths); this.cleanupCodexHomeDir(active.codexHomeDir); - this.activeSessions.delete(sessionId); + this.releaseActiveSession(active); this.logCliProcessFinished(active, code, signal); + if (active.completedFromEvent) { + resolve(); + return; + } + if (this.stoppedSessions.has(sessionId)) { this.store.updateSession(sessionId, { status: 'idle' }); this.emit('sessionStopped', sessionId); @@ -594,7 +654,7 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun if (this.engine !== CoworkAgentEngine.Codex) return false; if (code === 0) return false; if (!active.cliSessionId) return false; - if (active.assistantMessageId) return false; + if (active.assistantContent.trim()) return false; const stderr = active.stderrTail.toLowerCase(); return stderr.includes('thread/resume') && ( @@ -603,6 +663,23 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun ); } + private completeCodexSessionFromEvent(active: ActiveCliSession): void { + if (active.completedFromEvent) return; + if (this.store.getSession(active.sessionId)?.status === 'error') return; + active.completedFromEvent = true; + this.clearSessionTimers(active); + this.finalizeAssistant(active); + this.addCodexGeneratedImagesFromDirectory(active); + if (!this.hasVisibleOutput(active)) { + this.replaceAssistant(active, t('externalCliCodexNoVisibleOutput'), true); + } + this.store.updateSession(active.sessionId, { status: 'completed', claudeSessionId: active.cliSessionId }); + this.applyTurnMemoryUpdates(active.sessionId); + this.releaseActiveSession(active); + this.emit('complete', active.sessionId, active.cliSessionId); + active.child.kill('SIGTERM'); + } + private buildCommandArgs( cwd: string, prompt: string, @@ -710,7 +787,8 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun return args; } - if (cliSessionId) { + const canResumeCodexSession = this.getConfigSource() !== ExternalAgentConfigSource.WesightModel; + if (cliSessionId && canResumeCodexSession) { const resumeArgs = [ 'exec', 'resume', @@ -826,11 +904,23 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun return 'qwen'; } - private resolveSpawnCommandSpec( + private async resolveSpawnCommandSpec( command: string, args: string[], env: Record, - ): SpawnCommandSpec { + ): Promise { + if (this.engine === CoworkAgentEngine.ClaudeCode && process.platform === 'win32') { + const resolution = await resolveCliCommand(command); + if (resolution.path) { + return this.buildResolvedWindowsCliSpawnSpec(resolution.path, args, 'agent-engine-command-resolution'); + } + console.warn('[ExternalCliRuntimeAdapter] Claude Code CLI path resolution failed; falling back to PATH lookup.', { + error: resolution.error, + timedOut: resolution.timedOut, + }); + return { command, args, source: 'path' }; + } + if (this.engine !== CoworkAgentEngine.Codex || process.platform !== 'win32') { return { command, args, source: 'path' }; } @@ -857,13 +947,29 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun }; } + private buildResolvedWindowsCliSpawnSpec( + commandPath: string, + args: string[], + source: string, + ): SpawnCommandSpec { + if (isWindowsCommandShim(commandPath)) { + return { + command: 'cmd.exe', + args: buildWindowsCommandShimArgs(commandPath, args), + source, + windowsVerbatimArguments: true, + }; + } + return { command: commandPath, args, source }; + } + private resolveWindowsNodeRuntime(env: Record): string | null { const candidates = [ path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs', 'node.exe'), process.env['ProgramFiles(x86)'] ? path.join(process.env['ProgramFiles(x86)'] as string, 'nodejs', 'node.exe') : null, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'nodejs', 'node.exe') : null, process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Programs', 'nodejs', 'node.exe') : null, - ].filter((item): item is string => Boolean(item?.trim())); + ].filter((item): item is string => typeof item === 'string' && item.trim().length > 0); for (const candidate of candidates) { if (fs.existsSync(candidate)) { @@ -978,17 +1084,23 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun env: Record, apiConfigOverride?: ApiConfigOverride, ): string | null { - const resolved = resolveRawApiConfig(apiConfigOverride); - if (!resolved.config) return null; - if (resolved.config.apiType === 'anthropic') return null; + const resolved = resolveCodexWesightApiConfig('local', apiConfigOverride); + if (!resolved.config) { + throw new Error(`Codex CLI could not use WeSight model config: ${resolved.error ?? 'unknown configuration error'}`); + } const apiKey = resolved.config.apiKey.trim(); const baseUrl = resolved.config.baseURL.trim(); - if (!apiKey || !baseUrl) return null; + if (!apiKey || !baseUrl) { + throw new Error('Codex CLI could not use WeSight model config: missing API key or proxy base URL.'); + } try { const providerName = resolved.providerMetadata?.providerName || 'wesight'; const codexHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-codex-home-')); - fs.writeFileSync(path.join(codexHomeDir, 'auth.json'), `${JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2)}\n`, 'utf8'); + chmodBestEffort(codexHomeDir, 0o700); + const authPath = path.join(codexHomeDir, 'auth.json'); + fs.writeFileSync(authPath, `${JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2)}\n`, 'utf8'); + chmodBestEffort(authPath, 0o600); fs.writeFileSync( path.join(codexHomeDir, 'config.toml'), this.buildCodexRuntimeConfig(providerName, baseUrl, resolved.config.model), @@ -996,10 +1108,10 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun ); env.CODEX_HOME = codexHomeDir; env.OPENAI_API_KEY = apiKey; + this.appendNoProxyHosts(env, ['127.0.0.1', 'localhost']); return codexHomeDir; } catch (error) { - console.warn('[ExternalCliRuntimeAdapter] Failed to prepare temporary Codex WeSight config:', error); - return null; + throw new Error('Failed to prepare temporary Codex WeSight config.', { cause: error }); } } @@ -1030,6 +1142,23 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun return `${modelLine}\n${configText}`; } + private appendNoProxyHosts(env: Record, hosts: string[]): void { + const existing = (env.NO_PROXY || env.no_proxy || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + const normalized = new Set(existing.map((item) => item.toLowerCase())); + for (const host of hosts) { + if (!normalized.has(host.toLowerCase())) { + existing.push(host); + normalized.add(host.toLowerCase()); + } + } + const value = existing.join(','); + env.NO_PROXY = value; + env.no_proxy = value; + } + private cleanupCodexHomeDir(codexHomeDir: string | null): void { if (!codexHomeDir) return; const tmpRoot = path.resolve(os.tmpdir()); @@ -1077,6 +1206,82 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun return JSON.stringify(value); } + private summarizeCodexConfigForLog( + env: Record, + codexHomeDir: string | null, + ): CodexConfigLogSummary { + const source = codexHomeDir ? 'temporary' : 'local'; + const codexHome = codexHomeDir || env.CODEX_HOME || path.join(os.homedir(), '.codex'); + const configPath = path.join(codexHome, 'config.toml'); + const configText = this.readTextFileForLog(configPath); + const modelProvider = this.extractTomlString(configText, 'model_provider'); + const model = this.extractTomlString(configText, 'model'); + const providerBody = modelProvider + ? this.readTomlTableBody(configText, 'model_providers', modelProvider) + : ''; + const providerName = this.extractTomlString(providerBody, 'name'); + const baseUrl = this.extractTomlString(providerBody, 'base_url'); + const wireApi = this.extractTomlString(providerBody, 'wire_api'); + + return { + source, + configPath, + modelProvider: modelProvider || '(not set)', + model: model || '(not set)', + providerName: providerName || modelProvider || '(not set)', + serverUrl: baseUrl ? this.sanitizeUrlForLog(baseUrl) : '(not configured)', + wireApi: wireApi || '(not set)', + }; + } + + private readTextFileForLog(filePath: string): string { + try { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''; + } catch { + return ''; + } + } + + private extractTomlString(configText: string, key: string): string { + const match = configText.match(new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']*)["']`, 'm')); + return match?.[1]?.trim() ?? ''; + } + + private readTomlTableBody(configText: string, tablePrefix: string, tableKey: string): string { + const escapedPrefix = tablePrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedKey = tableKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const tableMatch = configText.match( + new RegExp( + `(?:^|\\r?\\n)\\s*\\[${escapedPrefix}\\.(?:"${escapedKey}"|'${escapedKey}'|${escapedKey})\\]\\s*\\r?\\n([\\s\\S]*?)(?=\\r?\\n\\s*\\[|(?![\\s\\S]))`, + ), + ); + return tableMatch?.[1] ?? ''; + } + + private sanitizeUrlForLog(value: string): string { + const redacted = this.redactSensitiveTextForLog(value); + try { + const url = new URL(redacted); + if (url.username) url.username = 'redacted'; + if (url.password) url.password = 'redacted'; + for (const key of Array.from(url.searchParams.keys())) { + if (/token|secret|password|api[_-]?key|access[_-]?key/i.test(key)) { + url.searchParams.set(key, 'redacted'); + } + } + return url.toString(); + } catch { + return redacted; + } + } + + private redactSensitiveTextForLog(value: string): string { + return value + .replace(/(authorization\s*[:=]\s*bearer\s+)([^\s"']+)/gi, '$1') + .replace(/((?:api[_-]?key|access[_-]?token|auth[_-]?token|password|secret)\s*[:=]\s*["']?)([^\s"',}]+)/gi, '$1') + .replace(/\b(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+/g, '$1...'); + } + private summarizeProxyEnv(env: Record): Record { return { httpProxy: Boolean(env.HTTP_PROXY || env.http_proxy), @@ -1301,6 +1506,7 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun } private handleOutputLine(active: ActiveCliSession, line: string): void { + if (this.engine === CoworkAgentEngine.Codex && active.completedFromEvent) return; const trimmed = line.trim(); if (!trimmed) return; try { @@ -1363,7 +1569,9 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun return; } if (type === CodexCliEventType.Error) { - this.handleError(active.sessionId, firstString(event.message, event.error) ?? 'Codex CLI returned an error.'); + const message = firstString(event.message, event.error) ?? 'Codex CLI returned an error.'; + active.cliErrorMessage = message; + active.stderrTail = this.appendStderrTail(active.stderrTail, `${message}\n`); return; } if (type === CodexCliEventType.ItemStarted && isRecord(event.item)) { @@ -1388,7 +1596,14 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun return; } if (type === CodexCliEventType.TurnFailed) { + active.completedFromEvent = true; + this.clearSessionTimers(active); this.handleError(active.sessionId, firstString(event.message, event.error) ?? 'Codex turn failed.'); + active.child.kill('SIGTERM'); + return; + } + if (type === CodexCliEventType.TurnCompleted) { + this.completeCodexSessionFromEvent(active); } } @@ -1396,6 +1611,7 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun const payloadType = String(payload.type ?? ''); if (payloadType !== CodexCliItemType.ImageGenerationEnd) return; const imageId = firstString(payload.call_id, payload.id); + if (!imageId) return; this.handleCodexImageGenerationItem(active, { type: CodexCliItemType.ImageGenerationCall, id: imageId, @@ -1881,13 +2097,13 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun return parts.length > 0 ? parts.join('') : null; } if (!isRecord(value)) return null; - const direct = firstString(value.text, value.message, value.content, value.output); - if (direct) return direct; - return this.extractCodexText(value.text) - ?? this.extractCodexText(value.message) - ?? this.extractCodexText(value.content) - ?? this.extractCodexText(value.output) - ?? this.extractCodexText(value.payload); + const direct = [value.text, value.message, value.content, value.output] + .map((item) => this.extractCodexText(item)) + .filter((item): item is string => Boolean(item)); + if (direct.length > 0) { + return direct.reduce((longest, item) => (item.length > longest.length ? item : longest)); + } + return this.extractCodexText(value.payload); } private summarizeClaudeCliEvent(event: Record): Record { @@ -2051,6 +2267,7 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun private replaceAssistant(active: ActiveCliSession, content: string, isFinal: boolean): void { const safeContent = truncateLargeContent(content, STREAMING_TEXT_MAX_CHARS); + this.logAssistantOutputStarted(active, safeContent, isFinal); active.assistantContent = safeContent; if (!active.assistantMessageId) { const message = this.store.addMessage(active.sessionId, { @@ -2078,6 +2295,21 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun this.emit('messageUpdate', active.sessionId, active.assistantMessageId, active.assistantContent); } + private logAssistantOutputStarted(active: ActiveCliSession, content: string, isFinal: boolean): void { + if (active.assistantOutputStartedLogged) return; + if (!content.trim()) return; + active.assistantOutputStartedLogged = true; + console.log('[ExternalCliRuntimeAdapter] CLI assistant output started.', { + engine: this.getEngineDisplayName(), + sessionId: active.sessionId, + cliSessionId: active.cliSessionId || '(not set)', + configSource: active.configSource, + elapsedMs: Math.max(0, Date.now() - active.startedAt), + outputChars: content.length, + isFinal, + }); + } + private addToolMessage( sessionId: string, input: { type: CoworkMessage['type']; content: string; metadata?: CoworkMessageMetadata }, @@ -2174,9 +2406,21 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun hasAssistantOutput: assistantOutput.bytes > 0, hasVisibleOutput: this.hasVisibleOutput(active), stderrChars: active.stderrTail.length, + ...this.summarizeStderrForLog(active.stderrTail), }); } + private summarizeStderrForLog(stderrTail: string): Record { + const trimmed = stderrTail.trim(); + if (!trimmed) return {}; + const redacted = this.redactSensitiveTextForLog(trimmed); + const truncated = redacted.length > STDERR_LOG_MAX_CHARS; + return { + stderrTail: truncated ? redacted.slice(-STDERR_LOG_MAX_CHARS) : redacted, + stderrTailTruncated: truncated, + }; + } + private getEngineDisplayName(): string { if (this.engine === CoworkAgentEngine.ClaudeCode) return 'Claude Code CLI'; if (this.engine === CoworkAgentEngine.Codex) return 'Codex CLI'; diff --git a/src/main/libs/claudeSettings.ts b/src/main/libs/claudeSettings.ts index e552a739..bd8b8dcc 100644 --- a/src/main/libs/claudeSettings.ts +++ b/src/main/libs/claudeSettings.ts @@ -1,7 +1,7 @@ import { app } from 'electron'; import { join } from 'path'; -import { ProviderName, resolveCodingPlanBaseUrl } from '../../shared/providers'; +import { ProviderName, ProviderRegistry, resolveCodingPlanBaseUrl } from '../../shared/providers'; import type { SqliteStore } from '../sqliteStore'; import type { CoworkApiConfig } from './coworkConfigStore'; import { type AnthropicApiFormat,normalizeProviderApiFormat } from './coworkFormatTransform'; @@ -387,6 +387,124 @@ export function resolveCurrentApiConfig( }; } +export function resolveCodexWesightApiConfig( + target: OpenAICompatProxyTarget = 'local', + override: ApiConfigOverride = {}, +): ApiConfigResolution { + const sqliteStore = getStore(); + if (!sqliteStore) { + return { + config: null, + error: 'Store is not initialized.', + }; + } + + const appConfig = sqliteStore.get('app_config'); + if (!appConfig) { + return { + config: null, + error: 'Application config not found.', + }; + } + + const { matched, error } = resolveMatchedProvider(appConfig, override); + if (!matched) { + return { + config: null, + error, + }; + } + + let resolvedApiKey = matched.providerConfig.apiKey?.trim() || ''; + if (matched.providerName === 'qwen' && !resolvedApiKey && (matched.providerConfig as any).oauthCredentials) { + const oauthCreds = (matched.providerConfig as any).oauthCredentials; + const expiryBuffer = 5 * 60 * 1000; + resolvedApiKey = oauthCreds.access || ''; + if (Date.now() >= (oauthCreds.expires - expiryBuffer)) { + console.warn('Qwen OAuth token expired, please refresh credentials'); + } + } + + const effectiveApiKey = resolvedApiKey + || (!providerRequiresApiKey(matched.providerName) ? 'sk-wesight-local' : ''); + const upstreamBaseURL = resolveCodexOpenAICompatibleBaseURL(matched); + if (!upstreamBaseURL) { + return { + config: null, + error: `Provider ${matched.providerName} does not have an OpenAI-compatible endpoint for Codex CLI.`, + providerMetadata: { + providerName: matched.providerName, + codingPlanEnabled: !!matched.providerConfig.codingPlanEnabled, + supportsImage: matched.supportsImage, + modelName: matched.modelName, + }, + }; + } + + const proxyStatus = getCoworkOpenAICompatProxyStatus(); + if (!proxyStatus.running) { + return { + config: null, + error: 'OpenAI compatibility proxy is not running.', + providerMetadata: { + providerName: matched.providerName, + codingPlanEnabled: !!matched.providerConfig.codingPlanEnabled, + supportsImage: matched.supportsImage, + modelName: matched.modelName, + }, + }; + } + + configureCoworkOpenAICompatProxy({ + baseURL: upstreamBaseURL, + apiKey: resolvedApiKey || undefined, + model: matched.modelId, + provider: matched.providerName, + }); + + const proxyBaseURL = getCoworkOpenAICompatProxyBaseURL(target); + if (!proxyBaseURL) { + return { + config: null, + error: 'OpenAI compatibility proxy base URL is unavailable.', + providerMetadata: { + providerName: matched.providerName, + codingPlanEnabled: !!matched.providerConfig.codingPlanEnabled, + supportsImage: matched.supportsImage, + modelName: matched.modelName, + }, + }; + } + + return { + config: { + apiKey: effectiveApiKey, + baseURL: proxyBaseURL, + model: matched.modelId, + apiType: 'openai', + }, + providerMetadata: { + providerName: matched.providerName, + codingPlanEnabled: !!matched.providerConfig.codingPlanEnabled, + supportsImage: matched.supportsImage, + modelName: matched.modelName, + }, + }; +} + +function resolveCodexOpenAICompatibleBaseURL(matched: MatchedProvider): string { + if (matched.providerConfig.codingPlanEnabled) { + const codingPlanUrl = ProviderRegistry.getCodingPlanUrl(matched.providerName, 'openai')?.trim(); + if (codingPlanUrl) return codingPlanUrl; + } + + if (matched.apiFormat === 'openai') { + return matched.baseURL.trim(); + } + + return ProviderRegistry.getSwitchableBaseUrl(matched.providerName, 'openai')?.trim() || ''; +} + export function getCurrentApiConfig( target: OpenAICompatProxyTarget = 'local', override: ApiConfigOverride = {}, diff --git a/src/main/libs/coworkOpenAICompatProxy.ts b/src/main/libs/coworkOpenAICompatProxy.ts index de0ba135..dae14930 100644 --- a/src/main/libs/coworkOpenAICompatProxy.ts +++ b/src/main/libs/coworkOpenAICompatProxy.ts @@ -745,6 +745,191 @@ function convertChatCompletionsRequestToResponsesRequest( return request; } +function convertResponsesContentToChatContent(content: unknown): unknown { + if (typeof content === 'string') { + return content; + } + + const parts: Array> = []; + for (const item of toArray(content)) { + const itemObj = toOptionalObject(item); + if (!itemObj) continue; + const itemType = toString(itemObj.type); + if (itemType === 'input_text' || itemType === 'output_text' || itemType === 'text') { + const text = toString(itemObj.text); + if (text) { + parts.push({ type: 'text', text }); + } + continue; + } + if (itemType === 'input_image') { + const imageURL = toString(itemObj.image_url); + if (imageURL) { + parts.push({ type: 'image_url', image_url: { url: imageURL } }); + } + } + } + + if (parts.length === 1 && parts[0].type === 'text') { + return parts[0].text; + } + return parts; +} + +function normalizeChatToolsFromResponses(toolsInput: unknown): Array> { + const normalizedTools: Array> = []; + for (const tool of toArray(toolsInput)) { + const toolObj = toOptionalObject(tool); + if (!toolObj || toString(toolObj.type) !== 'function') { + continue; + } + const name = toString(toolObj.name); + if (!name) { + continue; + } + const functionObj: Record = { name }; + const description = toString(toolObj.description); + if (description) { + functionObj.description = description; + } + if (toolObj.parameters !== undefined) { + functionObj.parameters = toolObj.parameters; + } + if (typeof toolObj.strict === 'boolean') { + functionObj.strict = toolObj.strict; + } + normalizedTools.push({ + type: 'function', + function: functionObj, + }); + } + return normalizedTools; +} + +function normalizeChatToolChoiceFromResponses(toolChoice: unknown): unknown { + if (typeof toolChoice === 'string') { + if (toolChoice === 'required') return 'required'; + if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice; + return toolChoice; + } + + const toolChoiceObj = toOptionalObject(toolChoice); + if (!toolChoiceObj) { + return toolChoice; + } + if (toString(toolChoiceObj.type) === 'function') { + const name = toString(toolChoiceObj.name); + if (name) { + return { + type: 'function', + function: { name }, + }; + } + } + return toolChoice; +} + +function normalizeChatRoleFromResponses(role: string, itemType: string): string { + if (role === 'developer') return 'system'; + if (role === 'system' || role === 'user' || role === 'assistant' || role === 'tool') return role; + if (role === 'latest_reminder') return role; + if (!role && itemType === 'message') return 'user'; + return role || 'user'; +} + +function convertResponsesRequestToChatCompletionsRequest( + responsesRequest: Record, +): Record { + const request: Record = {}; + const messages: Array> = []; + + if (responsesRequest.model !== undefined) { + request.model = responsesRequest.model; + } + if (responsesRequest.temperature !== undefined) { + request.temperature = responsesRequest.temperature; + } + if (responsesRequest.top_p !== undefined) { + request.top_p = responsesRequest.top_p; + } + if (responsesRequest.parallel_tool_calls !== undefined) { + request.parallel_tool_calls = responsesRequest.parallel_tool_calls; + } + + const maxOutputTokens = toNumber(responsesRequest.max_output_tokens) + ?? toNumber(responsesRequest.max_completion_tokens) + ?? toNumber(responsesRequest.max_tokens); + if (maxOutputTokens !== null) { + request.max_tokens = maxOutputTokens; + } + + const tools = normalizeChatToolsFromResponses(responsesRequest.tools); + if (tools.length > 0) { + request.tools = tools; + } + if (responsesRequest.tool_choice !== undefined) { + request.tool_choice = normalizeChatToolChoiceFromResponses(responsesRequest.tool_choice); + } + + const instructions = toString(responsesRequest.instructions); + if (instructions) { + messages.push({ role: 'system', content: instructions }); + } + + const input = responsesRequest.input; + if (typeof input === 'string') { + messages.push({ role: 'user', content: input }); + } else { + for (const item of toArray(input)) { + const itemObj = toOptionalObject(item); + if (!itemObj) continue; + const itemType = toString(itemObj.type); + if (itemType === 'function_call_output') { + const toolCallId = toString(itemObj.call_id); + const output = stringifyUnknown(itemObj.output); + if (toolCallId && output) { + messages.push({ + role: 'tool', + tool_call_id: toolCallId, + content: output, + }); + } + continue; + } + if (itemType === 'function_call') { + const callId = toString(itemObj.call_id) || toString(itemObj.id); + const name = toString(itemObj.name); + if (callId && name) { + messages.push({ + role: 'assistant', + content: null, + tool_calls: [ + { + id: callId, + type: 'function', + function: { + name, + arguments: normalizeFunctionArguments(itemObj.arguments) || '{}', + }, + }, + ], + }); + } + continue; + } + + const role = normalizeChatRoleFromResponses(toString(itemObj.role), itemType); + const content = convertResponsesContentToChatContent(itemObj.content); + if (role && (typeof content === 'string' ? content : toArray(content).length > 0)) { + messages.push({ role, content }); + } + } + } + + request.messages = messages; + return request; +} + function normalizeToolName(value: unknown): string { return toString(value).trim().toLowerCase(); } @@ -1268,6 +1453,163 @@ function convertResponsesToOpenAIResponse(body: unknown): Record { + const source = toOptionalObject(body) ?? {}; + const choice = toOptionalObject(toArray(source.choices)[0]) ?? {}; + const message = toOptionalObject(choice.message) ?? {}; + const output: Array> = []; + const responseId = toString(source.id) || `resp_${Date.now()}`; + const model = toString(source.model) || fallbackModel; + const content = message.content; + const text = typeof content === 'string' + ? content + : toArray(content) + .map((item) => toString(toOptionalObject(item)?.text)) + .filter(Boolean) + .join(''); + + if (text) { + output.push({ + type: 'message', + id: `msg_${responseId}`, + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text, + annotations: [], + }, + ], + }); + } + + for (const toolCall of toArray(message.tool_calls)) { + const toolCallObj = toOptionalObject(toolCall); + const functionObj = toOptionalObject(toolCallObj?.function); + if (!toolCallObj || !functionObj) continue; + const callId = toString(toolCallObj.id) || `call_${output.length}`; + output.push({ + type: 'function_call', + id: callId, + call_id: callId, + name: toString(functionObj.name), + arguments: normalizeFunctionArguments(functionObj.arguments) || '{}', + status: 'completed', + }); + } + + const usage = toOptionalObject(source.usage); + return { + id: responseId, + object: 'response', + created_at: toNumber(source.created) ?? Math.floor(Date.now() / 1000), + status: 'completed', + model, + output, + usage: { + input_tokens: toNumber(usage?.prompt_tokens) ?? toNumber(usage?.input_tokens) ?? 0, + output_tokens: toNumber(usage?.completion_tokens) ?? toNumber(usage?.output_tokens) ?? 0, + total_tokens: toNumber(usage?.total_tokens) ?? 0, + }, + }; +} + +function writeResponsesSSE(res: http.ServerResponse, responseObj: Record): void { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + emitSSE(res, 'response.created', { + type: 'response.created', + response: { + ...responseObj, + output: [], + }, + }); + + const output = toArray(responseObj.output); + output.forEach((item, index) => { + const itemObj = toOptionalObject(item); + if (!itemObj) return; + emitSSE(res, 'response.output_item.added', { + type: 'response.output_item.added', + response_id: responseObj.id, + output_index: index, + item: itemObj, + }); + + if (toString(itemObj.type) === 'message') { + const content = toArray(itemObj.content); + content.forEach((part, contentIndex) => { + const partObj = toOptionalObject(part); + if (!partObj) return; + const text = toString(partObj.text); + emitSSE(res, 'response.content_part.added', { + type: 'response.content_part.added', + response_id: responseObj.id, + item_id: itemObj.id, + output_index: index, + content_index: contentIndex, + part: partObj, + }); + if (text) { + emitSSE(res, 'response.output_text.delta', { + type: 'response.output_text.delta', + response_id: responseObj.id, + item_id: itemObj.id, + output_index: index, + content_index: contentIndex, + delta: text, + }); + emitSSE(res, 'response.output_text.done', { + type: 'response.output_text.done', + response_id: responseObj.id, + item_id: itemObj.id, + output_index: index, + content_index: contentIndex, + text, + }); + } + emitSSE(res, 'response.content_part.done', { + type: 'response.content_part.done', + response_id: responseObj.id, + item_id: itemObj.id, + output_index: index, + content_index: contentIndex, + part: partObj, + }); + }); + } + + if (toString(itemObj.type) === 'function_call') { + emitSSE(res, 'response.function_call_arguments.done', { + type: 'response.function_call_arguments.done', + response_id: responseObj.id, + item_id: itemObj.id, + output_index: index, + call_id: itemObj.call_id, + arguments: toString(itemObj.arguments) || '{}', + }); + } + + emitSSE(res, 'response.output_item.done', { + type: 'response.output_item.done', + response_id: responseObj.id, + output_index: index, + item: itemObj, + }); + }); + + emitSSE(res, 'response.completed', { + type: 'response.completed', + response: responseObj, + }); + res.write('data: [DONE]\n\n'); + res.end(); +} + function cacheToolCallExtraContentFromResponsesResponse(body: unknown): void { const responseObj = resolveResponsesObject(body); for (const item of toArray(responseObj.output)) { @@ -2361,6 +2703,102 @@ async function handleRequest( // OpenClaw sends requests to /v1/chat/completions (OpenAI format) when using // the lobster provider. Transparently proxy these requests to the upstream with // IDE headers injected (needed for GitHub Copilot). + if (method === 'POST' && (url.pathname === '/v1/responses' || url.pathname === '/responses')) { + if (!upstreamConfig) { + writeJSON(res, 503, createAnthropicErrorBody('Proxy not configured', 'service_unavailable')); + return; + } + let body = ''; + try { + body = await readRequestBody(req); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid request body'; + writeJSON(res, 400, createAnthropicErrorBody(message, 'invalid_request_error')); + return; + } + + let responsesRequest: Record; + try { + const parsed = JSON.parse(body); + responsesRequest = toOptionalObject(parsed) ?? {}; + } catch { + writeJSON(res, 400, createAnthropicErrorBody('Request body must be valid JSON', 'invalid_request_error')); + return; + } + + const wantsStream = Boolean(responsesRequest.stream); + const chatRequest = convertResponsesRequestToChatCompletionsRequest(responsesRequest); + chatRequest.model = upstreamConfig.model; + chatRequest.stream = false; + filterOpenAIToolsForProvider(chatRequest, upstreamConfig.provider); + remapMessageRolesForMiniMax(chatRequest, upstreamConfig.provider); + hydrateOpenAIRequestToolCalls(chatRequest, upstreamConfig.provider, upstreamConfig.baseURL); + sanitizeToolsForGemini(chatRequest, upstreamConfig.provider, upstreamConfig.baseURL); + normalizeMaxTokensFieldForOpenAIProvider(chatRequest, upstreamConfig.provider); + mergeSystemMessagesForProvider(chatRequest); + + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (upstreamConfig.apiKey) { + if (isGeminiProvider(upstreamConfig.provider, upstreamConfig.baseURL)) { + headers['x-goog-api-key'] = upstreamConfig.apiKey; + } else { + headers.Authorization = `Bearer ${upstreamConfig.apiKey}`; + } + } + const targetURL = buildOpenAIChatCompletionsURL(upstreamConfig.baseURL); + console.log(`[CoworkProxy] Responses compat → ${targetURL} (provider: ${upstreamConfig.provider})`); + + let upstreamResponse: Response; + try { + upstreamResponse = await session.defaultSession.fetch(targetURL, { + method: 'POST', + headers, + body: JSON.stringify(chatRequest), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Network error'; + lastProxyError = message; + writeJSON(res, 502, createAnthropicErrorBody(message)); + return; + } + + if (!upstreamResponse.ok) { + const errorText = await upstreamResponse.text(); + const errorMessage = extractErrorMessage(errorText); + lastProxyError = errorMessage; + console.error(`[CoworkProxy] Responses compat upstream error: status=${upstreamResponse.status}, body=${errorText.slice(0, 500)}`); + writeJSON(res, upstreamResponse.status, { + error: { + message: errorMessage, + type: 'api_error', + }, + }); + return; + } + + let upstreamJSON: unknown; + try { + upstreamJSON = await upstreamResponse.json(); + } catch { + lastProxyError = 'Failed to parse upstream JSON response'; + writeJSON(res, 502, createAnthropicErrorBody('Failed to parse upstream JSON response')); + return; + } + + lastProxyError = null; + cacheToolCallExtraContentFromOpenAIResponse(upstreamJSON); + const responseObj = convertOpenAIResponseToResponses(upstreamJSON, upstreamConfig.model); + cacheToolCallExtraContentFromResponsesResponse(responseObj); + if (wantsStream) { + writeResponsesSSE(res, responseObj); + } else { + writeJSON(res, 200, responseObj); + } + return; + } + if (method === 'POST' && (url.pathname === '/v1/chat/completions' || url.pathname === '/chat/completions')) { if (!upstreamConfig) { writeJSON(res, 503, createAnthropicErrorBody('Proxy not configured', 'service_unavailable')); @@ -2768,8 +3206,11 @@ export const __openAICompatProxyTestUtils = { findSSEPacketBoundary, processResponsesStreamEvent, convertChatCompletionsRequestToResponsesRequest, + convertResponsesRequestToChatCompletionsRequest, + convertOpenAIResponseToResponses, filterOpenAIToolsForProvider, isGeminiProvider, + sanitizeToolsForGemini, }; export async function startCoworkOpenAICompatProxy(): Promise { diff --git a/src/main/libs/externalAgentConfigSync.ts b/src/main/libs/externalAgentConfigSync.ts index 95b61f15..5d3dce33 100644 --- a/src/main/libs/externalAgentConfigSync.ts +++ b/src/main/libs/externalAgentConfigSync.ts @@ -1,5 +1,3 @@ -import Database from 'better-sqlite3'; -import { randomUUID } from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -11,7 +9,7 @@ import { type ExternalAgentConfigSource as ExternalAgentConfigSourceType, } from '../../shared/cowork/constants'; import type { SqliteStore } from '../sqliteStore'; -import { resolveCurrentApiConfig, resolveRawApiConfig } from './claudeSettings'; +import { resolveRawApiConfig } from './claudeSettings'; import type { CoworkApiConfig } from './coworkConfigStore'; import { DEFAULT_DEEPSEEK_TUI_MODEL, @@ -56,30 +54,6 @@ type AppConfigForModelImport = { providers?: Record; }; -type CcSwitchProviderRow = { - id: string; - name: string; - settings_config: string; - meta: string; - category: string | null; - created_at: number | null; - sort_index: number | null; - is_current: number; -}; - -type CcSwitchProviderRecord = { - id: string; - name: string; - settingsConfig: Record; - meta: Record; - category: string | null; - createdAt: number | null; - sortIndex: number | null; - isCurrent: boolean; - baseUrl: string; - endpoints: string[]; -}; - export interface ExternalAgentModelImportResult { success: boolean; appType?: CliAppType; @@ -113,8 +87,28 @@ const DEFAULT_GROK_LOCAL_MODEL = DEFAULT_GROK_BUILD_MODEL; const DEFAULT_QWEN_CODE_LOCAL_MODEL = DEFAULT_QWEN_CODE_MODEL; const DEFAULT_DEEPSEEK_TUI_LOCAL_MODEL = DEFAULT_DEEPSEEK_TUI_MODEL; const CODEX_LOCAL_PROVIDER_KEY = 'local_codex'; -const CC_SWITCH_CLAUDE_COMMON_CONFIG_KEY = 'common_config_claude'; +const WESIGHT_CONFIG_BACKUP_DIR = '.wesight-backups'; +const WESIGHT_CONFIG_BACKUP_RECENT_RETENTION = 20; const WESIGHT_MANAGED_META_KEY = '__wesight_managed'; +const CODEX_WESIGHT_META_BEGIN = '# WeSight managed Codex config: begin'; +const CODEX_WESIGHT_META_END = '# WeSight managed Codex config: end'; +const CODEX_WESIGHT_META_KEYS = { + OriginalModelProvider: 'original_model_provider', + OriginalModel: 'original_model', + OriginalModelReasoningEffort: 'original_model_reasoning_effort', + OriginalDisableResponseStorage: 'original_disable_response_storage', + ManagedModelProvider: 'managed_model_provider', + ManagedModel: 'managed_model', +} as const; +type CodexWesightManagedMeta = { + hasMeta: boolean; + originalModelProvider?: string; + originalModel?: string; + originalModelReasoningEffort?: string; + originalDisableResponseStorage?: boolean; + managedModelProvider?: string; + managedModel?: string; +}; const CLAUDE_CREDENTIAL_ENV_KEYS = [ 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', @@ -150,6 +144,66 @@ const atomicWrite = (filePath: string, content: string): void => { fs.renameSync(tmpPath, filePath); }; +const pruneWesightConfigFileBackups = (backupsDir: string, baseName: string): void => { + const prefix = `${baseName}.`; + const entries = fs.readdirSync(backupsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.bak')) + .map((entry) => { + const backupPath = path.join(backupsDir, entry.name); + return { + name: entry.name, + path: backupPath, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + if (entries.length <= WESIGHT_CONFIG_BACKUP_RECENT_RETENTION + 1) { + return; + } + + const firstBackup = entries[0]; + const recentBackups = entries + .slice(1) + .sort((a, b) => b.name.localeCompare(a.name)) + .slice(0, WESIGHT_CONFIG_BACKUP_RECENT_RETENTION); + const retained = new Set([firstBackup.path, ...recentBackups.map((entry) => entry.path)]); + + for (const entry of entries) { + if (retained.has(entry.path)) continue; + fs.unlinkSync(entry.path); + } +}; + +export const createWesightConfigFileBackup = (filePath: string): string | null => { + if (!fs.existsSync(filePath)) { + return null; + } + const backupsDir = path.join(path.dirname(filePath), WESIGHT_CONFIG_BACKUP_DIR); + fs.mkdirSync(backupsDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const uniqueSuffix = process.hrtime.bigint().toString(36); + const backupPath = path.join(backupsDir, `${path.basename(filePath)}.${timestamp}.${process.pid}.${uniqueSuffix}.bak`); + fs.copyFileSync(filePath, backupPath); + pruneWesightConfigFileBackups(backupsDir, path.basename(filePath)); + return backupPath; +}; + +export const writeTextFileWithBackupIfChanged = (filePath: string, content: string): boolean => { + const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null; + if (existing === content) { + return false; + } + if (existing !== null) { + createWesightConfigFileBackup(filePath); + } + atomicWrite(filePath, content); + return true; +}; + +export const writeJsonObjectWithBackupIfChanged = (filePath: string, value: Record): boolean => { + return writeTextFileWithBackupIfChanged(filePath, `${JSON.stringify(value, null, 2)}\n`); +}; + const readJsonObject = (filePath: string): Record | null => { try { if (!fs.existsSync(filePath)) return null; @@ -178,39 +232,10 @@ const getString = (value: unknown): string => { return typeof value === 'string' ? value.trim() : ''; }; -const parseJsonObject = (value: string): Record => { - try { - const parsed = JSON.parse(value); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? parsed as Record - : {}; - } catch { - return {}; - } -}; - -const normalizeBaseUrlForMatch = (value: string): string => { - const trimmed = value.trim().replace(/\/+$/, ''); - if (!trimmed) return ''; - try { - const url = new URL(trimmed); - url.hash = ''; - url.search = ''; - url.hostname = url.hostname.toLowerCase(); - return url.toString().replace(/\/+$/, ''); - } catch { - return trimmed.toLowerCase(); - } -}; - -const normalizeProviderName = (value: string): string => { - return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, ''); -}; - -const baseUrlsMatch = (left: string, right: string): boolean => { - const normalizedLeft = normalizeBaseUrlForMatch(left); - const normalizedRight = normalizeBaseUrlForMatch(right); - return Boolean(normalizedLeft && normalizedRight && normalizedLeft === normalizedRight); +const getStringArray = (value: unknown): string[] => { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; }; const tomlString = (value: string): string => { @@ -262,6 +287,115 @@ const splitTomlHeadAndTables = (configText: string): { head: string; tables: str }; }; +const parseTomlStringValue = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) + || (trimmed.startsWith('\'') && trimmed.endsWith('\'')) + ) { + try { + return JSON.parse(trimmed.replace(/^'/, '"').replace(/'$/, '"')); + } catch { + return trimmed.slice(1, -1); + } + } + return trimmed.split(/\s+#/)[0]?.trim() || null; +}; + +const extractTomlTopLevelString = (head: string, key: string): string | null => { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = head.match(new RegExp(`^\\s*${escaped}\\s*=\\s*(.+)$`, 'm')); + return match?.[1] ? parseTomlStringValue(match[1]) : null; +}; + +const extractTomlTopLevelBoolean = (head: string, key: string): boolean | null => { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = head.match(new RegExp(`^\\s*${escaped}\\s*=\\s*(true|false)\\s*(?:#.*)?$`, 'm')); + if (!match?.[1]) return null; + return match[1] === 'true'; +}; + +const upsertTomlTopLevelOptionalString = (head: string, key: string, value: string | undefined): string => { + return value === undefined ? head : upsertTomlTopLevelString(head, key, value); +}; + +const upsertTomlTopLevelOptionalBoolean = (head: string, key: string, value: boolean | undefined): string => { + return value === undefined ? head : upsertTomlTopLevelBoolean(head, key, value); +}; + +const removeCodexWesightManagedMetaBlock = (head: string): string => { + const pattern = new RegExp( + `${CODEX_WESIGHT_META_BEGIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\r?\\n[\\s\\S]*?${CODEX_WESIGHT_META_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\r?\\n?`, + 'm', + ); + return head.replace(pattern, ''); +}; + +const extractCodexWesightManagedMeta = (head: string): CodexWesightManagedMeta => { + const pattern = new RegExp( + `${CODEX_WESIGHT_META_BEGIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\r?\\n([\\s\\S]*?)${CODEX_WESIGHT_META_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + 'm', + ); + const match = head.match(pattern); + if (!match?.[1]) { + return { hasMeta: false }; + } + + const values = new Map(); + for (const line of match[1].split(/\r?\n/)) { + const item = line.match(/^#\s*([a-z_]+)\s*=\s*(.+)$/); + if (item?.[1] && item[2]) { + values.set(item[1], item[2]); + } + } + + const getMetaString = (key: string): string | undefined => ( + values.has(key) ? parseTomlStringValue(values.get(key) || '') ?? undefined : undefined + ); + const getMetaBoolean = (key: string): boolean | undefined => { + const value = values.get(key)?.trim(); + if (value === 'true') return true; + if (value === 'false') return false; + return undefined; + }; + + return { + hasMeta: true, + originalModelProvider: getMetaString(CODEX_WESIGHT_META_KEYS.OriginalModelProvider), + originalModel: getMetaString(CODEX_WESIGHT_META_KEYS.OriginalModel), + originalModelReasoningEffort: getMetaString(CODEX_WESIGHT_META_KEYS.OriginalModelReasoningEffort), + originalDisableResponseStorage: getMetaBoolean(CODEX_WESIGHT_META_KEYS.OriginalDisableResponseStorage), + managedModelProvider: getMetaString(CODEX_WESIGHT_META_KEYS.ManagedModelProvider), + managedModel: getMetaString(CODEX_WESIGHT_META_KEYS.ManagedModel), + }; +}; + +const buildCodexWesightManagedMetaBlock = (meta: CodexWesightManagedMeta): string => { + const lines = [ + CODEX_WESIGHT_META_BEGIN, + ]; + const addString = (key: string, value: string | undefined) => { + if (value !== undefined) { + lines.push(`# ${key} = ${tomlString(value)}`); + } + }; + const addBoolean = (key: string, value: boolean | undefined) => { + if (value !== undefined) { + lines.push(`# ${key} = ${value ? 'true' : 'false'}`); + } + }; + + addString(CODEX_WESIGHT_META_KEYS.OriginalModelProvider, meta.originalModelProvider); + addString(CODEX_WESIGHT_META_KEYS.OriginalModel, meta.originalModel); + addString(CODEX_WESIGHT_META_KEYS.OriginalModelReasoningEffort, meta.originalModelReasoningEffort); + addBoolean(CODEX_WESIGHT_META_KEYS.OriginalDisableResponseStorage, meta.originalDisableResponseStorage); + addString(CODEX_WESIGHT_META_KEYS.ManagedModelProvider, meta.managedModelProvider); + addString(CODEX_WESIGHT_META_KEYS.ManagedModel, meta.managedModel); + lines.push(CODEX_WESIGHT_META_END); + return `${lines.join('\n')}\n`; +}; + const upsertTomlTopLevelString = (head: string, key: string, value: string): string => { const line = `${key} = ${tomlString(value)}`; const pattern = new RegExp(`^\\s*${key}\\s*=.*$`, 'm'); @@ -333,7 +467,26 @@ export const mergeCodexConfigForWesightModel = ( ): string => { const providerKey = sanitizeProviderKey(providerName); const split = splitTomlHeadAndTables(existingText); - let head = removeTomlTopLevelKeys(split.head, [ + const existingMeta = extractCodexWesightManagedMeta(split.head); + const cleanHead = removeCodexWesightManagedMetaBlock(split.head); + const meta: CodexWesightManagedMeta = { + hasMeta: true, + originalModelProvider: existingMeta.hasMeta + ? existingMeta.originalModelProvider + : extractTomlTopLevelString(cleanHead, 'model_provider') ?? undefined, + originalModel: existingMeta.hasMeta + ? existingMeta.originalModel + : extractTomlTopLevelString(cleanHead, 'model') ?? undefined, + originalModelReasoningEffort: existingMeta.hasMeta + ? existingMeta.originalModelReasoningEffort + : extractTomlTopLevelString(cleanHead, 'model_reasoning_effort') ?? undefined, + originalDisableResponseStorage: existingMeta.hasMeta + ? existingMeta.originalDisableResponseStorage + : extractTomlTopLevelBoolean(cleanHead, 'disable_response_storage') ?? undefined, + managedModelProvider: providerKey, + managedModel: model || DEFAULT_CODEX_MODEL, + }; + let head = removeTomlTopLevelKeys(cleanHead, [ 'model_provider', 'model', 'model_reasoning_effort', @@ -344,21 +497,50 @@ export const mergeCodexConfigForWesightModel = ( head = upsertTomlTopLevelString(head, 'model_reasoning_effort', 'high'); head = upsertTomlTopLevelBoolean(head, 'disable_response_storage', true); const tables = replaceCodexProviderTable(split.tables, providerKey, providerName, baseUrl); - return `${removeTrailingBlankLines(head)}\n\n${removeTrailingBlankLines(tables)}\n`; + return `${buildCodexWesightManagedMetaBlock(meta)}${removeTrailingBlankLines(head)}\n\n${removeTrailingBlankLines(tables)}\n`; }; export const mergeCodexConfigForLocalCli = (existingText: string): string => { - if (!hasCodexProviderTable(existingText, CODEX_LOCAL_PROVIDER_KEY)) { + const split = splitTomlHeadAndTables(existingText); + const existingMeta = extractCodexWesightManagedMeta(split.head); + if (!existingMeta.hasMeta && !hasCodexProviderTable(existingText, CODEX_LOCAL_PROVIDER_KEY)) { return existingText; } - const split = splitTomlHeadAndTables(existingText); - const currentProvider = extractTomlString(split.head, 'model_provider'); - if (currentProvider === CODEX_LOCAL_PROVIDER_KEY) { + const cleanHead = removeCodexWesightManagedMetaBlock(split.head); + const currentProvider = extractTomlString(cleanHead, 'model_provider'); + const restoredProvider = existingMeta.originalModelProvider + ?? (hasCodexProviderTable(existingText, CODEX_LOCAL_PROVIDER_KEY) ? CODEX_LOCAL_PROVIDER_KEY : undefined); + if (!restoredProvider) { + return existingText; + } + + if ( + !existingMeta.hasMeta + && currentProvider === restoredProvider + && !extractTomlTopLevelString(cleanHead, 'model') + ) { return existingText; } - const head = upsertTomlTopLevelString(split.head, 'model_provider', CODEX_LOCAL_PROVIDER_KEY); + let head = removeTomlTopLevelKeys(cleanHead, [ + 'model_provider', + 'model', + 'model_reasoning_effort', + 'disable_response_storage', + ]); + head = upsertTomlTopLevelString(head, 'model_provider', restoredProvider); + head = upsertTomlTopLevelOptionalString(head, 'model', existingMeta.originalModel); + head = upsertTomlTopLevelOptionalString( + head, + 'model_reasoning_effort', + existingMeta.originalModelReasoningEffort, + ); + head = upsertTomlTopLevelOptionalBoolean( + head, + 'disable_response_storage', + existingMeta.originalDisableResponseStorage, + ); return `${removeTrailingBlankLines(head)}\n\n${removeTrailingBlankLines(split.tables)}\n`; }; @@ -464,361 +646,150 @@ const buildClaudeEnvForConfig = ( }; }; -const mergeClaudeSettingsWithProvider = ( - existingSettings: Record, - commonConfig: Record, - providerSettingsConfig: Record, -): Record => { - const existingEnv = getNestedRecord(existingSettings, 'env'); - const commonEnv = getNestedRecord(commonConfig, 'env'); - const providerEnv = getNestedRecord(providerSettingsConfig, 'env'); - const env = { - ...existingEnv, - ...commonEnv, - ...providerEnv, - }; - - return { - ...existingSettings, - ...commonConfig, - ...providerSettingsConfig, - env, - }; -}; - export const mergeClaudeSettingsForWesightModel = ( existingSettings: Record, config: CoworkApiConfig, ): Record => { const existingManaged = getNestedRecord(existingSettings, WESIGHT_MANAGED_META_KEY); const existingClaude = getNestedRecord(existingManaged, 'claudeCode'); - const previousEnvKeys = Array.isArray(existingClaude.envKeys) - ? existingClaude.envKeys.filter((key): key is string => typeof key === 'string') - : []; - const existingEnv = { ...getNestedRecord(existingSettings, 'env') }; - for (const key of previousEnvKeys) { - if ((CLAUDE_MANAGED_ENV_KEYS as readonly string[]).includes(key) || isWesightPlaceholder(existingEnv[key])) { - delete existingEnv[key]; + const previousEnvKeys = getStringArray(existingClaude.envKeys); + const previousCreatedEnvKeys = getStringArray(existingClaude.createdEnvKeys); + const previousOriginalEnv = getNestedRecord(existingClaude, 'originalEnv'); + const hasRecoverableSnapshot = Object.keys(previousOriginalEnv).length > 0 || previousCreatedEnvKeys.length > 0; + const baselineEnv = { ...getNestedRecord(existingSettings, 'env') }; + + if (hasRecoverableSnapshot) { + for (const key of previousEnvKeys) { + if (Object.prototype.hasOwnProperty.call(previousOriginalEnv, key)) { + baselineEnv[key] = previousOriginalEnv[key]; + } else if (previousCreatedEnvKeys.includes(key)) { + delete baselineEnv[key]; + } } } - const env = buildClaudeEnvForConfig(existingEnv, config); + for (const key of CLAUDE_MANAGED_ENV_KEYS) { + if (isWesightPlaceholder(baselineEnv[key])) { + delete baselineEnv[key]; + } + } + + const originalEnv = Object.fromEntries( + Object.entries(previousOriginalEnv).filter(([, value]) => !isWesightPlaceholder(value)), + ); + const createdEnvKeys = new Set(previousCreatedEnvKeys); + for (const key of CLAUDE_MANAGED_ENV_KEYS) { + if (Object.prototype.hasOwnProperty.call(baselineEnv, key)) { + if (!Object.prototype.hasOwnProperty.call(originalEnv, key)) { + originalEnv[key] = baselineEnv[key]; + } + createdEnvKeys.delete(key); + } else { + createdEnvKeys.add(key); + } + } + + const env = buildClaudeEnvForConfig(baselineEnv, config); + const envKeys = CLAUDE_MANAGED_ENV_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(env, key)); return { ...existingSettings, env, [WESIGHT_MANAGED_META_KEY]: { ...existingManaged, claudeCode: { - envKeys: CLAUDE_MANAGED_ENV_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(env, key)), + ...existingClaude, + envKeys, + createdEnvKeys: envKeys.filter((key) => createdEnvKeys.has(key)), + originalEnv, }, }, }; }; -const getCcSwitchPaths = (): { dbPath: string; settingsPath: string } => { - const appDir = path.join(homeDir(), '.cc-switch'); - return { - dbPath: path.join(appDir, 'cc-switch.db'), - settingsPath: path.join(appDir, 'settings.json'), - }; -}; - -const getCurrentCcSwitchProviderId = (settings: Record): string | null => { - const value = settings.currentProviderClaude ?? settings.current_provider_claude; - return typeof value === 'string' && value.trim() ? value.trim() : null; -}; - -const writeCcSwitchCurrentProviderId = (settingsPath: string, providerId: string): void => { - const settings = readJsonObject(settingsPath) ?? {}; - settings.currentProviderClaude = providerId; - if (Object.prototype.hasOwnProperty.call(settings, 'current_provider_claude')) { - settings.current_provider_claude = providerId; +const removeClaudeManagedMetadata = ( + existingSettings: Record, + existingManaged: Record, + existingClaude: Record, + env?: Record, +): Record => { + const claudeManaged = { ...existingClaude }; + delete claudeManaged.envKeys; + delete claudeManaged.createdEnvKeys; + delete claudeManaged.originalEnv; + + const managed = { ...existingManaged }; + if (Object.keys(claudeManaged).length > 0) { + managed.claudeCode = claudeManaged; + } else { + delete managed.claudeCode; } - writeJsonObject(settingsPath, settings); -}; - -const readCcSwitchCommonClaudeConfig = (db: Database.Database): Record => { - const row = db - .prepare('SELECT value FROM settings WHERE key = ? LIMIT 1') - .get(CC_SWITCH_CLAUDE_COMMON_CONFIG_KEY) as { value?: string } | undefined; - return row?.value ? parseJsonObject(row.value) : {}; -}; -const readCcSwitchProviderEndpoints = ( - db: Database.Database, - providerId: string, -): string[] => { - try { - const rows = db - .prepare('SELECT url FROM provider_endpoints WHERE app_type = ? AND provider_id = ? ORDER BY id ASC') - .all('claude', providerId) as Array<{ url?: string }>; - return rows.map((row) => getString(row.url)).filter(Boolean); - } catch { - return []; + const next = { ...existingSettings }; + if (env) { + if (Object.keys(env).length > 0) { + next.env = env; + } else { + delete next.env; + } } -}; - -const readCcSwitchClaudeProviders = (db: Database.Database): CcSwitchProviderRecord[] => { - const rows = db - .prepare( - ` - SELECT id, name, settings_config, meta, category, created_at, sort_index, is_current - FROM providers - WHERE app_type = ? - ORDER BY is_current DESC, COALESCE(sort_index, 999999), created_at ASC, id ASC - `, - ) - .all('claude') as CcSwitchProviderRow[]; - - return rows.map((row) => { - const settingsConfig = parseJsonObject(row.settings_config || '{}'); - const env = getNestedRecord(settingsConfig, 'env'); - return { - id: row.id, - name: row.name, - settingsConfig, - meta: parseJsonObject(row.meta || '{}'), - category: row.category, - createdAt: row.created_at, - sortIndex: row.sort_index, - isCurrent: Boolean(row.is_current), - baseUrl: getString(env.ANTHROPIC_BASE_URL), - endpoints: readCcSwitchProviderEndpoints(db, row.id), - }; - }); -}; - -const ccSwitchProviderMatchesBaseUrl = ( - provider: CcSwitchProviderRecord, - baseUrl: string, -): boolean => { - if (baseUrlsMatch(provider.baseUrl, baseUrl)) { - return true; + if (Object.keys(managed).length > 0) { + next[WESIGHT_MANAGED_META_KEY] = managed; + } else { + delete next[WESIGHT_MANAGED_META_KEY]; } - return provider.endpoints.some((endpoint) => baseUrlsMatch(endpoint, baseUrl)); -}; - -const formatProviderDisplayName = (providerName: string | undefined): string => { - const normalized = providerName?.trim() || 'model'; - const knownNames: Record = { - anthropic: 'Anthropic', - deepseek: 'DeepSeek', - gemini: 'Gemini', - kimi: 'Kimi', - openai: 'OpenAI', - qwen: 'Qwen', - zhipu: 'Zhipu GLM', - }; - return knownNames[normalized.toLowerCase()] - ?? normalized - .replace(/[_-]+/g, ' ') - .replace(/\b\w/g, (letter) => letter.toUpperCase()); -}; - -const buildWesightCcSwitchProviderName = (providerName: string | undefined): string => { - return `WeSight - ${formatProviderDisplayName(providerName)}`; + return next; }; -const findCcSwitchProviderForConfig = ( - providers: CcSwitchProviderRecord[], - config: CoworkApiConfig, - settingsCurrentProviderId: string | null, - providerName: string | undefined, -): CcSwitchProviderRecord | null => { - const currentProvider = providers.find((provider) => provider.id === settingsCurrentProviderId) - ?? providers.find((provider) => provider.isCurrent) - ?? null; - if (currentProvider && ccSwitchProviderMatchesBaseUrl(currentProvider, config.baseURL)) { - return currentProvider; - } - - const baseUrlProvider = providers.find((provider) => ccSwitchProviderMatchesBaseUrl(provider, config.baseURL)); - if (baseUrlProvider) { - return baseUrlProvider; - } - - const wesightProviderName = normalizeProviderName(buildWesightCcSwitchProviderName(providerName)); - const existingWesightProvider = providers.find((provider) => { - return normalizeProviderName(provider.name) === wesightProviderName - || getString(provider.meta.managedBy) === 'wesight'; - }); - if (existingWesightProvider) { - return existingWesightProvider; - } +export const removeWesightManagedClaudeSettings = ( + existingSettings: Record, +): Record => { + const existingManaged = getNestedRecord(existingSettings, WESIGHT_MANAGED_META_KEY); + const existingClaude = getNestedRecord(existingManaged, 'claudeCode'); + const previousEnvKeys = getStringArray(existingClaude.envKeys); + const previousCreatedEnvKeys = getStringArray(existingClaude.createdEnvKeys); + const previousOriginalEnv = getNestedRecord(existingClaude, 'originalEnv'); + const hasRecoverableSnapshot = Object.keys(previousOriginalEnv).length > 0 || previousCreatedEnvKeys.length > 0; - const displayName = normalizeProviderName(formatProviderDisplayName(providerName)); - if (!displayName) { - return null; + if (previousEnvKeys.length === 0) { + return existingSettings; } - return providers.find((provider) => normalizeProviderName(provider.name) === displayName) ?? null; -}; -const ensureCcSwitchProviderEndpoint = ( - db: Database.Database, - providerId: string, - baseUrl: string, -): void => { - const normalizedBaseUrl = normalizeBaseUrlForMatch(baseUrl); - if (!normalizedBaseUrl) return; - const existingEndpoints = readCcSwitchProviderEndpoints(db, providerId); - if (existingEndpoints.some((endpoint) => baseUrlsMatch(endpoint, normalizedBaseUrl))) { - return; + if (!hasRecoverableSnapshot) { + console.warn('[ExternalAgentConfigSync] found legacy Claude Code managed marker without original environment snapshot; preserving local environment values.'); + return removeClaudeManagedMetadata(existingSettings, existingManaged, existingClaude); } - db - .prepare( - ` - INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) - VALUES (?, ?, ?, ?) - `, - ) - .run(providerId, 'claude', normalizedBaseUrl, Date.now()); -}; - -const upsertCcSwitchClaudeProvider = ( - db: Database.Database, - config: CoworkApiConfig, - providerName: string | undefined, - settingsCurrentProviderId: string | null, -): { providerId: string; settingsConfig: Record } => { - const providers = readCcSwitchClaudeProviders(db); - const targetProvider = findCcSwitchProviderForConfig(providers, config, settingsCurrentProviderId, providerName); - const now = Date.now(); - const settingsConfig = mergeClaudeSettingsForWesightModel(targetProvider?.settingsConfig ?? {}, config); - const providerId = targetProvider?.id ?? randomUUID(); - const existingMeta = targetProvider?.meta ?? {}; - const meta = { - ...existingMeta, - commonConfigEnabled: true, - endpointAutoSelect: true, - apiFormat: 'anthropic', - ...(targetProvider ? {} : { managedBy: 'wesight' }), - }; - if (targetProvider) { - db - .prepare( - ` - UPDATE providers - SET name = ?, settings_config = ?, meta = ?, is_current = 1 - WHERE app_type = ? AND id = ? - `, - ) - .run( - targetProvider.name, - JSON.stringify(settingsConfig), - JSON.stringify(meta), - 'claude', - providerId, - ); - } else { - const maxSortIndex = providers.reduce((max, provider) => Math.max(max, provider.sortIndex ?? 0), 0); - db - .prepare( - ` - INSERT INTO providers ( - id, app_type, name, settings_config, category, created_at, sort_index, meta, is_current, in_failover_queue - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 0) - `, - ) - .run( - providerId, - 'claude', - buildWesightCcSwitchProviderName(providerName), - JSON.stringify(settingsConfig), - 'wesight', - now, - maxSortIndex + 1, - JSON.stringify(meta), - ); + const env = { ...getNestedRecord(existingSettings, 'env') }; + for (const key of previousEnvKeys) { + if (Object.prototype.hasOwnProperty.call(previousOriginalEnv, key)) { + env[key] = previousOriginalEnv[key]; + } else if (previousCreatedEnvKeys.includes(key) || isWesightPlaceholder(env[key])) { + delete env[key]; + } } - db - .prepare('UPDATE providers SET is_current = CASE WHEN id = ? THEN 1 ELSE 0 END WHERE app_type = ?') - .run(providerId, 'claude'); - ensureCcSwitchProviderEndpoint(db, providerId, config.baseURL); - - return { providerId, settingsConfig }; + return removeClaudeManagedMetadata(existingSettings, existingManaged, existingClaude, env); }; -const trySyncClaudeCodeThroughCcSwitchProviders = ( - config: CoworkApiConfig, - providerName: string | undefined, - primaryConfigPath: string, -): boolean => { - const { dbPath, settingsPath } = getCcSwitchPaths(); - if (!fs.existsSync(dbPath)) { - return false; - } - - let db: Database.Database | null = null; - try { - const ccSwitchSettings = readJsonObject(settingsPath) ?? {}; - db = new Database(dbPath, { fileMustExist: true }); - const settingsCurrentProviderId = getCurrentCcSwitchProviderId(ccSwitchSettings); - let providerId = ''; - let providerSettingsConfig: Record = {}; - - const transaction = db.transaction(() => { - const result = upsertCcSwitchClaudeProvider(db as Database.Database, config, providerName, settingsCurrentProviderId); - providerId = result.providerId; - providerSettingsConfig = result.settingsConfig; - }); - transaction(); - - const commonConfig = readCcSwitchCommonClaudeConfig(db); - const liveSettings = mergeClaudeSettingsWithProvider( - readJsonObject(primaryConfigPath) ?? {}, - commonConfig, - providerSettingsConfig, - ); - writeJsonObject(primaryConfigPath, liveSettings); - writeCcSwitchCurrentProviderId(settingsPath, providerId); - return true; - } catch (error) { - console.warn('[ExternalAgentConfigSync] cc-switch provider sync failed, falling back to direct Claude config sync:', error); +export const cleanupWesightManagedClaudeSettings = (settingsPath = getCliConfigPaths('claude').primaryConfigPath): boolean => { + const settings = readJsonObject(settingsPath); + if (!settings) return false; + const cleaned = removeWesightManagedClaudeSettings(settings); + if (cleaned === settings || JSON.stringify(cleaned) === JSON.stringify(settings)) { return false; - } finally { - try { - db?.close(); - } catch { - // Ignore close errors after config sync. - } } + return writeJsonObjectWithBackupIfChanged(settingsPath, cleaned); }; -const syncClaudeCodeFromWesightModel = (): void => { - const resolved = resolveCurrentApiConfig('local'); - const config = requireApiConfig(resolved); - const paths = getCliConfigPaths('claude'); - if (trySyncClaudeCodeThroughCcSwitchProviders( - config, - resolved.providerMetadata?.providerName, - paths.primaryConfigPath, - )) { - return; - } +export const createWesightClaudeSettingsBackup = ( + settingsPath = getCliConfigPaths('claude').primaryConfigPath, +): string | null => createWesightConfigFileBackup(settingsPath); - const settings = readJsonObject(paths.primaryConfigPath) ?? {}; - writeJsonObject(paths.primaryConfigPath, mergeClaudeSettingsForWesightModel(settings, config)); +const syncClaudeCodeFromWesightModel = (): void => { + console.log('[ExternalAgentConfigSync] preserving native Claude Code settings; WeSight model config will be injected at runtime.'); }; const syncCodexFromWesightModel = (): void => { - const resolved = resolveRawApiConfig(); - const config = requireApiConfig(resolved); - if (config.apiType === 'anthropic') { - throw new Error('Codex 引擎跟随 WeSight 模型设置时,需要选择 OpenAI 兼容的模型配置。'); - } - - const providerName = resolved.providerMetadata?.providerName || 'wesight'; - const paths = getCliConfigPaths('codex'); - const existingConfigText = fs.existsSync(paths.primaryConfigPath) - ? fs.readFileSync(paths.primaryConfigPath, 'utf8') - : ''; - - atomicWrite( - paths.primaryConfigPath, - mergeCodexConfigForWesightModel(existingConfigText, providerName, config.baseURL, config.model), - ); + console.log('[ExternalAgentConfigSync] preserving native Codex settings; WeSight model config will be injected at runtime.'); }; const syncCodexFromLocalCliConfig = (): void => { @@ -830,8 +801,23 @@ const syncCodexFromLocalCliConfig = (): void => { const existingConfigText = fs.readFileSync(paths.primaryConfigPath, 'utf8'); const nextConfigText = mergeCodexConfigForLocalCli(existingConfigText); if (nextConfigText !== existingConfigText) { - atomicWrite(paths.primaryConfigPath, nextConfigText); + writeTextFileWithBackupIfChanged(paths.primaryConfigPath, nextConfigText); + } +}; + +export const cleanupWesightManagedCodexConfig = ( + configPath = getCliConfigPaths('codex').primaryConfigPath, +): boolean => { + if (!fs.existsSync(configPath)) { + return false; + } + + const existingConfigText = fs.readFileSync(configPath, 'utf8'); + const nextConfigText = mergeCodexConfigForLocalCli(existingConfigText); + if (nextConfigText === existingConfigText) { + return false; } + return writeTextFileWithBackupIfChanged(configPath, nextConfigText); }; export const syncOpenCodeGlobalConfigFromWesightModel = (): void => { diff --git a/src/main/libs/externalAgentEnvironment.ts b/src/main/libs/externalAgentEnvironment.ts index fd1b7330..82f2d236 100644 --- a/src/main/libs/externalAgentEnvironment.ts +++ b/src/main/libs/externalAgentEnvironment.ts @@ -71,6 +71,8 @@ export interface ExternalAgentEnvironmentSnapshot { export interface CliProbeMetric { command: string; + path: string | null; + version: string | null; resolveMs: number; versionMs?: number; found: boolean; @@ -112,6 +114,12 @@ type ProviderRow = { name: string; }; +type CliConfigSummary = { + providerId: string | null; + providerName: string | null; + count: number; +}; + const homeDir = (): string => os.homedir(); const ccSwitchAppDir = (): string => path.join(homeDir(), '.cc-switch'); @@ -255,9 +263,100 @@ const getQwenCodeConfigDir = (): string => path.join(homeDir(), '.qwen'); const getDeepSeekTuiConfigDir = (): string => path.join(homeDir(), '.deepseek'); +const getNestedRecord = (value: unknown, key: string): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const nested = (value as Record)[key]; + return nested && typeof nested === 'object' && !Array.isArray(nested) + ? nested as Record + : {}; +}; + +const getString = (value: unknown): string => { + return typeof value === 'string' ? value.trim() : ''; +}; + +const extractTomlString = (configText: string, key: string): string => { + const match = configText.match(new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']*)["']`, 'm')); + return match?.[1]?.trim() ?? ''; +}; + +const normalizeTomlTableKey = (value: string): string => { + return value + .trim() + .replace(/^["']|["']$/g, '') + .trim(); +}; + +const listCodexModelProviderIds = (configText: string): string[] => { + const ids = new Set(); + const tablePattern = /^\s*\[model_providers\.([^\]]+)\]\s*$/gm; + let match: RegExpExecArray | null; + while ((match = tablePattern.exec(configText)) !== null) { + const id = normalizeTomlTableKey(match[1] ?? ''); + if (id) ids.add(id); + } + return [...ids]; +}; + +const readCodexModelProviderBody = (configText: string, providerId: string): string => { + const escapedProviderId = providerId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const tableMatch = configText.match( + new RegExp(`^\\s*\\[model_providers\\.(?:"${escapedProviderId}"|'${escapedProviderId}'|${escapedProviderId})\\]\\s*\\r?\\n([\\s\\S]*?)(?=\\r?\\n\\s*\\[|$)`, 'm'), + ); + return tableMatch?.[1] ?? ''; +}; + +const readClaudeNativeConfigSummary = (settingsPath: string): CliConfigSummary => { + const settings = readJsonObject(settingsPath); + if (!settings) return { providerId: null, providerName: null, count: 0 }; + const env = getNestedRecord(settings, 'env'); + const apiKey = getString(env.ANTHROPIC_AUTH_TOKEN) || getString(env.ANTHROPIC_API_KEY); + const baseUrl = getString(env.ANTHROPIC_BASE_URL); + const model = getString(env.ANTHROPIC_MODEL) + || getString(env.ANTHROPIC_DEFAULT_SONNET_MODEL) + || getString(env.ANTHROPIC_DEFAULT_OPUS_MODEL) + || getString(env.ANTHROPIC_DEFAULT_HAIKU_MODEL) + || getString(env.ANTHROPIC_SMALL_FAST_MODEL); + if (!isNonPlaceholderSecret(apiKey) || (!baseUrl && !model)) { + return { providerId: null, providerName: model || null, count: 0 }; + } + return { + providerId: 'local-live', + providerName: 'Local Claude Code', + count: 1, + }; +}; + +const readCodexNativeConfigSummary = ( + configPath: string, + authPath: string, +): CliConfigSummary => { + const configText = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : ''; + const modelProvider = extractTomlString(configText, 'model_provider'); + const model = extractTomlString(configText, 'model'); + const providerIds = listCodexModelProviderIds(configText); + const providerBody = modelProvider ? readCodexModelProviderBody(configText, modelProvider) : ''; + const providerName = extractTomlString(providerBody, 'name') || modelProvider || model || null; + if (providerIds.length > 0) { + return { + providerId: modelProvider || providerIds[0] || null, + providerName, + count: providerIds.length, + }; + } + if (modelProvider || model || fileContainsCredential(authPath)) { + return { + providerId: modelProvider || 'local-live', + providerName: providerName || 'Local Codex', + count: 1, + }; + } + return { providerId: null, providerName: null, count: 0 }; +}; + const readOpenCodeConfigSummary = ( configPath: string, -): { providerId: string | null; providerName: string | null; count: number } => { +): CliConfigSummary => { const config = readJsonObject(configPath); if (!config) { return { providerId: null, providerName: null, count: 0 }; @@ -279,7 +378,7 @@ const readOpenCodeConfigSummary = ( const readQwenCodeConfigSummary = ( configPath: string, -): { providerId: string | null; providerName: string | null; count: number } => { +): CliConfigSummary => { const config = readJsonObject(configPath); if (!config) { return { providerId: null, providerName: null, count: 0 }; @@ -309,7 +408,7 @@ const readQwenCodeConfigSummary = ( const readDeepSeekTuiConfigSummary = ( configPath: string, -): { providerId: string | null; providerName: string | null; count: number } => { +): CliConfigSummary => { if (!fs.existsSync(configPath)) { return { providerId: null, providerName: null, count: 0 }; } @@ -325,7 +424,7 @@ const readDeepSeekTuiConfigSummary = ( const readGrokBuildConfigSummary = ( configPath: string, -): { providerId: string | null; providerName: string | null; count: number } => { +): CliConfigSummary => { if (!fs.existsSync(configPath)) { return { providerId: null, providerName: null, count: 0 }; } @@ -340,7 +439,7 @@ const readGrokBuildConfigSummary = ( const readHermesConfigSummary = ( configPath: string, envPath: string, -): { providerId: string | null; providerName: string | null; count: number } => { +): CliConfigSummary => { if (!fs.existsSync(configPath)) { return { providerId: null, providerName: null, count: 0 }; } @@ -359,7 +458,7 @@ const readHermesConfigSummary = ( const readOpenClawConfigSummary = ( configPath: string, -): { providerId: string | null; providerName: string | null; count: number } => { +): CliConfigSummary => { const summary = summarizeOpenClawConfig(readOpenClawGlobalConfig(configPath)); const model = summary.currentModel; return { @@ -532,7 +631,7 @@ interface CommandResult { error: string | null; } -interface CommandResolution { +export interface CliCommandResolution { found: boolean; path: string | null; error: string | null; @@ -642,9 +741,13 @@ const getWindowsSearchPaths = (command: string): string[] => { } if (command === 'claude') { return [ + localAppData + ? path.join(localAppData, 'Programs', 'Claude', 'claude.exe') + : null, path.join(appData, 'npm', 'claude.cmd'), + path.join(home, 'AppData', 'Local', 'Programs', 'Claude', 'claude.exe'), path.join(home, '.local', 'bin', 'claude.exe'), - ]; + ].filter((candidate): candidate is string => Boolean(candidate)); } if (command === 'codex') { return [ @@ -686,18 +789,18 @@ const preferWindowsExecutable = (candidates: string[]): string | null => { ?? null; }; -const isWindowsCommandShim = (commandPath: string): boolean => { +export const isWindowsCommandShim = (commandPath: string): boolean => { return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath); }; -const buildWindowsCommandShimArgs = (commandPath: string, args: string[]): string[] => { +export const buildWindowsCommandShimArgs = (commandPath: string, args: string[]): string[] => { return ['/d', '/s', '/c', `call "${commandPath}" ${args.map((arg) => `"${arg.replace(/"/g, '\\"')}"`).join(' ')}`]; }; -const resolveCommand = async ( +export const resolveCliCommand = async ( command: string, options: ExternalAgentEnvironmentProbeOptions = {}, -): Promise => { +): Promise => { if (process.platform === 'win32') { for (const candidate of getWindowsSearchPaths(command)) { if (candidate && fs.existsSync(candidate)) { @@ -905,6 +1008,13 @@ const buildCliConfigSnapshot = ( }; } const { provider, count } = readCurrentProviderFromDb(dbPath, appType, settingsCurrentProviderId); + const nativeSummary = !provider && count === 0 + ? appType === 'claude' + ? readClaudeNativeConfigSummary(primaryConfigPath) + : appType === 'codex' + ? readCodexNativeConfigSummary(primaryConfigPath, secondaryConfigPaths[0] || path.join(configDir, 'auth.json')) + : { providerId: null, providerName: null, count: 0 } + : { providerId: null, providerName: null, count: 0 }; return { appType, @@ -912,9 +1022,9 @@ const buildCliConfigSnapshot = ( primaryConfigPath, secondaryConfigPaths, configExists: fs.existsSync(primaryConfigPath), - currentProviderId: provider?.id ?? settingsCurrentProviderId, - currentProviderName: provider?.name ?? null, - providerCount: count, + currentProviderId: provider?.id ?? settingsCurrentProviderId ?? nativeSummary.providerId, + currentProviderName: provider?.name ?? nativeSummary.providerName, + providerCount: count || nativeSummary.count, }; }; @@ -928,7 +1038,7 @@ const buildCommandStatus = ( ): Promise<{ status: CliCommandStatus; metric: CliProbeMetric }> => ( (async () => { const resolveStartedAt = Date.now(); - const resolution = await resolveCommand(command, options); + const resolution = await resolveCliCommand(command, options); const resolveMs = Date.now() - resolveStartedAt; const versionResult = resolution.found ? await readCommandVersion(resolution.path ?? command, appType, options) @@ -949,6 +1059,8 @@ const buildCommandStatus = ( }, metric: { command, + path: resolution.path, + version: versionResult.version, resolveMs, versionMs: versionResult.durationMs, found: resolution.found, diff --git a/src/main/libs/externalAgentProviderStore.ts b/src/main/libs/externalAgentProviderStore.ts index aa3d7756..589944ee 100644 --- a/src/main/libs/externalAgentProviderStore.ts +++ b/src/main/libs/externalAgentProviderStore.ts @@ -17,6 +17,9 @@ import { import { mergeClaudeSettingsForWesightModel, mergeCodexConfigForWesightModel, + removeWesightManagedClaudeSettings, + writeJsonObjectWithBackupIfChanged, + writeTextFileWithBackupIfChanged, } from './externalAgentConfigSync'; import { type CliAppType } from './externalAgentEnvironment'; import { @@ -808,6 +811,32 @@ export class ExternalAgentProviderStore { return Boolean(row.is_current); } + private selectFallbackProvider(appType: ExternalAgentProviderAppType): void { + const row = this.db + .prepare( + ` + SELECT id FROM external_agent_providers + WHERE app_type = ? + ORDER BY updated_at DESC, created_at DESC + LIMIT 1 + `, + ) + .get(appType) as { id: string } | undefined; + if (row?.id) { + this.setCurrentProvider(appType, row.id); + } + } + + private refreshLiveProviderSnapshot(appType: ExternalAgentProviderAppType): void { + if (this.importLiveProvider(appType)) { + return; + } + const deletedCurrentLiveSnapshot = this.deleteLiveProviderSnapshot(appType); + if (deletedCurrentLiveSnapshot) { + this.selectFallbackProvider(appType); + } + } + private stripInternalSettingsConfig(settingsConfig: Record): Record { const next = { ...settingsConfig }; delete next[INTERNAL_META_KEY]; @@ -937,11 +966,8 @@ export class ExternalAgentProviderStore { this.selectCcSwitchCurrentProvider(appType); } } - const hasAnyProvider = Boolean(this.db - .prepare('SELECT id FROM external_agent_providers WHERE app_type = ? LIMIT 1') - .get(appType)); - if (!hasAnyProvider || imported === 0) { - this.importLiveProviderIfEmpty(appType); + if (imported === 0) { + this.refreshLiveProviderSnapshot(appType); } } @@ -1200,7 +1226,8 @@ export class ExternalAgentProviderStore { private readLiveSettingsConfig(appType: ExternalAgentProviderAppType): Record | null { if (appType === CLAUDE_APP_TYPE) { - return readJsonObject(getClaudeSettingsPath()); + const settings = readJsonObject(getClaudeSettingsPath()); + return settings ? removeWesightManagedClaudeSettings(settings) : null; } if (appType === OPENCODE_APP_TYPE) { const config = readJsonObject(getOpenCodeConfigPath()); @@ -1273,9 +1300,10 @@ export class ExternalAgentProviderStore { const settingsConfig = this.stripInternalSettingsConfig(provider.settingsConfig); this.writeCcSwitchCurrentProvider(provider.appType, provider); if (provider.appType === CLAUDE_APP_TYPE) { - const existingConfig = readJsonObject(getClaudeSettingsPath()) ?? {}; - writeJsonFile( - getClaudeSettingsPath(), + const settingsPath = getClaudeSettingsPath(); + const existingConfig = readJsonObject(settingsPath) ?? {}; + writeJsonObjectWithBackupIfChanged( + settingsPath, mergeClaudeSettingsForWesightModel(existingConfig, { apiKey: provider.summary.apiKey, baseURL: provider.summary.baseUrl, @@ -1416,7 +1444,7 @@ export class ExternalAgentProviderStore { const existingConfigText = fs.existsSync(getCodexConfigPath()) ? fs.readFileSync(getCodexConfigPath(), 'utf8') : ''; - atomicWrite( + writeTextFileWithBackupIfChanged( getCodexConfigPath(), mergeCodexConfigForWesightModel( existingConfigText, diff --git a/src/main/main.ts b/src/main/main.ts index d55d5ae4..2f32eaa6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1725,16 +1725,27 @@ const mergeCodexAppStatus = ( const summarizeAgentEngineProbeReport = (report: ExternalAgentEnvironmentProbeReport): void => { console.debug(`[AgentEngineSnapshot] refreshed CLI environment snapshot in ${report.durationMs}ms.`); for (const metric of report.metrics) { + const durationMs = metric.resolveMs + (metric.versionMs ?? 0); + const version = metric.version ? ` (${metric.version.replace(/\s+/g, ' ').slice(0, 80)})` : ''; + const location = metric.path ? ` at ${metric.path}` : ''; + if (metric.found) { + if (metric.timedOut) { + console.debug(`[AgentEngineSnapshot] ${metric.command} found${location}${version}, but version probe timed out after ${durationMs}ms.`); + continue; + } + console.debug(`[AgentEngineSnapshot] ${metric.command} found${location}${version} in ${durationMs}ms.`); + continue; + } if (metric.timedOut) { - console.debug(`[AgentEngineSnapshot] ${metric.command} probe timed out after ${metric.resolveMs + (metric.versionMs ?? 0)}ms.`); + console.debug(`[AgentEngineSnapshot] ${metric.command} probe timed out after ${durationMs}ms.`); continue; } - if (metric.error && !metric.found) { + if (metric.error) { console.debug(`[AgentEngineSnapshot] ${metric.command} was not found after ${metric.resolveMs}ms.`); continue; } - console.debug(`[AgentEngineSnapshot] ${metric.command} probe completed in ${metric.resolveMs + (metric.versionMs ?? 0)}ms.`); - }; + console.debug(`[AgentEngineSnapshot] ${metric.command} probe completed in ${durationMs}ms.`); + } }; const broadcastAgentEngineSnapshotChanged = (response: AgentEngineSnapshotResponse): void => { diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index ba783b91..0929c37b 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -3078,7 +3078,7 @@ const Settings: React.FC = ({ onClose, initialTab, notice, notice return status.authSource; }; - const refreshAgentEnvironmentSnapshot = async (options: { forceRefresh?: boolean } = {}) => { + const refreshAgentEnvironmentSnapshot = async (options: { forceRefresh?: boolean; appTypes?: ExternalAgentProviderAppType[] } = {}) => { const snapshot = await coworkService.getAgentEngineSnapshot(options); setAgentEnvironmentSnapshot(snapshot); }; @@ -3753,6 +3753,10 @@ const Settings: React.FC = ({ onClose, initialTab, notice, notice window.dispatchEvent(new CustomEvent('wesight-agent-provider-changed', { detail: { appType: selectedExternalAgentAppType }, })); + await refreshAgentEnvironmentSnapshot({ + forceRefresh: true, + appTypes: [selectedExternalAgentAppType], + }); } finally { setAgentProviderSwitchingId(null); } diff --git a/src/renderer/components/cowork/AgentEnvironmentSetup.tsx b/src/renderer/components/cowork/AgentEnvironmentSetup.tsx index dd5bd5e4..f556c0fa 100644 --- a/src/renderer/components/cowork/AgentEnvironmentSetup.tsx +++ b/src/renderer/components/cowork/AgentEnvironmentSetup.tsx @@ -222,12 +222,12 @@ const AgentEnvironmentSetup: React.FC = ({ if (!status.config.configExists) { return 'agentSetupRepairConfigMissing'; } - if (!status.config.currentProviderId && status.config.providerCount === 0) { - return 'agentSetupRepairAuthMissing'; - } if (status.error) { return 'agentSetupRepairCliError'; } + if (status.authStatus !== 'logged_in') { + return 'agentSetupRepairAuthMissing'; + } return null; }, [getCliStatus, isSupportedInstallPlatform]); diff --git a/src/renderer/components/cowork/CoworkEngineSelector.tsx b/src/renderer/components/cowork/CoworkEngineSelector.tsx index 8676177f..f733ac1e 100644 --- a/src/renderer/components/cowork/CoworkEngineSelector.tsx +++ b/src/renderer/components/cowork/CoworkEngineSelector.tsx @@ -3,7 +3,10 @@ import { ChevronDownIcon, CpuChipIcon, } from '@heroicons/react/24/outline'; -import { CoworkAgentEngine } from '@shared/cowork/constants'; +import { + CoworkAgentEngine, + ExternalAgentConfigSource, +} from '@shared/cowork/constants'; import React from 'react'; import { useSelector } from 'react-redux'; @@ -14,6 +17,7 @@ import type { CoworkAgentEngine as CoworkAgentEngineType, ExternalAgentEnvironmentSnapshot, ExternalAgentProviderAppType, + ExternalAgentProviderListResult, } from '../../types/cowork'; interface CoworkEngineSelectorProps { @@ -141,6 +145,38 @@ const getCliAppTypeForEngine = (engine: CoworkAgentEngineType): ExternalAgentPro return null; }; +const ALL_CLI_APP_TYPES: ExternalAgentProviderAppType[] = [ + 'openclaw', + 'hermes', + 'claude', + 'codex', + 'opencode', + 'grok', + 'qwen', + 'deepseek_tui', +]; + +/** + * Partial refreshes only include the requested app types. Keep previous engine + * entries for omitted app types, and let next overwrite matching app types. + */ +const mergeSnapshots = ( + previous: ExternalAgentEnvironmentSnapshot | null, + next: ExternalAgentEnvironmentSnapshot, +): ExternalAgentEnvironmentSnapshot => { + if (!previous) return next; + const enginesByAppType = new Map(); + previous.engines.forEach((engine) => enginesByAppType.set(engine.appType, engine)); + next.engines.forEach((engine) => enginesByAppType.set(engine.appType, engine)); + return { + ...previous, + ...next, + engines: ALL_CLI_APP_TYPES + .map((appType) => enginesByAppType.get(appType)) + .filter((engine): engine is ExternalAgentEnvironmentSnapshot['engines'][number] => Boolean(engine)), + }; +}; + const CoworkEngineSelector: React.FC = ({ dropdownDirection = 'down', value, @@ -148,14 +184,15 @@ const CoworkEngineSelector: React.FC = ({ readOnlyTitle, }) => { const selectedEngine = useSelector((state: RootState) => state.cowork.config.agentEngine); + const coworkConfig = useSelector((state: RootState) => state.cowork.config); const effectiveEngine = value ?? selectedEngine; const [isOpen, setIsOpen] = React.useState(false); const [isUpdating, setIsUpdating] = React.useState(false); const [pendingEngine, setPendingEngine] = React.useState(null); const [switchError, setSwitchError] = React.useState(null); const [snapshot, setSnapshot] = React.useState(null); + const [providerLists, setProviderLists] = React.useState>>({}); const containerRef = React.useRef(null); - const hasRequestedOpenRefreshRef = React.useRef(false); const mountedRef = React.useRef(true); const selectedOption = ENGINE_OPTIONS.find((option) => option.engine === effectiveEngine) @@ -165,18 +202,50 @@ const CoworkEngineSelector: React.FC = ({ [effectiveEngine], ); - const refreshSnapshot = React.useCallback((options: { forceRefresh?: boolean } = {}) => { - if (!effectiveAppType) { + const getConfigSourceForEngine = React.useCallback((engine: CoworkAgentEngineType) => { + if (engine === CoworkAgentEngine.OpenClaw) return coworkConfig.openclawConfigSource; + if (engine === CoworkAgentEngine.ClaudeCode) return coworkConfig.claudeCodeConfigSource; + if (engine === CoworkAgentEngine.Codex) return coworkConfig.codexConfigSource; + if (engine === CoworkAgentEngine.Hermes) return coworkConfig.hermesConfigSource; + if (engine === CoworkAgentEngine.OpenCode) return coworkConfig.opencodeConfigSource; + if (engine === CoworkAgentEngine.GrokBuild) return ExternalAgentConfigSource.LocalCli; + if (engine === CoworkAgentEngine.QwenCode) return coworkConfig.qwenCodeConfigSource; + if (engine === CoworkAgentEngine.DeepSeekTui) return coworkConfig.deepseekTuiConfigSource; + return ExternalAgentConfigSource.WesightModel; + }, [coworkConfig]); + + const loadProviderList = React.useCallback(async (appType: ExternalAgentProviderAppType) => { + const result = await coworkService.listAgentProviders(appType); + if (!mountedRef.current || !result.success) return; + setProviderLists((prev) => ({ + ...prev, + [appType]: result, + })); + }, []); + + const refreshLocalProviderLists = React.useCallback(() => { + const appTypes = ENGINE_OPTIONS + .filter((option) => getConfigSourceForEngine(option.engine) === ExternalAgentConfigSource.LocalCli) + .map((option) => getCliAppTypeForEngine(option.engine)) + .filter((appType): appType is ExternalAgentProviderAppType => Boolean(appType)); + Array.from(new Set(appTypes)).forEach((appType) => { + void loadProviderList(appType); + }); + }, [getConfigSourceForEngine, loadProviderList]); + + const refreshSnapshot = React.useCallback((options: { forceRefresh?: boolean; appTypes?: ExternalAgentProviderAppType[] } = {}) => { + const appTypes = options.appTypes ?? (effectiveAppType ? [effectiveAppType] : []); + if (appTypes.length === 0) { setSnapshot(null); return Promise.resolve(); } return coworkService.getAgentEngineSnapshot({ ...options, - appTypes: [effectiveAppType], + appTypes, }) .then((nextSnapshot) => { if (mountedRef.current && nextSnapshot) { - setSnapshot(nextSnapshot); + setSnapshot((previous) => mergeSnapshots(previous, nextSnapshot)); } }) .catch(() => { @@ -191,7 +260,7 @@ const CoworkEngineSelector: React.FC = ({ void refreshSnapshot(); const unsubscribe = coworkService.onAgentEnginesChanged((nextSnapshot) => { if (mountedRef.current) { - setSnapshot(nextSnapshot); + setSnapshot((previous) => mergeSnapshots(previous, nextSnapshot)); } }); @@ -202,12 +271,38 @@ const CoworkEngineSelector: React.FC = ({ }, [refreshSnapshot]); React.useEffect(() => { - if (!isOpen || readOnly || hasRequestedOpenRefreshRef.current) { + if (!isOpen || readOnly) { return; } - hasRequestedOpenRefreshRef.current = true; - void refreshSnapshot({ forceRefresh: true }); - }, [isOpen, readOnly, refreshSnapshot]); + void refreshSnapshot({ forceRefresh: true, appTypes: ALL_CLI_APP_TYPES }); + refreshLocalProviderLists(); + }, [isOpen, readOnly, refreshLocalProviderLists, refreshSnapshot]); + + React.useEffect(() => { + if (!effectiveAppType || readOnly) { + return; + } + if (getConfigSourceForEngine(effectiveEngine) === ExternalAgentConfigSource.LocalCli) { + void loadProviderList(effectiveAppType); + } + }, [effectiveAppType, effectiveEngine, getConfigSourceForEngine, loadProviderList, readOnly]); + + React.useEffect(() => { + const handleProviderChanged = (event: Event) => { + const appType = (event as CustomEvent<{ appType?: ExternalAgentProviderAppType }>).detail?.appType; + if (appType) { + void loadProviderList(appType); + void refreshSnapshot({ forceRefresh: true, appTypes: [appType] }); + } else { + refreshLocalProviderLists(); + void refreshSnapshot({ forceRefresh: true, appTypes: ALL_CLI_APP_TYPES }); + } + }; + window.addEventListener('wesight-agent-provider-changed', handleProviderChanged); + return () => { + window.removeEventListener('wesight-agent-provider-changed', handleProviderChanged); + }; + }, [loadProviderList, refreshLocalProviderLists, refreshSnapshot]); React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -245,7 +340,14 @@ const CoworkEngineSelector: React.FC = ({ const nextSnapshot = await coworkService.getAgentEngineSnapshot({ appTypes: [appType], }); - setSnapshot(nextSnapshot); + if (nextSnapshot) { + setSnapshot((previous) => mergeSnapshots(previous, nextSnapshot)); + } else { + setSnapshot(null); + } + if (getConfigSourceForEngine(engine) === ExternalAgentConfigSource.LocalCli) { + void loadProviderList(appType); + } } else { setSnapshot(null); } @@ -263,6 +365,34 @@ const CoworkEngineSelector: React.FC = ({ return snapshot?.engines.find((item) => item.engine === engine) ?? null; }; + const getCurrentProvider = (appType: ExternalAgentProviderAppType) => { + const list = providerLists[appType]; + const providers = list?.providers ?? []; + const currentProviderId = list?.currentProviderId; + if (currentProviderId) { + return providers.find((provider) => provider.id === currentProviderId) ?? null; + } + return providers.find((provider) => provider.isCurrent) + ?? providers[0] + ?? null; + }; + + const getConfigSummary = (engine: CoworkAgentEngineType, status: CliEngineStatus | null): string | null => { + const configSource = getConfigSourceForEngine(engine); + if (!isCliEngine(engine)) return null; + if (configSource !== ExternalAgentConfigSource.LocalCli) { + return i18nService.t('coworkAgentConfigSourceWesightModel'); + } + const appType = getCliAppTypeForEngine(engine); + const provider = appType ? getCurrentProvider(appType) : null; + const providerLabel = provider + ? [provider.name, provider.summary.model].filter(Boolean).join(' · ') + : status?.config.currentProviderName + || status?.config.currentProviderId + || i18nService.t('coworkAgentLocalModelUnknown'); + return `${i18nService.t('coworkAgentConfigSourceLocalCli')} · ${providerLabel}`; + }; + const renderCliStatus = (engine: CoworkAgentEngineType) => { if (engine === CoworkAgentEngine.CodexApp) { const status = snapshot?.codexApp; @@ -280,25 +410,40 @@ const CoworkEngineSelector: React.FC = ({ } const status = getCliStatus(engine); if (!isCliEngine(engine) || !status) return null; + const configSummary = getConfigSummary(engine, status); if (!status.found) { return ( -
- - - {i18nService.t(status.checking ? 'coworkAgentEngineCliChecking' : 'coworkAgentEngineCliMissing')} - -
+ <> +
+ + + {i18nService.t(status.checking ? 'coworkAgentEngineCliChecking' : 'coworkAgentEngineCliMissing')} + +
+ {configSummary && ( +
+ {configSummary} +
+ )} + ); } const authMeta = resolveAuthMeta(status); return ( -
- - - {i18nService.t(authMeta.labelKey)} - {status.version ? ` · ${status.version}` : ''} - -
+ <> +
+ + + {i18nService.t(authMeta.labelKey)} + {status.version ? ` · ${status.version}` : ''} + +
+ {configSummary && ( +
+ {configSummary} +
+ )} + ); }; diff --git a/src/renderer/config.ts b/src/renderer/config.ts index e1e4a1e6..7d81cc19 100644 --- a/src/renderer/config.ts +++ b/src/renderer/config.ts @@ -305,9 +305,9 @@ export const defaultConfig: AppConfig = { }, model: { availableModels: [ - { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', supportsImage: false }, + { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', supportsImage: false }, ], - defaultModel: 'deepseek-reasoner', + defaultModel: 'deepseek-v4-flash', defaultModelProvider: 'deepseek', }, providers: buildDefaultProviders(), diff --git a/src/renderer/services/config.ts b/src/renderer/services/config.ts index 29ccbdf4..4f0a49a1 100644 --- a/src/renderer/services/config.ts +++ b/src/renderer/services/config.ts @@ -102,8 +102,12 @@ const migrateCustomProviders = (config: AppConfig): AppConfig => { // Model IDs that have been removed from specific providers. // These will be filtered out from saved configs during migration. const REMOVED_PROVIDER_MODELS: Record = { - deepseek: ['deepseek-chat'], - openai: ['gpt-5.2-2025-12-11'], + deepseek: ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v3.2-exp'], + openai: ['gpt-5.2-2025-12-11', 'gpt-5.2'], + 'github-copilot': ['gpt-4o'], + minimax: ['MiniMax-M2.5', 'MiniMax-text-01', 'abab7-chat-preview'], + zhipu: ['glm-4.5', 'glm-4.6'], + moonshot: ['kimi-k2.5'], }; // Models to inject into existing saved configs (for existing users). @@ -113,17 +117,73 @@ const REMOVED_PROVIDER_MODELS: Record = { // so the models follow normal user-editable behavior (same as other models). // position: 'start' inserts at the beginning, 'end' appends at the end. const ADDED_PROVIDER_MODELS: Record; position: 'start' | 'end' }> = { + deepseek: { + models: [ + { id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro', supportsImage: false }, + { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', supportsImage: false }, + ], + position: 'start', + }, minimax: { models: [ - { id: 'MiniMax-M2.7', name: 'MiniMax M2.7', supportsImage: false }, + { id: 'MiniMax-M3.0', name: 'MiniMax M3.0', supportsImage: false }, + ], + position: 'start', + }, + zhipu: { + models: [ + { id: 'glm-5.1', name: 'GLM 5.1', supportsImage: false }, + { id: 'glm-4.7-flash', name: 'GLM 4.7 Flash', supportsImage: false }, + ], + position: 'start', + }, + xiaomi: { + models: [ + { id: 'mimo-v2.5-pro', name: 'MiMo V2.5 Pro', supportsImage: false }, + { id: 'mimo-v2-pro', name: 'MiMo V2 Pro', supportsImage: false }, + ], + position: 'start', + }, + anthropic: { + models: [ + { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', supportsImage: true }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', supportsImage: true }, + ], + position: 'start', + }, + 'github-copilot': { + models: [ + { id: 'gpt-5', name: 'GPT-5', supportsImage: true }, + { id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', supportsImage: true }, + { id: 'claude-opus-4.8', name: 'Claude Opus 4.8', supportsImage: true }, + { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', supportsImage: true }, ], position: 'start', }, openai: { models: [ - { id: 'gpt-5.4', name: 'GPT-5.4', supportsImage: true }, - { id: 'gpt-5.2', name: 'GPT-5.2', supportsImage: true }, - { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', supportsImage: true }, + { id: 'gpt-5.5', name: 'GPT-5.5', supportsImage: true }, + { id: 'gpt-5.4-mini', name: 'GPT-5.4 mini', supportsImage: true }, + ], + position: 'start', + }, + moonshot: { + models: [ + { id: 'kimi-k2.6', name: 'Kimi K2.6', supportsImage: true }, + ], + position: 'start', + }, + qwen: { + models: [ + { id: 'qwen3-max', name: 'Qwen3 Max', supportsImage: true }, + { id: 'qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B', supportsImage: false }, + ], + position: 'start', + }, + gemini: { + models: [ + { id: 'gemini-3.5-flash', name: 'Gemini 3.5 Flash', supportsImage: true }, + { id: 'gemini-3.1-flash-lite', name: 'Gemini 3.1 Flash Lite', supportsImage: true }, ], position: 'start', }, diff --git a/src/renderer/services/i18n.ts b/src/renderer/services/i18n.ts index 620da51d..c3b85f3c 100644 --- a/src/renderer/services/i18n.ts +++ b/src/renderer/services/i18n.ts @@ -662,7 +662,7 @@ const translations: Record> = { coworkAgentCurrentModelBaseUrl: 'Base URL', coworkAgentConfigImportModel: '导入到模型设置', coworkAgentConfigImportModelImporting: '正在导入...', - coworkAgentConfigImportModelHint: '已有本机 CLI API 配置时,可一次性导入为“设置 > 模型”里的自定义 Provider。', + coworkAgentConfigImportModelHint: '将本机 CLI API 配置复制到“设置 > 模型”的自定义 Provider;不会切换当前 Agent 配置来源。', coworkAgentConfigImportModelSuccess: '已导入到模型设置,请在“模型”页确认启用与默认模型。', coworkAgentConfigImportModelDuplicate: '模型设置中已有相同配置,已跳过重复导入。', coworkAgentConfigImportModelFailed: '本机配置导入失败', @@ -2552,7 +2552,7 @@ const translations: Record> = { coworkAgentCurrentModelBaseUrl: 'Base URL', coworkAgentConfigImportModel: 'Import to Model Settings', coworkAgentConfigImportModelImporting: 'Importing...', - coworkAgentConfigImportModelHint: 'If the local CLI already has API config, import it once as a custom provider in Settings > Model.', + coworkAgentConfigImportModelHint: 'Copy the local CLI API config into a custom provider in Settings > Model; this does not switch the current agent config source.', coworkAgentConfigImportModelSuccess: 'Imported to model settings. Review the provider and default model in the Model page.', coworkAgentConfigImportModelDuplicate: 'The same model config already exists, so no duplicate was added.', coworkAgentConfigImportModelFailed: 'Failed to import local config', diff --git a/src/shared/cowork/runtimeMetrics.ts b/src/shared/cowork/runtimeMetrics.ts index dc67669c..4b476af0 100644 --- a/src/shared/cowork/runtimeMetrics.ts +++ b/src/shared/cowork/runtimeMetrics.ts @@ -197,28 +197,10 @@ const calculateMeasuredOutputWindowMs = (record: RuntimeCallRecord): number | nu return Math.max(record.lastOutputAt - record.firstOutputAt, MIN_MEASURED_OUTPUT_WINDOW_MS); }; -const calculateRuntimeOutputWindowMs = (record: RuntimeCallRecord): number | null => { - const measuredWindowMs = calculateMeasuredOutputWindowMs(record); - if (measuredWindowMs) return measuredWindowMs; - if (record.firstOutputAt && record.completedAt && record.completedAt > record.firstOutputAt) { - return Math.max(record.completedAt - record.firstOutputAt, MIN_MEASURED_OUTPUT_WINDOW_MS); - } - if (record.durationMs && record.ttftMs !== null && record.ttftMs !== undefined) { - const postTtftMs = record.durationMs - record.ttftMs; - if (postTtftMs > 0) { - return Math.max(postTtftMs, MIN_MEASURED_OUTPUT_WINDOW_MS); - } - } - if (record.durationMs) { - return Math.max(record.durationMs, MIN_MEASURED_OUTPUT_WINDOW_MS); - } - return null; -}; - export const calculateRuntimeTps = (record: RuntimeCallRecord): number | null => { const visibleOutputTokens = getVisibleOutputTokensForTps(record); if (!visibleOutputTokens) return null; - const outputWindowMs = calculateRuntimeOutputWindowMs(record); + const outputWindowMs = calculateMeasuredOutputWindowMs(record); if (!outputWindowMs) return null; const seconds = Math.max(outputWindowMs / 1000, MIN_MEASURED_OUTPUT_WINDOW_MS / 1000); return visibleOutputTokens / seconds; diff --git a/src/shared/providers/constants.ts b/src/shared/providers/constants.ts index db63801c..2a132b2f 100644 --- a/src/shared/providers/constants.ts +++ b/src/shared/providers/constants.ts @@ -177,7 +177,10 @@ const PROVIDER_DEFINITIONS = [ }, region: 'china', enPriority: 0, - defaultModels: [{ id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', supportsImage: false }], + defaultModels: [ + { id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro', supportsImage: false }, + { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', supportsImage: false }, + ], }, { id: ProviderName.Moonshot, @@ -199,8 +202,8 @@ const PROVIDER_DEFINITIONS = [ }, region: 'china', enPriority: 0, - defaultModels: [{ id: 'kimi-k2.5', name: 'Kimi K2.5', supportsImage: true }], - codingPlanModels: [{ id: 'kimi-for-coding', name: 'Kimi K2.5', supportsImage: true }], + defaultModels: [{ id: 'kimi-k2.6', name: 'Kimi K2.6', supportsImage: true }], + codingPlanModels: [{ id: 'kimi-for-coding', name: 'Kimi K2.6', supportsImage: true }], }, { id: ProviderName.Qwen, @@ -221,7 +224,9 @@ const PROVIDER_DEFINITIONS = [ enPriority: 0, defaultModels: [ { id: 'qwen3.5-plus', name: 'Qwen3.5 Plus', supportsImage: true }, + { id: 'qwen3-max', name: 'Qwen3 Max', supportsImage: true }, { id: 'qwen3-coder-plus', name: 'Qwen3 Coder Plus', supportsImage: false }, + { id: 'qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B', supportsImage: false }, ], }, { @@ -241,8 +246,10 @@ const PROVIDER_DEFINITIONS = [ region: 'china', enPriority: 0, defaultModels: [ + { id: 'glm-5.1', name: 'GLM 5.1', supportsImage: false }, { id: 'glm-5', name: 'GLM 5', supportsImage: false }, { id: 'glm-4.7', name: 'GLM 4.7', supportsImage: false }, + { id: 'glm-4.7-flash', name: 'GLM 4.7 Flash', supportsImage: false }, ], }, { @@ -259,7 +266,7 @@ const PROVIDER_DEFINITIONS = [ enPriority: 0, defaultModels: [ { id: 'MiniMax-M2.7', name: 'MiniMax M2.7', supportsImage: false }, - { id: 'MiniMax-M2.5', name: 'MiniMax M2.5', supportsImage: false }, + { id: 'MiniMax-M3.0', name: 'MiniMax M3.0', supportsImage: false }, ], }, { @@ -326,7 +333,11 @@ const PROVIDER_DEFINITIONS = [ }, region: 'china', enPriority: 0, - defaultModels: [{ id: 'mimo-v2-flash', name: 'MiMo V2 Flash', supportsImage: false }], + defaultModels: [ + { id: 'mimo-v2.5-pro', name: 'MiMo V2.5 Pro', supportsImage: false }, + { id: 'mimo-v2-flash', name: 'MiMo V2 Flash', supportsImage: false }, + { id: 'mimo-v2-pro', name: 'MiMo V2 Pro', supportsImage: false }, + ], }, { id: ProviderName.Ollama, @@ -355,10 +366,13 @@ const PROVIDER_DEFINITIONS = [ region: 'global', enPriority: 0, defaultModels: [ + { id: 'gpt-5', name: 'GPT-5', supportsImage: true }, { id: 'gpt-5-mini', name: 'GPT-5 mini', supportsImage: true }, + { id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', supportsImage: true }, + { id: 'claude-opus-4.8', name: 'Claude Opus 4.8', supportsImage: true }, { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', supportsImage: true }, + { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', supportsImage: true }, { id: 'gpt-4.1', name: 'GPT-4.1', supportsImage: true }, - { id: 'gpt-4o', name: 'GPT-4o', supportsImage: true }, ], }, { @@ -370,8 +384,9 @@ const PROVIDER_DEFINITIONS = [ region: 'global', enPriority: 1, defaultModels: [ + { id: 'gpt-5.5', name: 'GPT-5.5', supportsImage: true }, { id: 'gpt-5.4', name: 'GPT-5.4', supportsImage: true }, - { id: 'gpt-5.2', name: 'GPT-5.2', supportsImage: true }, + { id: 'gpt-5.4-mini', name: 'GPT-5.4 mini', supportsImage: true }, { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', supportsImage: true }, { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', supportsImage: true }, ], @@ -385,9 +400,11 @@ const PROVIDER_DEFINITIONS = [ region: 'global', enPriority: 3, defaultModels: [ - { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', supportsImage: true }, + { id: 'gemini-3.5-flash', name: 'Gemini 3.5 Flash', supportsImage: true }, { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', supportsImage: true }, + { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', supportsImage: true }, { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', supportsImage: true }, + { id: 'gemini-3.1-flash-lite', name: 'Gemini 3.1 Flash Lite', supportsImage: true }, ], }, { @@ -399,9 +416,11 @@ const PROVIDER_DEFINITIONS = [ region: 'global', enPriority: 2, defaultModels: [ - { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', supportsImage: true }, + { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', supportsImage: true }, { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', supportsImage: true }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', supportsImage: true }, { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', supportsImage: true }, + { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', supportsImage: true }, ], }, { From 40ed4b27643d8866ee6b490323b51b3bb990a4cd Mon Sep 17 00:00:00 2001 From: Activer <8515500@gmail.com> Date: Sun, 7 Jun 2026 10:32:05 +0800 Subject: [PATCH 02/13] test(cowork): add external cli smoke and proxy coverage --- ...sight-agent-cli-programmatic-smoke-test.md | 198 +++++++ scripts/wesight-agent-cli-smoke.cjs | 382 +++++++++++++ .../externalCliRuntimeAdapter.test.ts | 509 +++++++++++++++++- src/main/libs/claudeSettings.test.ts | 133 ++++- src/main/libs/coworkOpenAICompatProxy.test.ts | 198 +++++++ src/main/libs/externalAgentConfigSync.test.ts | 276 +++++++++- .../libs/externalAgentEnvironment.test.ts | 160 +++++- src/main/runtimeTelemetryStore.test.ts | 41 +- 8 files changed, 1877 insertions(+), 20 deletions(-) create mode 100644 dev-docs/wesight-agent-cli-programmatic-smoke-test.md create mode 100644 scripts/wesight-agent-cli-smoke.cjs create mode 100644 src/main/libs/coworkOpenAICompatProxy.test.ts diff --git a/dev-docs/wesight-agent-cli-programmatic-smoke-test.md b/dev-docs/wesight-agent-cli-programmatic-smoke-test.md new file mode 100644 index 00000000..2e5e0ac9 --- /dev/null +++ b/dev-docs/wesight-agent-cli-programmatic-smoke-test.md @@ -0,0 +1,198 @@ +# WeSight Agent CLI 程序化真实冒烟测试说明 + +## 背景 + +本次修复围绕 Agent Engine 在“跟随 WeSight 模型设置”时的真实运行链路,重点验证: + +- Claude Code 使用 WeSight 配置时,不污染本地 `~/.claude/settings.json`。 +- Codex CLI 使用 WeSight 配置时,只使用临时 `CODEX_HOME`,不污染本地 `~/.codex/config.toml` / `auth.json`。 +- Codex CLI 遇到 WeSight 当前 provider 为 Anthropic-compatible 配置时,自动切换到同 provider 预置的 OpenAI-compatible endpoint,再通过 WeSight proxy 调用。 +- 真实启动 Claude Code 和 Codex CLI,而不是只跑单元测试或 mock。 + +## 测试脚本 + +新增脚本: + +```text +scripts/wesight-agent-cli-smoke.cjs +``` + +脚本运行在 Electron runtime 中,而不是普通 Node 进程中。原因是 WeSight OpenAI compatibility proxy 使用 Electron 的 `session.defaultSession.fetch`,真实测试需要处在 Electron 环境内才能复用完整链路。 + +脚本会: + +- 只读正式 WeSight DB:`%APPDATA%\WeSight\wesight.sqlite`。 +- 读取正式 `app_config` 中 provider 的 API key、baseUrl、apiFormat、models。 +- 创建临时 userData 目录和临时 SQLite DB。 +- 将单个 provider 配置写入临时 DB,并按测试 case 切换 `apiFormat`。 +- 启动 `startCoworkOpenAICompatProxy()`。 +- 使用 `ExternalCliRuntimeAdapter` 真实启动 Claude Code / Codex CLI。 +- 创建临时 workspace 和 Cowork session。 +- 发送带 `WESIGHT_SMOKE_OK` 标记要求的 prompt。 +- 最后校验本地 CLI 配置文件 hash 是否保持不变。 + +正式 DB 不会写入测试会话;MiniMax 这类正式配置中 `enabled=false` 但已有 API key/model 的 provider,会只在临时 DB 中启用用于测试。 + +## 测试设计原则 + +这次程序化测试不是单元测试的替代,而是补齐“真实 CLI + 真实 provider + WeSight proxy + 临时配置隔离”的端到端验证。 + +核心原则: + +- 使用真实 Claude Code / Codex CLI 进程,避免只验证 mock adapter。 +- 读取 WeSight 正式 DB 中已经配置好的 provider,避免人工重新录入测试配置导致偏差。 +- 所有会被测试流程修改的状态都放到临时 userData、临时 DB、临时 workspace 中。 +- Codex 使用 WeSight 设置时必须走临时 `CODEX_HOME`,不能改写用户本地 `~/.codex`。 +- Claude Code 使用 WeSight 设置时必须通过环境变量和临时上下文注入,不能改写用户本地 `~/.claude/settings.json`。 +- 每次测试前后对本地 Claude/Codex 配置文件做 hash 对比,把“配置不被污染”作为验收条件。 +- Codex 不测试 `local_cli` 直连 OpenAI 账号,因为该链路不经过 WeSight provider/proxy,不能证明本次修复是否有效。 + +适合验证的问题: + +- “跟随 WeSight 设置”是否真实调用了当前 provider。 +- Anthropic-compatible provider 是否能为 Codex 自动切换到同 provider 的 OpenAI-compatible endpoint。 +- WeSight proxy 是否把 Codex `/responses` 请求正确转成上游 `/v1/chat/completions`。 +- CLI 进程结束后,Cowork session 是否收到 `complete` 并落到 `completed` 状态。 +- 本地 CLI 配置是否保持不变。 + +不适合验证的问题: + +- 第三方模型内容质量。 +- Codex 使用用户本地 OpenAI/ChatGPT 账号的原生能力。 +- UI 交互细节,例如按钮状态、滚动、输入框禁用状态。 +- provider 长时间稳定性或并发压测。 + +## 常用命令 + +先编译 Electron 主进程: + +```bash +npx tsc -p electron-tsconfig.json +``` + +列出正式 DB 中 provider 摘要,不输出 API key: + +```powershell +$env:WESIGHT_SMOKE_LIST_PROVIDERS='1' +npx electron scripts/wesight-agent-cli-smoke.cjs +``` + +跑 DeepSeek 最小关键 case: + +```powershell +$env:WESIGHT_SMOKE_PROVIDERS='deepseek' +$env:WESIGHT_SMOKE_FORMATS='anthropic' +$env:WESIGHT_SMOKE_ENGINES='codex' +$env:WESIGHT_SMOKE_TIMEOUT_MS='300000' +npx electron scripts/wesight-agent-cli-smoke.cjs +``` + +跑完整 DeepSeek + MiniMax 矩阵: + +```powershell +$env:WESIGHT_SMOKE_PROVIDERS='deepseek,minimax' +$env:WESIGHT_SMOKE_FORMATS='anthropic,openai' +$env:WESIGHT_SMOKE_ENGINES='claude,codex' +$env:WESIGHT_SMOKE_TIMEOUT_MS='300000' +npx electron scripts/wesight-agent-cli-smoke.cjs +``` + +可选环境变量: + +```text +WESIGHT_SMOKE_PROVIDERS 默认 deepseek,minimax +WESIGHT_SMOKE_FORMATS 默认 anthropic,openai +WESIGHT_SMOKE_ENGINES 默认 claude,codex +WESIGHT_SMOKE_TIMEOUT_MS 默认 300000 +WESIGHT_SMOKE_LIST_PROVIDERS 设置为 1 时只列 provider,不发起模型请求 +WESIGHT_SMOKE_KEEP_TEMP 设置为 1 时保留临时目录 +WESIGHT_SMOKE_PROMPT 覆盖默认测试 prompt +WESIGHT_SMOKE_USER_DATA 覆盖正式 WeSight userData 路径 +``` + +## 本次真实测试结果 + +本次测试真实启动了 Claude Code 和 Codex CLI,并真实调用 DeepSeek / MiniMax provider。 + +通过的 case: + +```text +DeepSeek + Anthropic-compatible + Claude Code +DeepSeek + Anthropic-compatible + Codex CLI +DeepSeek + OpenAI-compatible + Claude Code +DeepSeek + OpenAI-compatible + Codex CLI +MiniMax + Anthropic-compatible + Claude Code +MiniMax + Anthropic-compatible + Codex CLI +MiniMax + OpenAI-compatible + Claude Code +MiniMax + OpenAI-compatible + Codex CLI +``` + +关键日志证据: + +```text +[ExternalCliRuntimeAdapter] starting Codex CLI. +configSource: 'wesight_model' +usesTemporaryCodexHome: true +codexServerUrl: 'http://127.0.0.1:/' +wireApi: 'responses' +``` + +DeepSeek Anthropic-compatible 配置下,Codex 自动切换并通过 WeSight proxy 转发到: + +```text +[CoworkProxy] Responses compat → https://api.deepseek.com/v1/chat/completions (provider: deepseek) +``` + +MiniMax Anthropic-compatible 配置下,Codex 自动切换并通过 WeSight proxy 转发到: + +```text +[CoworkProxy] Responses compat → https://api.minimaxi.com/v1/chat/completions (provider: minimax) +``` + +本地 CLI 配置保护校验通过: + +```json +{ + "claudeSettings": true, + "codexConfig": true, + "codexAuth": true +} +``` + +含义: + +- `~/.claude/settings.json` 测试前后 hash 不变。 +- `~/.codex/config.toml` 测试前后 hash 不变。 +- `~/.codex/auth.json` 测试前后 hash 不变。 + +## 验收标准 + +每个通过的 case 满足: + +- session 状态为 `completed`。 +- 收到 runtime `complete` 事件。 +- assistant 输出非空。 +- assistant 输出包含 `WESIGHT_SMOKE_OK`。 +- Codex case 使用临时 `CODEX_HOME`。 +- Codex case 的 `base_url` 指向 WeSight local proxy。 +- proxy upstream 指向 DeepSeek/MiniMax OpenAI-compatible endpoint。 +- 本地 Claude/Codex 配置文件 hash 不变。 + +## 注意事项 + +- 该脚本会真实调用配置的第三方大模型,可能产生 token 消耗。 +- 不测试 Codex `local_cli` 直连 OpenAI 原始本地账号,因为这与 WeSight 配置链路无关。 +- MiniMax 当前正式配置中 `enabled=false`,但已有 API key 和模型。本次脚本仅在临时 DB 中启用 MiniMax 进行测试,不修改正式 DB。 +- Windows 上临时目录偶尔会因为文件锁导致清理时报 `EPERM`。脚本已将清理改为 best-effort;如有残留,可稍后手动删除 `%TEMP%\wesight-agent-cli-smoke-*`。 + +## 相关验证命令 + +本次配套代码验证: + +```bash +npx vitest run src/main/libs/claudeSettings.test.ts src/main/libs/agentEngine/externalCliRuntimeAdapter.test.ts src/main/libs/coworkOpenAICompatProxy.test.ts +npx tsc -p electron-tsconfig.json +npx eslint src/main/libs/claudeSettings.ts src/main/libs/claudeSettings.test.ts src/main/libs/agentEngine/externalCliRuntimeAdapter.ts src/main/libs/agentEngine/externalCliRuntimeAdapter.test.ts +``` + +lint 当前只有既有 `any` warning,没有 error。 diff --git a/scripts/wesight-agent-cli-smoke.cjs b/scripts/wesight-agent-cli-smoke.cjs new file mode 100644 index 00000000..11f95d38 --- /dev/null +++ b/scripts/wesight-agent-cli-smoke.cjs @@ -0,0 +1,382 @@ +/* eslint-env node */ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Database = require('better-sqlite3'); +const { app } = require('electron'); + +const repoRoot = path.resolve(__dirname, '..'); +const distRoot = path.join(repoRoot, 'dist-electron', 'src'); + +if (!fs.existsSync(path.join(distRoot, 'main', 'libs', 'claudeSettings.js'))) { + console.error(`Compiled Electron files were not found under ${distRoot}. Run: npx tsc -p electron-tsconfig.json`); + process.exit(1); +} + +const { + CoworkAgentEngine, + ExternalAgentConfigSource, +} = require(path.join(distRoot, 'shared', 'cowork', 'constants.js')); +const { ProviderRegistry } = require(path.join(distRoot, 'shared', 'providers', 'constants.js')); +const { SqliteStore } = require(path.join(distRoot, 'main', 'sqliteStore.js')); +const { CoworkStore } = require(path.join(distRoot, 'main', 'coworkStore.js')); +const { + startCoworkOpenAICompatProxy, + stopCoworkOpenAICompatProxy, +} = require(path.join(distRoot, 'main', 'libs', 'coworkOpenAICompatProxy.js')); +const { setStoreGetter } = require(path.join(distRoot, 'main', 'libs', 'claudeSettings.js')); +const { ExternalCliRuntimeAdapter } = require(path.join( + distRoot, + 'main', + 'libs', + 'agentEngine', + 'externalCliRuntimeAdapter.js', +)); + +const providerIds = parseCsv(process.env.WESIGHT_SMOKE_PROVIDERS || 'deepseek,minimax'); +const formats = parseCsv(process.env.WESIGHT_SMOKE_FORMATS || 'anthropic,openai'); +const engines = parseCsv(process.env.WESIGHT_SMOKE_ENGINES || 'claude,codex'); +const timeoutMs = Number(process.env.WESIGHT_SMOKE_TIMEOUT_MS || 5 * 60 * 1000); +const keepTemp = process.env.WESIGHT_SMOKE_KEEP_TEMP === '1'; +const listProvidersOnly = process.env.WESIGHT_SMOKE_LIST_PROVIDERS === '1'; +const prompt = process.env.WESIGHT_SMOKE_PROMPT || [ + 'You are running a WeSight external CLI smoke test.', + 'Do not create, edit, delete, or inspect files.', + 'Return only a compact JSON object with these keys:', + 'marker, engine, provider, apiFormat, note.', + 'The marker value must be exactly "WESIGHT_SMOKE_OK".', + 'The note value must be one short Chinese sentence.', +].join('\n'); + +function parseCsv(value) { + return value + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +function normalizePathForDisplay(value) { + return value.replace(/\\/g, '/'); +} + +function readJsonValueFromDb(dbPath, key) { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + const row = db.prepare('SELECT value FROM kv WHERE key = ?').get(key); + if (!row?.value) return null; + return JSON.parse(row.value); + } finally { + db.close(); + } +} + +function hashFile(filePath) { + if (!fs.existsSync(filePath)) return null; + const stat = fs.statSync(filePath); + if (!stat.isFile()) return null; + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +function hashLocalCliConfigs() { + const home = os.homedir(); + return { + claudeSettings: hashFile(path.join(home, '.claude', 'settings.json')), + codexConfig: hashFile(path.join(home, '.codex', 'config.toml')), + codexAuth: hashFile(path.join(home, '.codex', 'auth.json')), + }; +} + +function compareHashes(before, after) { + return Object.fromEntries( + Object.keys(before).map((key) => [key, before[key] === after[key]]), + ); +} + +function buildOfficialUserDataPath() { + return path.join(app.getPath('appData'), 'WeSight'); +} + +function buildProviderConfig(appConfig, providerId, apiFormat) { + const provider = appConfig.providers?.[providerId]; + if (!provider) { + throw new Error(`Provider ${providerId} is not configured in app_config.`); + } + if (!provider.apiKey?.trim() && providerId !== 'ollama') { + throw new Error(`Provider ${providerId} is missing an API key.`); + } + const models = Array.isArray(provider.models) + ? provider.models.filter((model) => typeof model?.id === 'string' && model.id.trim()) + : []; + if (models.length === 0) { + throw new Error(`Provider ${providerId} has no configured models.`); + } + + const currentModel = appConfig.model?.defaultModel; + const preferred = provider.models.find((model) => model.id === currentModel); + const model = preferred?.id || models[0].id; + const switchableBaseUrl = ProviderRegistry.getSwitchableBaseUrl(providerId, apiFormat); + const baseUrl = switchableBaseUrl || provider.baseUrl; + if (!baseUrl?.trim()) { + throw new Error(`Provider ${providerId} has no ${apiFormat} base URL.`); + } + + const nextProvider = { + ...provider, + enabled: true, + apiFormat, + baseUrl, + codingPlanEnabled: false, + models, + }; + return { + appConfig: { + ...appConfig, + model: { + ...(appConfig.model || {}), + defaultModel: model, + defaultModelProvider: providerId, + }, + providers: { + [providerId]: nextProvider, + }, + }, + model, + baseUrl, + wasEnabled: Boolean(provider.enabled), + }; +} + +function createRuntime(engine, coworkStore) { + return new ExternalCliRuntimeAdapter({ + engine: engine === 'claude' ? CoworkAgentEngine.ClaudeCode : CoworkAgentEngine.Codex, + store: coworkStore, + }); +} + +async function runRuntimeCase({ engine, providerId, apiFormat, tempRoot, tempStore, coworkStore }) { + const workspace = path.join(tempRoot, 'workspace', `${providerId}-${apiFormat}-${engine}`); + fs.mkdirSync(workspace, { recursive: true }); + const session = coworkStore.createSession( + `Smoke ${engine} ${providerId} ${apiFormat}`, + workspace, + '', + 'local', + [], + 'main', + ); + const runtime = createRuntime(engine, coworkStore); + const events = []; + const errors = []; + let complete = false; + + runtime.on('message', (_sessionId, message) => { + events.push({ type: 'message', messageType: message.type, chars: message.content.length }); + }); + runtime.on('messageUpdate', (_sessionId, _messageId, content) => { + events.push({ type: 'messageUpdate', chars: content.length }); + }); + runtime.on('complete', () => { + complete = true; + }); + runtime.on('error', (_sessionId, error) => { + errors.push(error); + }); + + const runPrompt = [ + prompt, + '', + `Smoke metadata: engine=${engine}; provider=${providerId}; apiFormat=${apiFormat}.`, + ].join('\n'); + + const runPromise = runtime.startSession(session.id, runPrompt, { + systemPrompt: 'You are a smoke-test responder. Do not use tools. Do not modify files.', + runtimeSnapshot: { + configSource: ExternalAgentConfigSource.WesightModel, + modelId: tempStore.get('app_config')?.model?.defaultModel, + providerKey: providerId, + providerName: providerId, + }, + }); + await withTimeout(runPromise, timeoutMs, `${engine}/${providerId}/${apiFormat} timed out`); + + const finalSession = coworkStore.getSession(session.id); + const assistantOutput = (finalSession?.messages || []) + .filter((message) => message.type === 'assistant') + .map((message) => message.content) + .join('\n'); + const systemOutput = (finalSession?.messages || []) + .filter((message) => message.type === 'system') + .map((message) => message.content) + .join('\n'); + + return { + engine, + provider: providerId, + apiFormat, + status: finalSession?.status || 'missing', + complete, + assistantChars: assistantOutput.length, + hasMarker: assistantOutput.includes('WESIGHT_SMOKE_OK'), + errors, + systemTail: systemOutput.slice(-1000), + eventCount: events.length, + }; +} + +async function withTimeout(promise, ms, message) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function run() { + app.setName('WeSight'); + await app.whenReady(); + + const officialUserDataPath = process.env.WESIGHT_SMOKE_USER_DATA || buildOfficialUserDataPath(); + const officialDbPath = path.join(officialUserDataPath, 'wesight.sqlite'); + if (!fs.existsSync(officialDbPath)) { + throw new Error(`WeSight DB not found: ${officialDbPath}`); + } + const sourceAppConfig = readJsonValueFromDb(officialDbPath, 'app_config'); + if (!sourceAppConfig?.providers) { + throw new Error(`app_config.providers not found in ${officialDbPath}`); + } + + if (listProvidersOnly) { + console.log(JSON.stringify({ + officialDbPath: normalizePathForDisplay(officialDbPath), + providers: Object.entries(sourceAppConfig.providers).map(([key, provider]) => ({ + key, + enabled: Boolean(provider?.enabled), + apiFormat: provider?.apiFormat || null, + hasApiKey: Boolean(provider?.apiKey && String(provider.apiKey).trim()), + baseUrl: provider?.baseUrl || '', + modelCount: Array.isArray(provider?.models) ? provider.models.length : 0, + models: Array.isArray(provider?.models) + ? provider.models.map((model) => model?.id).filter(Boolean).slice(0, 8) + : [], + })), + }, null, 2)); + return; + } + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-agent-cli-smoke-')); + const tempUserData = path.join(tempRoot, 'userData'); + fs.mkdirSync(tempUserData, { recursive: true }); + app.setPath('userData', tempUserData); + + const localHashesBefore = hashLocalCliConfigs(); + const sqliteStore = SqliteStore.create(tempUserData); + const coworkStore = new CoworkStore(sqliteStore.getDatabase()); + setStoreGetter(() => sqliteStore); + + const results = []; + try { + await startCoworkOpenAICompatProxy(); + coworkStore.setConfig({ + workingDirectory: path.join(tempRoot, 'workspace'), + claudeCodeConfigSource: ExternalAgentConfigSource.WesightModel, + codexConfigSource: ExternalAgentConfigSource.WesightModel, + }); + + for (const providerId of providerIds) { + for (const apiFormat of formats) { + let providerCase; + try { + providerCase = buildProviderConfig(sourceAppConfig, providerId, apiFormat); + } catch (error) { + results.push({ + provider: providerId, + apiFormat, + status: 'blocked', + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + sqliteStore.set('app_config', providerCase.appConfig); + + for (const engine of engines) { + try { + const result = await runRuntimeCase({ + engine, + providerId, + apiFormat, + tempRoot, + tempStore: sqliteStore, + coworkStore, + }); + results.push({ + ...result, + model: providerCase.model, + configuredBaseUrl: providerCase.baseUrl, + providerWasEnabled: providerCase.wasEnabled, + }); + } catch (error) { + results.push({ + engine, + provider: providerId, + apiFormat, + model: providerCase.model, + configuredBaseUrl: providerCase.baseUrl, + providerWasEnabled: providerCase.wasEnabled, + status: 'error', + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + } + } finally { + stopCoworkOpenAICompatProxy(); + sqliteStore.close(); + setStoreGetter(() => null); + } + + const localHashesAfter = hashLocalCliConfigs(); + const summary = { + ok: results.every((result) => ( + result.status === 'completed' + && result.complete === true + && result.hasMarker === true + )), + officialDbPath: normalizePathForDisplay(officialDbPath), + tempRoot: normalizePathForDisplay(tempRoot), + localCliConfigHashesUnchanged: compareHashes(localHashesBefore, localHashesAfter), + results, + }; + console.log(JSON.stringify(summary, null, 2)); + + if (!keepTemp) { + try { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } catch (error) { + console.warn(`Failed to remove temp smoke directory ${tempRoot}:`, error); + } + } + + if (!summary.ok) { + process.exitCode = 1; + } +} + +run() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => { + app.quit(); + }); diff --git a/src/main/libs/agentEngine/externalCliRuntimeAdapter.test.ts b/src/main/libs/agentEngine/externalCliRuntimeAdapter.test.ts index 40b4db12..d40a3a45 100644 --- a/src/main/libs/agentEngine/externalCliRuntimeAdapter.test.ts +++ b/src/main/libs/agentEngine/externalCliRuntimeAdapter.test.ts @@ -1,10 +1,15 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { describe, expect, test, vi } from 'vitest'; import { CoworkAgentEngine, ExternalAgentConfigSource, } from '../../../shared/cowork/constants'; +import { ProviderName } from '../../../shared/providers'; import type { CoworkMessage, CoworkStore } from '../../coworkStore'; +import { setStoreGetter } from '../claudeSettings'; import type { ExternalAgentProvider } from '../externalAgentProviderStore'; import { appendNodeRequireOption, @@ -44,16 +49,19 @@ const codexProvider: ExternalAgentProvider = { const createStore = (codexConfigSource = ExternalAgentConfigSource.LocalCli) => { const messages: CoworkMessage[] = []; + const session = { + id: 'session-1', + messages, + status: 'running', + }; const store = { getConfig: () => ({ codexConfigSource, }), - getSession: () => ({ - id: 'session-1', - messages, - status: 'running', - }), - updateSession: () => undefined, + getSession: () => session, + updateSession: (_sessionId: string, patch: Partial) => { + Object.assign(session, patch); + }, addMessage: (_sessionId: string, input: Omit) => { const message = { ...input, @@ -71,7 +79,7 @@ const createStore = (codexConfigSource = ExternalAgentConfigSource.LocalCli) => }, } as unknown as CoworkStore; - return { store, messages }; + return { store, messages, session }; }; describe('appendNodeRequireOption', () => { @@ -93,6 +101,61 @@ describe('appendNodeRequireOption', () => { }); describe('ExternalCliRuntimeAdapter Codex local config', () => { + test('uses Agent engine command resolution for Claude Code on Windows', async () => { + if (process.platform !== 'win32') { + return; + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-claude-cli-')); + const originalAppData = process.env.APPDATA; + const originalLocalAppData = process.env.LOCALAPPDATA; + try { + process.env.APPDATA = tempDir; + process.env.LOCALAPPDATA = path.join(tempDir, 'local'); + const claudeCmd = path.join(tempDir, 'npm', 'claude.cmd'); + fs.mkdirSync(path.dirname(claudeCmd), { recursive: true }); + fs.writeFileSync(claudeCmd, '@echo off\r\n', 'utf8'); + + const { store } = createStore(); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.ClaudeCode, + store, + }); + const internals = adapter as unknown as { + resolveSpawnCommandSpec: ( + command: string, + args: string[], + env: Record, + ) => Promise<{ + command: string; + args: string[]; + source: string; + windowsVerbatimArguments?: boolean; + }>; + }; + + const spawnSpec = await internals.resolveSpawnCommandSpec('claude', ['-p', 'hello'], {}); + + expect(spawnSpec.command).toBe('cmd.exe'); + expect(spawnSpec.source).toBe('agent-engine-command-resolution'); + expect(spawnSpec.windowsVerbatimArguments).toBe(true); + expect(spawnSpec.args.join(' ')).toContain(claudeCmd); + expect(spawnSpec.args.join(' ')).toContain('hello'); + } finally { + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + test('does not override the local Codex CLI config with a selected provider', () => { const { store } = createStore(); const adapter = new ExternalCliRuntimeAdapter({ @@ -138,6 +201,38 @@ describe('ExternalCliRuntimeAdapter Codex local config', () => { expect(args).not.toContain('model="gpt-5.5"'); }); + test('does not resume Codex when WeSight model mode uses a temporary home', () => { + const { store } = createStore(ExternalAgentConfigSource.WesightModel); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const internals = adapter as unknown as { + buildCommandArgs: ( + cwd: string, + prompt: string, + imagePaths: string[], + selectedProvider: ExternalAgentProvider | null, + sessionTitle: string, + cliSessionId: string | null, + ) => string[]; + }; + + const args = internals.buildCommandArgs( + 'D:\\LHA\\wesight', + 'hello again', + [], + null, + 'session', + '019e9cb9-32ce-7aa3-a54b-e98520aa4644', + ); + + expect(args).toContain('exec'); + expect(args).not.toContain('resume'); + expect(args).toContain('--cd'); + expect(args.at(-1)).toBe('hello again'); + }); + test('builds a Codex runtime config for WeSight model routing', () => { const { store } = createStore(ExternalAgentConfigSource.WesightModel); const adapter = new ExternalCliRuntimeAdapter({ @@ -160,6 +255,158 @@ describe('ExternalCliRuntimeAdapter Codex local config', () => { expect(config).toContain('requires_openai_auth = true'); }); + test('does not fall back to local Codex config when WeSight routing is unsupported', () => { + setStoreGetter(() => ({ + get: (key: string) => { + if (key !== 'app_config') return null; + return { + model: { + defaultModel: 'claude-sonnet-4-5-20250929', + defaultModelProvider: ProviderName.Anthropic, + }, + providers: { + [ProviderName.Anthropic]: { + enabled: true, + apiKey: 'sk-test-anthropic', + baseUrl: 'https://api.anthropic.com', + apiFormat: 'anthropic', + models: [{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }], + }, + }, + }; + }, + }) as never); + try { + const { store } = createStore(ExternalAgentConfigSource.WesightModel); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const internals = adapter as unknown as { + prepareCodexHomeForExecMode: ( + env: Record, + provider: ExternalAgentProvider | null, + ) => string | null; + }; + + expect(() => internals.prepareCodexHomeForExecMode({}, null)).toThrow( + 'Codex CLI could not use WeSight model config: Provider anthropic does not have an OpenAI-compatible endpoint for Codex CLI.', + ); + } finally { + setStoreGetter(() => null); + } + }); + + test('summarizes the Codex server URL used for CLI startup', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-codex-log-')); + try { + fs.writeFileSync( + path.join(tempDir, 'config.toml'), + [ + 'model_provider = "deepseek"', + 'model = "deepseek-v4-flash"', + '', + '[model_providers.deepseek]', + 'name = "deepseek"', + 'base_url = "http://127.0.0.1:56186/v1?api_key=secret-value"', + 'wire_api = "responses"', + '', + ].join('\n'), + 'utf8', + ); + const { store } = createStore(ExternalAgentConfigSource.WesightModel); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const internals = adapter as unknown as { + summarizeCodexConfigForLog: ( + env: Record, + codexHomeDir: string | null, + ) => { + serverUrl: string; + modelProvider: string; + model: string; + wireApi: string; + }; + }; + + const summary = internals.summarizeCodexConfigForLog({}, tempDir); + + expect(summary.serverUrl).toBe('http://127.0.0.1:56186/v1?api_key=redacted'); + expect(summary.modelProvider).toBe('deepseek'); + expect(summary.model).toBe('deepseek-v4-flash'); + expect(summary.wireApi).toBe('responses'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('logs stderr tail content when a CLI process finishes', () => { + const { store } = createStore(); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const internals = adapter as unknown as { + logCliProcessFinished: ( + active: { + sessionId: string; + cliSessionId: string | null; + initialMessageCount: number; + stderrTail: string; + }, + code: number | null, + signal: NodeJS.Signals | null, + ) => void; + }; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + internals.logCliProcessFinished( + { + sessionId: 'session-1', + cliSessionId: 'thread-1', + initialMessageCount: 0, + stderrTail: 'upstream error: The deepseek-v4-flash model is not supported\n', + }, + 1, + null, + ); + + expect(logSpy).toHaveBeenCalledTimes(1); + const payload = logSpy.mock.calls[0][1] as Record; + expect(payload.stderrChars).toBe(61); + expect(payload.stderrTail).toBe('upstream error: The deepseek-v4-flash model is not supported'); + expect(payload.stderrTailTruncated).toBe(false); + } finally { + logSpy.mockRestore(); + } + }); + + test('retries Codex resume when rollout is missing and no assistant text was produced', () => { + const { store } = createStore(); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const active = { + cliSessionId: 'thread-1', + assistantMessageId: 'message-1', + assistantContent: '', + stderrTail: 'Error: thread/resume: thread/resume failed: no rollout found for thread id thread-1 (code -32600)', + }; + const internals = adapter as unknown as { + shouldRetryCodexWithoutResume: (active: typeof active, code: number | null) => boolean; + }; + + expect(internals.shouldRetryCodexWithoutResume(active, 1)).toBe(true); + expect(internals.shouldRetryCodexWithoutResume({ + ...active, + assistantContent: 'partial answer', + }, 1)).toBe(false); + }); + test('extracts assistant text from Codex CLI 0.136 JSONL events', () => { const { store, messages } = createStore(); const adapter = new ExternalCliRuntimeAdapter({ @@ -170,8 +417,10 @@ describe('ExternalCliRuntimeAdapter Codex local config', () => { handleCodexEvent: (active: { sessionId: string; cliSessionId: string | null; + startedAt: number; assistantMessageId: string | null; assistantContent: string; + assistantOutputStartedLogged: boolean; initialMessageCount: number; codexGeneratedImageIds: Set; }, event: unknown) => void; @@ -179,8 +428,10 @@ describe('ExternalCliRuntimeAdapter Codex local config', () => { const active = { sessionId: 'session-1', cliSessionId: null, + startedAt: Date.now(), assistantMessageId: null, assistantContent: '', + assistantOutputStartedLogged: false, initialMessageCount: 0, codexGeneratedImageIds: new Set(), }; @@ -205,6 +456,231 @@ describe('ExternalCliRuntimeAdapter Codex local config', () => { expect(messages[0].metadata).toEqual({ isStreaming: false, isFinal: true }); }); + test('prefers the most complete Codex text field', () => { + const { store } = createStore(); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const internals = adapter as unknown as { + extractCodexText: (value: unknown) => string | null; + }; + + expect(internals.extractCodexText({ + text: 'short', + content: [ + { text: 'complete ' }, + { text: 'assistant text' }, + ], + })).toBe('complete assistant text'); + expect(internals.extractCodexText({ + payload: { + output: 'nested output', + }, + })).toBe('nested output'); + }); + + test('stops Codex turn failure before processing late output', () => { + const { store, messages, session } = createStore(); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const errorSpy = vi.fn(); + adapter.on('error', errorSpy); + const child = { + kill: vi.fn(), + }; + const active = { + child, + sessionId: 'session-1', + cliSessionId: 'thread-1', + startedAt: Date.now(), + initialMessageCount: 0, + assistantMessageId: null, + assistantContent: '', + assistantOutputStartedLogged: false, + stderrTail: '', + cliErrorMessage: null, + sawEvent: true, + sawClaudeVisibleOutput: false, + startupTimer: null, + noContentNoticeTimer: null, + noContentTimeoutTimer: null, + imagePaths: [], + codexHomeDir: null, + localClaudeConfig: null, + configSource: ExternalAgentConfigSource.WesightModel, + codexGeneratedImageIds: new Set(), + completedFromEvent: false, + }; + const internals = adapter as unknown as { + handleCodexEvent: (active: typeof active, event: unknown) => void; + handleOutputLine: (active: typeof active, line: string) => void; + }; + + internals.handleCodexEvent(active, { + type: 'turn.failed', + message: 'Codex turn failed upstream.', + }); + internals.handleOutputLine(active, JSON.stringify({ + type: 'item.completed', + item: { + type: 'agent_message', + text: 'late output', + }, + })); + + expect(active.completedFromEvent).toBe(true); + expect(session.status).toBe('error'); + expect(errorSpy).toHaveBeenCalledWith('session-1', 'Codex turn failed upstream.'); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + expect(messages).toHaveLength(0); + }); + + test('ignores Codex image generation events without an id', () => { + const { store, messages } = createStore(); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const active = { + sessionId: 'session-1', + codexGeneratedImageIds: new Set(), + }; + const internals = adapter as unknown as { + handleCodexEventMessage: (active: typeof active, payload: Record) => void; + }; + + internals.handleCodexEventMessage(active, { + type: 'image_generation_end', + }); + + expect(active.codexGeneratedImageIds.size).toBe(0); + expect(messages).toHaveLength(0); + }); + + test('releases Codex session lock before emitting complete from turn event', () => { + const { store } = createStore(); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const completeSpy = vi.fn(); + adapter.on('complete', completeSpy); + const child = { + kill: vi.fn(), + }; + const active = { + child, + sessionId: 'session-1', + cliSessionId: 'thread-1', + startedAt: Date.now(), + initialMessageCount: 0, + assistantMessageId: null, + assistantContent: '', + assistantOutputStartedLogged: false, + stderrTail: '', + cliErrorMessage: null, + sawEvent: true, + sawClaudeVisibleOutput: false, + startupTimer: null, + noContentNoticeTimer: null, + noContentTimeoutTimer: null, + imagePaths: [], + codexHomeDir: null, + localClaudeConfig: null, + configSource: ExternalAgentConfigSource.WesightModel, + codexGeneratedImageIds: new Set(), + completedFromEvent: false, + }; + const internals = adapter as unknown as { + activeSessions: Map; + completeCodexSessionFromEvent: (active: typeof active) => void; + }; + internals.activeSessions.set('session-1', active); + + internals.completeCodexSessionFromEvent(active); + + expect(adapter.isSessionActive('session-1')).toBe(false); + expect(completeSpy).toHaveBeenCalledWith('session-1', 'thread-1'); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + test('ignores late Codex output after turn completion', () => { + const { store, messages } = createStore(); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const active = { + sessionId: 'session-1', + cliSessionId: 'thread-1', + startedAt: Date.now(), + assistantMessageId: null, + assistantContent: '', + assistantOutputStartedLogged: false, + initialMessageCount: 0, + completedFromEvent: true, + codexGeneratedImageIds: new Set(), + }; + const internals = adapter as unknown as { + handleOutputLine: (active: typeof active, line: string) => void; + }; + + internals.handleOutputLine(active, JSON.stringify({ + type: 'item.completed', + item: { + type: 'agent_message', + text: 'late output', + }, + })); + + expect(messages).toHaveLength(0); + }); + + test('logs when external CLI assistant output starts', () => { + const { store } = createStore(); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.Codex, + store, + }); + const active = { + sessionId: 'session-1', + cliSessionId: 'thread-1', + startedAt: Date.now() - 1200, + assistantMessageId: null, + assistantContent: '', + assistantOutputStartedLogged: false, + initialMessageCount: 0, + configSource: ExternalAgentConfigSource.WesightModel, + }; + const internals = adapter as unknown as { + appendAssistant: (active: typeof active, delta: string) => void; + }; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + internals.appendAssistant(active, 'hello'); + internals.appendAssistant(active, ' world'); + + const outputStartedLogs = logSpy.mock.calls.filter((call) => ( + call[0] === '[ExternalCliRuntimeAdapter] CLI assistant output started.' + )); + expect(outputStartedLogs).toHaveLength(1); + expect(outputStartedLogs[0][1]).toMatchObject({ + engine: 'Codex CLI', + sessionId: 'session-1', + cliSessionId: 'thread-1', + configSource: ExternalAgentConfigSource.WesightModel, + outputChars: 5, + isFinal: false, + }); + } finally { + logSpy.mockRestore(); + } + }); + test('redacts Claude Code stream text from log summaries', () => { const { store } = createStore(); const adapter = new ExternalCliRuntimeAdapter({ @@ -246,16 +722,20 @@ describe('ExternalCliRuntimeAdapter Codex local config', () => { handleClaudeCliEvent: (active: { sessionId: string; cliSessionId: string | null; + startedAt: number; assistantMessageId: string | null; assistantContent: string; + assistantOutputStartedLogged: boolean; initialMessageCount: number; }, event: unknown) => void; }; const active = { sessionId: 'session-1', cliSessionId: null, + startedAt: Date.now(), assistantMessageId: null, assistantContent: '', + assistantOutputStartedLogged: false, initialMessageCount: 0, }; const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -271,8 +751,21 @@ describe('ExternalCliRuntimeAdapter Codex local config', () => { }, }, }); + internals.handleClaudeCliEvent(active, { + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { + type: 'text_delta', + text: ' more text', + }, + }, + }); - expect(logSpy).not.toHaveBeenCalled(); + const outputStartedLogs = logSpy.mock.calls.filter((call) => ( + call[0] === '[ExternalCliRuntimeAdapter] CLI assistant output started.' + )); + expect(outputStartedLogs).toHaveLength(1); } finally { logSpy.mockRestore(); } diff --git a/src/main/libs/claudeSettings.test.ts b/src/main/libs/claudeSettings.test.ts index ca214879..a3d3f77b 100644 --- a/src/main/libs/claudeSettings.test.ts +++ b/src/main/libs/claudeSettings.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { ProviderName } from '../../shared/providers'; -import { resolveCurrentApiConfig, setStoreGetter } from './claudeSettings'; +import { resolveCodexWesightApiConfig, resolveCurrentApiConfig, setStoreGetter } from './claudeSettings'; import * as coworkOpenAICompatProxy from './coworkOpenAICompatProxy'; describe('resolveCurrentApiConfig', () => { @@ -46,3 +46,134 @@ describe('resolveCurrentApiConfig', () => { expect(configureProxy).not.toHaveBeenCalled(); }); }); + +describe('resolveCodexWesightApiConfig', () => { + afterEach(() => { + setStoreGetter(() => null); + vi.restoreAllMocks(); + }); + + test('routes an Anthropic-compatible DeepSeek config through the OpenAI-compatible endpoint', () => { + const configureProxy = vi.spyOn(coworkOpenAICompatProxy, 'configureCoworkOpenAICompatProxy'); + vi.spyOn(coworkOpenAICompatProxy, 'getCoworkOpenAICompatProxyStatus').mockReturnValue({ + running: true, + baseURL: 'http://127.0.0.1:12345/v1', + hasUpstream: false, + upstreamBaseURL: null, + upstreamModel: null, + lastError: null, + }); + vi.spyOn(coworkOpenAICompatProxy, 'getCoworkOpenAICompatProxyBaseURL').mockReturnValue('http://127.0.0.1:12345/v1'); + setStoreGetter(() => ({ + get: (key: string) => { + if (key !== 'app_config') return null; + return { + model: { + defaultModel: 'deepseek-reasoner', + defaultModelProvider: ProviderName.DeepSeek, + }, + providers: { + [ProviderName.DeepSeek]: { + enabled: true, + apiKey: 'sk-test-deepseek', + baseUrl: 'https://api.deepseek.com/anthropic', + apiFormat: 'anthropic', + models: [{ id: 'deepseek-reasoner', name: 'DeepSeek Reasoner' }], + }, + }, + }; + }, + }) as never); + + const resolution = resolveCodexWesightApiConfig('local'); + + expect(resolution.error).toBeUndefined(); + expect(resolution.config).toEqual({ + apiKey: 'sk-test-deepseek', + baseURL: 'http://127.0.0.1:12345/v1', + model: 'deepseek-reasoner', + apiType: 'openai', + }); + expect(configureProxy).toHaveBeenCalledWith({ + baseURL: 'https://api.deepseek.com', + apiKey: 'sk-test-deepseek', + model: 'deepseek-reasoner', + provider: ProviderName.DeepSeek, + }); + }); + + test('uses the OpenAI-compatible coding plan endpoint for Codex', () => { + const configureProxy = vi.spyOn(coworkOpenAICompatProxy, 'configureCoworkOpenAICompatProxy'); + vi.spyOn(coworkOpenAICompatProxy, 'getCoworkOpenAICompatProxyStatus').mockReturnValue({ + running: true, + baseURL: 'http://127.0.0.1:23456/v1', + hasUpstream: false, + upstreamBaseURL: null, + upstreamModel: null, + lastError: null, + }); + vi.spyOn(coworkOpenAICompatProxy, 'getCoworkOpenAICompatProxyBaseURL').mockReturnValue('http://127.0.0.1:23456/v1'); + setStoreGetter(() => ({ + get: (key: string) => { + if (key !== 'app_config') return null; + return { + model: { + defaultModel: 'glm-5', + defaultModelProvider: ProviderName.Zhipu, + }, + providers: { + [ProviderName.Zhipu]: { + enabled: true, + apiKey: 'sk-test-zhipu', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + apiFormat: 'anthropic', + codingPlanEnabled: true, + models: [{ id: 'glm-5', name: 'GLM 5' }], + }, + }, + }; + }, + }) as never); + + const resolution = resolveCodexWesightApiConfig('local'); + + expect(resolution.error).toBeUndefined(); + expect(resolution.config?.baseURL).toBe('http://127.0.0.1:23456/v1'); + expect(configureProxy).toHaveBeenCalledWith({ + baseURL: 'https://open.bigmodel.cn/api/coding/paas/v4', + apiKey: 'sk-test-zhipu', + model: 'glm-5', + provider: ProviderName.Zhipu, + }); + }); + + test('fails clearly when the provider has no OpenAI-compatible endpoint for Codex', () => { + const configureProxy = vi.spyOn(coworkOpenAICompatProxy, 'configureCoworkOpenAICompatProxy'); + setStoreGetter(() => ({ + get: (key: string) => { + if (key !== 'app_config') return null; + return { + model: { + defaultModel: 'claude-sonnet-4-5-20250929', + defaultModelProvider: ProviderName.Anthropic, + }, + providers: { + [ProviderName.Anthropic]: { + enabled: true, + apiKey: 'sk-test-anthropic', + baseUrl: 'https://api.anthropic.com', + apiFormat: 'anthropic', + models: [{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }], + }, + }, + }; + }, + }) as never); + + const resolution = resolveCodexWesightApiConfig('local'); + + expect(resolution.config).toBeNull(); + expect(resolution.error).toBe('Provider anthropic does not have an OpenAI-compatible endpoint for Codex CLI.'); + expect(configureProxy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/libs/coworkOpenAICompatProxy.test.ts b/src/main/libs/coworkOpenAICompatProxy.test.ts new file mode 100644 index 00000000..5e6e2e38 --- /dev/null +++ b/src/main/libs/coworkOpenAICompatProxy.test.ts @@ -0,0 +1,198 @@ +import { expect, test, vi } from 'vitest'; + +import { __openAICompatProxyTestUtils } from './coworkOpenAICompatProxy'; + +const parseSSEWrites = (writes: string[]) => writes.flatMap((write) => ( + write + .trim() + .split(/\n\n/) + .filter(Boolean) + .map((packet) => { + const event = packet.match(/^event:\s*(.+)$/m)?.[1] ?? ''; + const data = packet.match(/^data:\s*(.+)$/m)?.[1] ?? '{}'; + return { + event, + data: JSON.parse(data) as Record, + }; + }) +)); + +test('convertResponsesRequestToChatCompletionsRequest maps developer role to system', () => { + const converted = __openAICompatProxyTestUtils.convertResponsesRequestToChatCompletionsRequest({ + model: 'deepseek-v4-flash', + input: [ + { + type: 'message', + role: 'developer', + content: [{ type: 'input_text', text: 'Follow the workspace policy.' }], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ], + }); + + expect(converted.messages).toEqual([ + { role: 'system', content: 'Follow the workspace policy.' }, + { role: 'user', content: 'hello' }, + ]); +}); + +test('processResponsesStreamEvent emits streamed function call metadata and arguments', () => { + const writes: string[] = []; + const res = { + write: vi.fn((chunk: string) => { + writes.push(chunk); + return true; + }), + }; + const state = __openAICompatProxyTestUtils.createStreamState(); + const context = __openAICompatProxyTestUtils.createResponsesStreamContext(); + + __openAICompatProxyTestUtils.processResponsesStreamEvent( + res as never, + state, + context, + 'response.output_item.added', + { + response_id: 'resp_1', + model: 'gpt-test', + output_index: 0, + item: { + id: 'item_1', + type: 'function_call', + call_id: 'call_1', + name: 'lookup', + }, + }, + ); + __openAICompatProxyTestUtils.processResponsesStreamEvent( + res as never, + state, + context, + 'response.function_call_arguments.done', + { + response_id: 'resp_1', + model: 'gpt-test', + output_index: 0, + call_id: 'call_1', + arguments: '{"query":"weather"}', + }, + ); + + const events = parseSSEWrites(writes); + const toolStart = events.find((item) => item.event === 'content_block_start'); + const argumentDelta = events.find((item) => item.event === 'content_block_delta'); + + expect(toolStart?.data.content_block).toMatchObject({ + type: 'tool_use', + id: 'call_1', + name: 'lookup', + }); + expect(argumentDelta?.data.delta).toEqual({ + type: 'input_json_delta', + partial_json: '{"query":"weather"}', + }); +}); + +test('convertChatCompletionsRequestToResponsesRequest auto-closes missing tool outputs', () => { + const converted = __openAICompatProxyTestUtils.convertChatCompletionsRequestToResponsesRequest({ + model: 'gpt-test', + messages: [ + { + role: 'assistant', + tool_calls: [ + { + id: 'call_missing', + type: 'function', + function: { + name: 'lookup', + arguments: '{"query":"weather"}', + }, + }, + ], + }, + ], + }); + + expect(converted.input).toEqual([ + { + type: 'function_call', + call_id: 'call_missing', + name: 'lookup', + arguments: '{"query":"weather"}', + }, + { + type: 'function_call_output', + call_id: 'call_missing', + output: expect.stringContaining('Missing tool output'), + }, + ]); +}); + +test('filterOpenAIToolsForProvider removes Skill tools and resets forced choices', () => { + const request = { + tools: [ + { type: 'function', function: { name: 'Skill' } }, + { type: 'function', function: { name: 'Read' } }, + ], + tool_choice: { + type: 'function', + function: { name: 'skill' }, + }, + }; + + __openAICompatProxyTestUtils.filterOpenAIToolsForProvider(request, 'openai'); + + expect(request.tools).toEqual([ + { type: 'function', function: { name: 'Read' } }, + ]); + expect(request.tool_choice).toBe('auto'); +}); + +test('isGeminiProvider detects explicit provider and Google base URL', () => { + expect(__openAICompatProxyTestUtils.isGeminiProvider('gemini')).toBe(true); + expect(__openAICompatProxyTestUtils.isGeminiProvider( + 'custom', + 'https://generativelanguage.googleapis.com/v1beta/openai/', + )).toBe(true); + expect(__openAICompatProxyTestUtils.isGeminiProvider('openai', 'https://api.openai.com/v1')).toBe(false); +}); + +test('sanitizeToolsForGemini removes unsupported schema keys', () => { + const request = { + tools: [ + { + type: 'function', + function: { + name: 'lookup', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + query: { + type: 'string', + format: 'uri', + description: 'Search query', + }, + }, + }, + }, + }, + ], + }; + + __openAICompatProxyTestUtils.sanitizeToolsForGemini(request, 'gemini'); + + expect(request.tools[0].function.parameters).toEqual({ + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query', + }, + }, + }); +}); diff --git a/src/main/libs/externalAgentConfigSync.test.ts b/src/main/libs/externalAgentConfigSync.test.ts index 3c245fee..f499656a 100644 --- a/src/main/libs/externalAgentConfigSync.test.ts +++ b/src/main/libs/externalAgentConfigSync.test.ts @@ -1,10 +1,20 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { expect, test } from 'vitest'; +import { CoworkAgentEngine, ExternalAgentConfigSource } from '../../shared/cowork/constants'; import { buildEnvForConfig } from './claudeSettings'; import { + applyExternalAgentConfigForEngine, + cleanupWesightManagedClaudeSettings, + cleanupWesightManagedCodexConfig, + createWesightClaudeSettingsBackup, mergeClaudeSettingsForWesightModel, mergeCodexConfigForLocalCli, mergeCodexConfigForWesightModel, + removeWesightManagedClaudeSettings, + writeTextFileWithBackupIfChanged, } from './externalAgentConfigSync'; const apiConfig = { @@ -41,6 +51,7 @@ test('mergeCodexConfigForWesightModel preserves user TOML content', () => { expect(merged).toContain('model = "glm-5.1-highspeed"'); expect(merged).toContain('[model_providers.zhipu_glm]'); expect(merged).toContain('base_url = "https://api.example.com/v1"'); + expect(merged).toContain('# WeSight managed Codex config: begin'); expect(merged).not.toContain('sk-wesight-secret'); }); @@ -119,11 +130,109 @@ test('mergeCodexConfigForLocalCli switches back to local_codex when available', expect(merged).toContain('# user comment'); expect(merged).toContain('model_provider = "local_codex"'); - expect(merged).toContain('model = "MiniMax-M2"'); + expect(merged).not.toContain('model = "MiniMax-M2"'); expect(merged).toContain('[model_providers.local_codex]'); expect(merged).toContain('[model_providers.minimax]'); }); +test('mergeCodexConfigForLocalCli restores the original model after WeSight model sync', () => { + const localConfig = [ + '# user comment', + 'model_provider = "local_codex"', + 'model = "gpt-5.1-codex-max"', + 'model_reasoning_effort = "medium"', + 'disable_response_storage = false', + '', + '[model_providers.local_codex]', + 'name = "Local Codex"', + 'wire_api = "responses"', + 'requires_openai_auth = true', + '', + ].join('\n'); + const wesightConfig = mergeCodexConfigForWesightModel( + localConfig, + 'deepseek', + 'https://api.deepseek.com', + 'deepseek-v4-flash', + ); + + const restored = mergeCodexConfigForLocalCli(wesightConfig); + + expect(restored).toContain('# user comment'); + expect(restored).not.toContain('# WeSight managed Codex config'); + expect(restored).toContain('model_provider = "local_codex"'); + expect(restored).toContain('model = "gpt-5.1-codex-max"'); + expect(restored).toContain('model_reasoning_effort = "medium"'); + expect(restored).toContain('disable_response_storage = false'); + expect(restored).toContain('[model_providers.local_codex]'); + expect(restored).toContain('[model_providers.deepseek]'); +}); + +test('mergeCodexConfigForLocalCli removes legacy WeSight model residue without metadata', () => { + const existing = [ + 'model_provider = "deepseek"', + 'model = "deepseek-v4-flash"', + 'model_reasoning_effort = "high"', + 'disable_response_storage = true', + '', + '[model_providers.local_codex]', + 'name = "Local Codex"', + 'wire_api = "responses"', + 'requires_openai_auth = true', + '', + '[model_providers.deepseek]', + 'name = "deepseek"', + 'base_url = "https://api.deepseek.com"', + 'wire_api = "responses"', + 'requires_openai_auth = true', + '', + ].join('\n'); + + const restored = mergeCodexConfigForLocalCli(existing); + + expect(restored).toContain('model_provider = "local_codex"'); + expect(restored).not.toContain('model = "deepseek-v4-flash"'); + expect(restored).not.toContain('model_reasoning_effort = "high"'); + expect(restored).not.toContain('disable_response_storage = true'); + expect(restored).toContain('[model_providers.local_codex]'); +}); + +test('cleanupWesightManagedCodexConfig restores config on disk', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-codex-config-')); + const configPath = path.join(tempDir, 'config.toml'); + try { + const wesightConfig = mergeCodexConfigForWesightModel([ + 'model_provider = "local_codex"', + 'model = "gpt-5.4"', + '', + '[model_providers.local_codex]', + 'name = "Local Codex"', + 'wire_api = "responses"', + 'requires_openai_auth = true', + '', + ].join('\n'), 'deepseek', 'https://api.deepseek.com', 'deepseek-v4-flash'); + fs.writeFileSync(configPath, wesightConfig, 'utf8'); + + expect(cleanupWesightManagedCodexConfig(configPath)).toBe(true); + const restored = fs.readFileSync(configPath, 'utf8'); + + expect(restored).not.toContain('# WeSight managed Codex config'); + expect(restored).toContain('model_provider = "local_codex"'); + expect(restored).toContain('model = "gpt-5.4"'); + expect(restored).not.toContain('model = "deepseek-v4-flash"'); + expect(fs.existsSync(path.join(tempDir, '.wesight-backups'))).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('applyExternalAgentConfigForEngine leaves Codex local config untouched for WeSight model mode', () => { + expect(() => applyExternalAgentConfigForEngine( + CoworkAgentEngine.Codex, + ExternalAgentConfigSource.WesightModel, + )).not.toThrow(); +}); + test('mergeCodexConfigForLocalCli leaves config unchanged when local_codex is missing', () => { const existing = [ 'model_provider = "minimax"', @@ -194,6 +303,171 @@ test('mergeClaudeSettingsForWesightModel records all managed Claude env keys', ( ]); }); +test('removeWesightManagedClaudeSettings restores original Claude env keys', () => { + const merged = mergeClaudeSettingsForWesightModel({ + env: { + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-flash', + USER_TOKEN: 'keep-me', + }, + theme: 'dark', + }, apiConfig); + + const cleaned = removeWesightManagedClaudeSettings(merged); + + expect(cleaned.theme).toBe('dark'); + expect(cleaned.__wesight_managed).toBeUndefined(); + expect(cleaned.env).toEqual({ + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-flash', + USER_TOKEN: 'keep-me', + }); +}); + +test('removeWesightManagedClaudeSettings preserves legacy marker env values', () => { + const legacy = { + env: { + ANTHROPIC_API_KEY: 'sk-local', + ANTHROPIC_BASE_URL: 'https://local.example/v1', + USER_TOKEN: 'keep-me', + }, + __wesight_managed: { + claudeCode: { + envKeys: ['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL'], + }, + }, + }; + + const cleaned = removeWesightManagedClaudeSettings(legacy); + + expect(cleaned.__wesight_managed).toBeUndefined(); + expect(cleaned.env).toEqual(legacy.env); +}); + +test('mergeClaudeSettingsForWesightModel preserves the earliest original env snapshot', () => { + const first = mergeClaudeSettingsForWesightModel({ + env: { + ANTHROPIC_BASE_URL: 'https://local.example/v1', + ANTHROPIC_MODEL: 'local-claude', + }, + }, apiConfig); + const second = mergeClaudeSettingsForWesightModel(first, { + ...apiConfig, + apiKey: 'sk-second', + baseURL: 'https://second.example/v1', + model: 'second-model', + }); + + const cleaned = removeWesightManagedClaudeSettings(second); + + expect(cleaned.env).toEqual({ + ANTHROPIC_BASE_URL: 'https://local.example/v1', + ANTHROPIC_MODEL: 'local-claude', + }); +}); + +test('cleanupWesightManagedClaudeSettings restores settings on disk', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-claude-settings-')); + const settingsPath = path.join(tempDir, 'settings.json'); + try { + fs.writeFileSync(settingsPath, JSON.stringify(mergeClaudeSettingsForWesightModel({ + env: { + ANTHROPIC_BASE_URL: 'https://local.example/v1', + KEEP_ME: 'yes', + }, + }, apiConfig)), 'utf8'); + + expect(cleanupWesightManagedClaudeSettings(settingsPath)).toBe(true); + const cleaned = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Record; + + expect(cleaned.__wesight_managed).toBeUndefined(); + expect(cleaned.env).toEqual({ + ANTHROPIC_BASE_URL: 'https://local.example/v1', + KEEP_ME: 'yes', + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('createWesightClaudeSettingsBackup copies existing settings file', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-claude-backup-')); + const settingsPath = path.join(tempDir, 'settings.json'); + try { + fs.writeFileSync(settingsPath, '{"env":{"KEEP_ME":"yes"}}\n', 'utf8'); + + const backupPath = createWesightClaudeSettingsBackup(settingsPath); + + expect(backupPath).toBeTruthy(); + expect(backupPath && fs.existsSync(backupPath)).toBe(true); + expect(backupPath).toContain(`${path.sep}.wesight-backups${path.sep}`); + expect(fs.readFileSync(backupPath as string, 'utf8')).toBe('{"env":{"KEEP_ME":"yes"}}\n'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('writeTextFileWithBackupIfChanged backs up each changed existing config file', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-config-backup-')); + const configPath = path.join(tempDir, 'config.toml'); + try { + fs.writeFileSync(configPath, 'model = "first"\n', 'utf8'); + + expect(writeTextFileWithBackupIfChanged(configPath, 'model = "second"\n')).toBe(true); + expect(writeTextFileWithBackupIfChanged(configPath, 'model = "third"\n')).toBe(true); + + const backupsDir = path.join(tempDir, '.wesight-backups'); + const backupContents = fs.readdirSync(backupsDir) + .map((fileName) => fs.readFileSync(path.join(backupsDir, fileName), 'utf8')) + .sort(); + + expect(fs.readFileSync(configPath, 'utf8')).toBe('model = "third"\n'); + expect(backupContents).toEqual([ + 'model = "first"\n', + 'model = "second"\n', + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('writeTextFileWithBackupIfChanged keeps the first backup permanently', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-config-first-backup-')); + const configPath = path.join(tempDir, 'config.toml'); + try { + fs.writeFileSync(configPath, 'model = "version-0"\n', 'utf8'); + for (let index = 1; index <= 25; index += 1) { + writeTextFileWithBackupIfChanged(configPath, `model = "version-${index}"\n`); + } + + const backupsDir = path.join(tempDir, '.wesight-backups'); + const backupContents = fs.readdirSync(backupsDir) + .map((fileName) => fs.readFileSync(path.join(backupsDir, fileName), 'utf8')); + + expect(backupContents).toHaveLength(21); + expect(backupContents).toContain('model = "version-0"\n'); + expect(backupContents).toContain('model = "version-24"\n'); + expect(fs.readFileSync(configPath, 'utf8')).toBe('model = "version-25"\n'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('writeTextFileWithBackupIfChanged skips unchanged config files', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-config-unchanged-')); + const configPath = path.join(tempDir, 'config.toml'); + try { + fs.writeFileSync(configPath, 'model = "same"\n', 'utf8'); + + expect(writeTextFileWithBackupIfChanged(configPath, 'model = "same"\n')).toBe(false); + + expect(fs.existsSync(path.join(tempDir, '.wesight-backups'))).toBe(false); + expect(fs.readFileSync(configPath, 'utf8')).toBe('model = "same"\n'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + test('buildEnvForConfig injects real secrets only into process env', () => { const env = buildEnvForConfig(apiConfig); diff --git a/src/main/libs/externalAgentEnvironment.test.ts b/src/main/libs/externalAgentEnvironment.test.ts index f5e149d7..8248849e 100644 --- a/src/main/libs/externalAgentEnvironment.test.ts +++ b/src/main/libs/externalAgentEnvironment.test.ts @@ -3,23 +3,37 @@ import os from 'os'; import path from 'path'; import { afterEach, beforeEach, expect, test } from 'vitest'; -import { getExternalAgentEnvironmentSnapshot, summarizeCliAuthStatus } from './externalAgentEnvironment'; +import { getExternalAgentEnvironmentSnapshot, resolveCliCommand, summarizeCliAuthStatus } from './externalAgentEnvironment'; let tempDir = ''; let originalPath = ''; let originalOpenAiKey: string | undefined; +let originalAppData: string | undefined; +let originalLocalAppData: string | undefined; +let originalHome: string | undefined; +let originalUserProfile: string | undefined; -const writeExecutable = (name: string, script: string): void => { - const filePath = path.join(tempDir, name); +const writeExecutable = (name: string, script: string): string => { + const fileName = process.platform === 'win32' ? `${name}.cmd` : name; + const filePath = path.join(tempDir, fileName); fs.writeFileSync(filePath, script, 'utf8'); fs.chmodSync(filePath, 0o755); + return filePath; }; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-agent-env-')); originalPath = process.env.PATH ?? ''; originalOpenAiKey = process.env.OPENAI_API_KEY; + originalAppData = process.env.APPDATA; + originalLocalAppData = process.env.LOCALAPPDATA; + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; process.env.PATH = `${tempDir}${path.delimiter}${originalPath}`; + process.env.APPDATA = path.join(tempDir, 'appdata'); + process.env.LOCALAPPDATA = path.join(tempDir, 'localappdata'); + process.env.HOME = tempDir; + process.env.USERPROFILE = tempDir; delete process.env.OPENAI_API_KEY; }); @@ -30,12 +44,44 @@ afterEach(() => { } else { process.env.OPENAI_API_KEY = originalOpenAiKey; } + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } fs.rmSync(tempDir, { recursive: true, force: true }); }); test('probes CLI commands asynchronously and isolates version timeouts', async () => { - writeExecutable('claude', '#!/bin/sh\necho "claude-test 1.0.0"\n'); - writeExecutable('grok', '#!/bin/sh\nif [ "$1" = "--version" ]; then sleep 2; fi\n'); + let claudePath: string; + if (process.platform === 'win32') { + claudePath = path.join(process.env.APPDATA || tempDir, 'npm', 'claude.cmd'); + fs.mkdirSync(path.dirname(claudePath), { recursive: true }); + fs.writeFileSync(claudePath, '@echo off\r\necho claude-test 1.0.0\r\n', 'utf8'); + } else { + claudePath = writeExecutable('claude', '#!/bin/sh\necho "claude-test 1.0.0"\n'); + } + writeExecutable( + 'grok', + process.platform === 'win32' + ? '@echo off\r\npowershell.exe -NoProfile -Command "Start-Sleep -Seconds 2"\r\n' + : '#!/bin/sh\nif [ "$1" = "--version" ]; then sleep 2; fi\n', + ); const { snapshot, report } = await getExternalAgentEnvironmentSnapshot({ appTypes: ['claude', 'grok'], @@ -51,7 +97,7 @@ test('probes CLI commands asynchronously and isolates version timeouts', async ( expect(claude).toMatchObject({ found: true, - path: path.join(tempDir, 'claude'), + path: claudePath, version: 'claude-test 1.0.0', }); expect(claude?.checking).toBeUndefined(); @@ -113,8 +159,84 @@ test('does not treat WeSight placeholders as local CLI credentials', () => { expect(result.authStatus).toBe('logged_out'); }); +test('summarizes native Claude Code settings when cc-switch is absent', async () => { + writeExecutable( + 'claude', + process.platform === 'win32' + ? '@echo off\r\necho claude-test 1.0.0\r\n' + : '#!/bin/sh\necho "claude-test 1.0.0"\n', + ); + const configDir = path.join(tempDir, '.claude'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-local-claude', + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-flash', + }, + }), 'utf8'); + + const { snapshot } = await getExternalAgentEnvironmentSnapshot({ + appTypes: ['claude'], + includeUserShellPath: false, + }); + + expect(snapshot.engines[0]?.config).toMatchObject({ + currentProviderId: 'local-live', + currentProviderName: 'Local Claude Code', + providerCount: 1, + }); + expect(snapshot.engines[0]?.authStatus).toBe('logged_in'); +}); + +test('summarizes native Codex model providers when cc-switch is absent', async () => { + writeExecutable( + 'codex', + process.platform === 'win32' + ? '@echo off\r\necho codex-test 1.0.0\r\n' + : '#!/bin/sh\necho "codex-test 1.0.0"\n', + ); + const configDir = path.join(tempDir, '.codex'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.toml'), [ + 'model_provider = "local_codex"', + 'model = "gpt-5.5"', + '', + '[model_providers.deepseek]', + 'name = "DeepSeek"', + 'base_url = "https://api.deepseek.com/v1"', + '', + '[model_providers.local_codex]', + 'name = "Local Codex"', + 'base_url = "http://127.0.0.1:4000/v1"', + '', + '[model_providers.minimax]', + 'name = "MiniMax"', + ].join('\n'), 'utf8'); + fs.writeFileSync(path.join(configDir, 'auth.json'), JSON.stringify({ + OPENAI_API_KEY: 'sk-local-codex', + }), 'utf8'); + + const { snapshot } = await getExternalAgentEnvironmentSnapshot({ + appTypes: ['codex'], + includeUserShellPath: false, + }); + + expect(snapshot.engines[0]?.config).toMatchObject({ + currentProviderId: 'local_codex', + currentProviderName: 'Local Codex', + providerCount: 3, + }); + expect(snapshot.engines[0]?.authStatus).toBe('logged_in'); +}); + test('limits probes to requested app types', async () => { - writeExecutable('codex', '#!/bin/sh\necho "codex-test 1.0.0"\n'); + const codexPath = writeExecutable( + 'codex', + process.platform === 'win32' + ? '@echo off\r\necho codex-test 1.0.0\r\n' + : '#!/bin/sh\necho "codex-test 1.0.0"\n', + ); const { snapshot, report } = await getExternalAgentEnvironmentSnapshot({ appTypes: ['codex'] }); @@ -122,8 +244,30 @@ test('limits probes to requested app types', async () => { expect(snapshot.engines[0]).toMatchObject({ appType: 'codex', found: true, - path: path.join(tempDir, 'codex'), + path: codexPath, version: 'codex-test 1.0.0', }); expect(report.metrics.map(metric => metric.command)).toEqual(['codex']); }); + +test('resolves native Windows Claude Code installs before PATH lookup', async () => { + if (process.platform !== 'win32') { + return; + } + + process.env.LOCALAPPDATA = tempDir; + const claudePath = path.join(tempDir, 'Programs', 'Claude', 'claude.exe'); + fs.mkdirSync(path.dirname(claudePath), { recursive: true }); + fs.writeFileSync(claudePath, '', 'utf8'); + + const resolution = await resolveCliCommand('claude', { + includeUserShellPath: false, + commandProbeTimeoutMs: 100, + }); + + expect(resolution).toMatchObject({ + found: true, + path: claudePath, + error: null, + }); +}); diff --git a/src/main/runtimeTelemetryStore.test.ts b/src/main/runtimeTelemetryStore.test.ts index bd2a4dc9..83b08f7a 100644 --- a/src/main/runtimeTelemetryStore.test.ts +++ b/src/main/runtimeTelemetryStore.test.ts @@ -137,6 +137,7 @@ test('uses visible output tokens for TPS when official usage includes hidden tok }); store.markAssistantOutput('call-hidden', startedAt + 17_500); + store.updateAssistantEstimate('call-hidden', 45, 12); store.markAssistantOutput('call-hidden', startedAt + 19_000); store.updateAssistantEstimate('call-hidden', 91, 24); store.applyUsage('call-hidden', { @@ -153,8 +154,8 @@ test('uses visible output tokens for TPS when official usage includes hidden tok const summary = store.getSummary(); expect(summary.totalOutputTokens).toBe(1_358); - expect(summary.avgRuntimeTps).toBeCloseTo(15); - expect(summary.avgModelTps).toBeCloseTo(126.7, 1); + expect(summary.avgRuntimeTps).toBeCloseTo(16); + expect(summary.avgModelTps).toBeCloseTo(127.7, 1); }); test('uses runtime-correlated highspeed GLM estimates within the model range', () => { @@ -179,6 +180,8 @@ test('uses runtime-correlated highspeed GLM estimates within the model range', ( }); store.markAssistantOutput('call-glm-highspeed', startedAt + 2300); + store.updateAssistantEstimate('call-glm-highspeed', 80, 35); + store.markAssistantOutput('call-glm-highspeed', startedAt + 4500); store.updateAssistantEstimate('call-glm-highspeed', 159, 70); store.finishCall('call-glm-highspeed', RuntimeCallStatus.Completed, startedAt + 4500); @@ -208,6 +211,8 @@ test('uses runtime-correlated highspeed GLM estimates within the model range', ( }); store.markAssistantOutput('call-glm-highspeed-fast', startedAt + 2400); + store.updateAssistantEstimate('call-glm-highspeed-fast', 80, 35); + store.markAssistantOutput('call-glm-highspeed-fast', startedAt + 2700); store.updateAssistantEstimate('call-glm-highspeed-fast', 159, 70); store.finishCall('call-glm-highspeed-fast', RuntimeCallStatus.Completed, startedAt + 2700); @@ -218,6 +223,38 @@ test('uses runtime-correlated highspeed GLM estimates within the model range', ( expect(calculateModelTps(slowCall)).toBeGreaterThanOrEqual(300); }); +test('does not calculate output-phase TPS for batch-only CLI output', () => { + const startedAt = Date.now() - 20_000; + store.createCall({ + id: 'call-batch-output', + sessionId: 'session-1', + turnIndex: 1, + agentId: 'main', + source: RuntimeCallSource.Chat, + engine: CoworkAgentEngine.Codex, + providerKey: 'deepseek', + providerName: 'DeepSeek', + modelId: 'deepseek-reasoner', + modelName: 'deepseek-reasoner', + configSource: 'wesight_model', + cwd: '/tmp', + startedAt, + inputTokens: 100, + contextTokens: 100, + tokensEstimated: true, + }); + + store.markAssistantOutput('call-batch-output', startedAt + 19_500); + store.updateAssistantEstimate('call-batch-output', 400, 100); + store.finishCall('call-batch-output', RuntimeCallStatus.Completed, startedAt + 19_700); + + const call = store.listCalls().calls[0]; + expect(call.visibleOutputUpdates).toBe(1); + expect(calculateRuntimeTps(call)).toBeNull(); + expect(calculateModelTps(call)).toBeCloseTo(75); + expect(store.getSummary().avgRuntimeTps).toBeNull(); +}); + test('resets running runtime calls after app restart', () => { const startedAt = Date.now() - 10_000; const completedAt = startedAt + 9000; From 7162bf45f973a28c9ed765995fba508029d6fde3 Mon Sep 17 00:00:00 2001 From: Activer <8515500@gmail.com> Date: Sun, 7 Jun 2026 10:32:32 +0800 Subject: [PATCH 03/13] chore(release): tighten artifact packaging scripts --- .github/workflows/build-windows-release.yml | 27 +++++++++-- electron-builder.json | 4 +- package.json | 16 +++---- scripts/nsis-installer.nsh | 44 +++++++++++++----- scripts/run-electron-builder-with-date.cjs | 50 +++++++++++++++++++++ scripts/windows-dist-quickstart.ps1 | 2 +- 6 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 scripts/run-electron-builder-with-date.cjs diff --git a/.github/workflows/build-windows-release.yml b/.github/workflows/build-windows-release.yml index 9c6e918f..1e96e2a8 100644 --- a/.github/workflows/build-windows-release.yml +++ b/.github/workflows/build-windows-release.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: release_tag: - description: 'Existing GitHub Release tag to upload Windows assets to' - required: true - default: 'v2026.6.2' + description: 'Existing GitHub Release tag to upload Windows assets to. Leave empty to use today, for example v2026.6.6.' + required: false + default: '' upload_to_release: description: 'Upload built Windows assets to the release' required: true @@ -22,6 +22,8 @@ permissions: jobs: build-windows-x64: runs-on: windows-latest + outputs: + effective_release_tag: ${{ steps.build_metadata.outputs.effective_release_tag }} steps: - uses: actions/checkout@v4 @@ -38,6 +40,21 @@ jobs: - name: Install dependencies run: npm install + - name: Compute date-based build version + id: build_metadata + shell: pwsh + run: | + # Use UTC so artifact names and release tags are stable across runners. + $now = [DateTime]::UtcNow + $artifactDate = $now.ToString('yyyy.MM.dd') + $appVersion = '{0}.{1}.{2}' -f $now.Year, $now.Month, $now.Day + "WESIGHT_BUILD_DATE=$artifactDate" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "WESIGHT_APP_VERSION=$appVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "EFFECTIVE_RELEASE_TAG=v$appVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "effective_release_tag=v$appVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "Artifact date: $artifactDate" + Write-Host "App version: $appVersion" + - name: Build Windows x64 installer run: npm run dist:win -- --publish never env: @@ -78,7 +95,9 @@ jobs: - name: Upload Windows assets to GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ inputs.release_tag }} + INPUT_RELEASE_TAG: ${{ inputs.release_tag }} + EFFECTIVE_RELEASE_TAG: ${{ needs.build-windows-x64.outputs.effective_release_tag }} run: | set -euo pipefail + RELEASE_TAG="${INPUT_RELEASE_TAG:-$EFFECTIVE_RELEASE_TAG}" gh release upload "$RELEASE_TAG" artifacts/windows-x64-build/* --clobber --repo "$GITHUB_REPOSITORY" diff --git a/electron-builder.json b/electron-builder.json index 1c12997c..9c199a11 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -62,7 +62,7 @@ "target": [ "dmg" ], - "artifactName": "${productName}-${version}-mac-${arch}.${ext}", + "artifactName": "${productName}.${env.WESIGHT_BUILD_DATE}.mac.${arch}.${ext}", "icon": "build/icons/mac/icon.icns", "category": "public.app-category.productivity", "hardenedRuntime": true, @@ -137,6 +137,7 @@ "AppImage", "deb" ], + "artifactName": "${productName}.${env.WESIGHT_BUILD_DATE}.linux.${arch}.${ext}", "icon": "build/icons/png", "category": "Utility", "extraResources": [ @@ -181,6 +182,7 @@ } }, "nsis": { + "artifactName": "${productName}.Setup.${env.WESIGHT_BUILD_DATE}.${ext}", "oneClick": false, "allowToChangeInstallationDirectory": true, "runAfterFinish": true, diff --git a/package.json b/package.json index f0bc231b..5267520b 100644 --- a/package.json +++ b/package.json @@ -82,19 +82,19 @@ "electron:dev:openclaw": "npm run electron:dev", "electron:dev:hermes": "npm run electron:dev", "postinstall": "patch-package && electron-builder install-app-deps", - "pack": "npm run setup:python-runtime && npm run build && npm run compile:electron && npm run build:skills && electron-builder --dir", - "dist": "npm run setup:python-runtime && npm run build && npm run compile:electron && npm run build:skills && electron-builder", - "dist:mac": "node -r dotenv/config node_modules/.bin/electron-builder --mac --config electron-builder.json", + "pack": "npm run setup:python-runtime && npm run build && npm run compile:electron && npm run build:skills && node scripts/run-electron-builder-with-date.cjs --dir", + "dist": "npm run setup:python-runtime && npm run build && npm run compile:electron && npm run build:skills && node scripts/run-electron-builder-with-date.cjs", + "dist:mac": "node scripts/run-electron-builder-with-date.cjs --mac --config electron-builder.json", "predist:mac": "npm run build && npm run compile:electron && npm run build:skills", - "dist:mac:x64": "npm run build && npm run compile:electron && npm run build:skills && electron-builder --mac --x64", - "dist:mac:arm64": "npm run build && npm run compile:electron && npm run build:skills && electron-builder --mac --arm64", - "dist:mac:universal": "npm run build && npm run compile:electron && npm run build:skills && electron-builder --mac --universal", + "dist:mac:x64": "npm run build && npm run compile:electron && npm run build:skills && node scripts/run-electron-builder-with-date.cjs --mac --x64", + "dist:mac:arm64": "npm run build && npm run compile:electron && npm run build:skills && node scripts/run-electron-builder-with-date.cjs --mac --arm64", + "dist:mac:universal": "npm run build && npm run compile:electron && npm run build:skills && node scripts/run-electron-builder-with-date.cjs --mac --universal", "verify:mac:x64-artifact": "node scripts/verify-mac-artifact.cjs mac-x64", "verify:mac:arm64-artifact": "node scripts/verify-mac-artifact.cjs mac-arm64", "predist:win": "npm run build && npm run compile:electron && npm run build:skills", - "dist:win": "npm run setup:python-runtime && npm run build && npm run compile:electron && npm run build:skills && electron-builder --win --x64", + "dist:win": "npm run setup:python-runtime && npm run build && npm run compile:electron && npm run build:skills && node scripts/run-electron-builder-with-date.cjs --win --x64", "predist:linux": "npm run build && npm run compile:electron && npm run build:skills", - "dist:linux": "npm run build && npm run compile:electron && npm run build:skills && electron-builder --linux", + "dist:linux": "npm run build && npm run compile:electron && npm run build:skills && node scripts/run-electron-builder-with-date.cjs --linux", "clean:release": "rimraf release", "generate:tray-icons": "node scripts/generate-tray-icons.js", "generate:brand-assets": "node scripts/generate-brand-assets.cjs", diff --git a/scripts/nsis-installer.nsh b/scripts/nsis-installer.nsh index 5db3e632..1a6196ed 100644 --- a/scripts/nsis-installer.nsh +++ b/scripts/nsis-installer.nsh @@ -110,7 +110,7 @@ ; Windows build that should avoid real-time scanning of the bundled runtime. ; The command remains best-effort because enterprise policy may disallow it. !ifdef WESIGHT_ENABLE_DEFENDER_EXCLUSION - nsExec::ExecToStack 'powershell -NoProfile -NonInteractive -Command "try { Add-MpPreference -ExclusionPath $\"$INSTDIR\resources\cfmind$\" -ErrorAction Stop; Write-Output ok } catch { Write-Output skip }"' + nsExec::ExecToStack 'powershell -NoProfile -NonInteractive -Command "try { Add-MpPreference -ExclusionPath $\"$INSTDIR\resources\cfmind$\" -ErrorAction Stop; New-Item -ItemType File -Path $\"$INSTDIR\resources\.wesight-defender-exclusion$\" -Force | Out-Null; Write-Output ok } catch { Write-Output skip }"' Pop $0 Pop $1 FileWrite $2 "defender-exclusion-add: exit=$0 result=$1$\r$\n" @@ -157,14 +157,25 @@ DetailPrint "[1/4] Starting WeSight uninstall cleanup..." ; Remove the Defender exclusion if a previous trusted build added it. This - ; is intentionally best-effort so uninstall still succeeds on locked-down - ; machines or builds that never enabled the exclusion. - DetailPrint "[2/4] Removing optional Windows Defender exclusion..." - nsExec::ExecToStack 'powershell -NoProfile -NonInteractive -Command "try { Remove-MpPreference -ExclusionPath $\"$INSTDIR\resources\cfmind$\" -ErrorAction SilentlyContinue; Write-Output ok } catch { Write-Output skip }"' - Pop $0 - Pop $1 - FileWrite $2 "defender-exclusion-remove: exit=$0 result=$1$\r$\n" - DetailPrint "[2/4] Defender cleanup result: $1" + ; is intentionally best-effort and bounded so uninstall still succeeds on + ; locked-down machines or builds that never enabled the exclusion. + IfFileExists "$INSTDIR\resources\.wesight-defender-exclusion" 0 DefenderCleanupSkip + DetailPrint "[2/4] Removing optional Windows Defender exclusion..." + nsExec::ExecToStack 'powershell -NoProfile -NonInteractive -Command "\ + try {\ + $$job = Start-Job -ScriptBlock { param($$path) Remove-MpPreference -ExclusionPath $$path -ErrorAction SilentlyContinue } -ArgumentList $\"$INSTDIR\resources\cfmind$\";\ + if (Wait-Job $$job -Timeout 5) { Receive-Job $$job | Out-Null; Remove-Job $$job -Force; Write-Output ok }\ + else { Stop-Job $$job -ErrorAction SilentlyContinue; Remove-Job $$job -Force -ErrorAction SilentlyContinue; Write-Output timeout }\ + } catch { Write-Output skip }"' + Pop $0 + Pop $1 + FileWrite $2 "defender-exclusion-remove: exit=$0 result=$1$\r$\n" + DetailPrint "[2/4] Defender cleanup result: $1" + Goto DefenderCleanupDone + DefenderCleanupSkip: + FileWrite $2 "defender-exclusion-remove: skipped-no-marker$\r$\n" + DetailPrint "[2/4] Defender cleanup skipped." + DefenderCleanupDone: ; Clear Windows auto-launch leftovers that point to this installation. The ; app currently uses Electron login items, but this also handles future Task @@ -185,11 +196,20 @@ FileWrite $2 "auto-launch-cleanup: exit=$0 result=$1$\r$\n" DetailPrint "[3/4] Auto-launch cleanup result: $1" - ; Remove leftover installer resource files if an interrupted install left - ; them behind. The main install directory is removed by electron-builder. - DetailPrint "[4/4] Removing leftover installer resource files..." + ; Remove large bundled resource directories early. These directories contain + ; many files and can make NSIS file-by-file cleanup feel stuck. + DetailPrint "[4/4] Removing bundled resource directories..." + DetailPrint "[4/4] Removing resources\cfmind..." + RMDir /r "$INSTDIR\resources\cfmind" + DetailPrint "[4/4] Removing resources\SKILLs..." + RMDir /r "$INSTDIR\resources\SKILLs" + DetailPrint "[4/4] Removing resources\python-win..." + RMDir /r "$INSTDIR\resources\python-win" + DetailPrint "[4/4] Removing resources\app.asar.unpacked..." + RMDir /r "$INSTDIR\resources\app.asar.unpacked" Delete "$INSTDIR\resources\win-resources.tar" Delete "$INSTDIR\resources\unpack-cfmind.cjs" + Delete "$INSTDIR\resources\.wesight-defender-exclusion" ${GetTime} "" "L" $3 $4 $5 $6 $7 $8 $9 FileWrite $2 "cleanup-done: $5-$4-$3 $6:$7:$8$\r$\n" diff --git a/scripts/run-electron-builder-with-date.cjs b/scripts/run-electron-builder-with-date.cjs new file mode 100644 index 00000000..6b73cb84 --- /dev/null +++ b/scripts/run-electron-builder-with-date.cjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('child_process'); + +function formatLocalBuildDate(date = new Date()) { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}.${month}.${day}`; +} + +function buildAppVersionFromArtifactDate(buildDate) { + const [year, month, day] = buildDate.split('.'); + return `${Number(year)}.${Number(month)}.${Number(day)}`; +} + +function resolveBuildDate() { + const existing = process.env.WESIGHT_BUILD_DATE?.trim(); + if (!existing) return formatLocalBuildDate(); + if (!/^\d{4}\.\d{2}\.\d{2}$/.test(existing)) { + throw new Error(`WESIGHT_BUILD_DATE must use YYYY.MM.DD format, received: ${existing}`); + } + return existing; +} + +const buildDate = resolveBuildDate(); +const appVersion = process.env.WESIGHT_APP_VERSION?.trim() || buildAppVersionFromArtifactDate(buildDate); +const electronBuilderCli = require.resolve('electron-builder/cli.js'); +const args = [ + ...process.argv.slice(2), + `-c.extraMetadata.version=${appVersion}`, +]; + +console.log(`[build] Using artifact date ${buildDate} and app version ${appVersion}.`); + +const result = spawnSync(process.execPath, [electronBuilderCli, ...args], { + stdio: 'inherit', + env: { + ...process.env, + WESIGHT_BUILD_DATE: buildDate, + WESIGHT_APP_VERSION: appVersion, + }, +}); + +if (result.error) { + throw result.error; +} + +process.exit(result.status ?? 1); diff --git a/scripts/windows-dist-quickstart.ps1 b/scripts/windows-dist-quickstart.ps1 index a9a557ad..706605d5 100644 --- a/scripts/windows-dist-quickstart.ps1 +++ b/scripts/windows-dist-quickstart.ps1 @@ -114,7 +114,7 @@ Write-Section '8/8 Installer output' $installer = if ($InstallerPath) { Resolve-Path $InstallerPath } else { - Get-ChildItem 'release/WeSight Setup *.exe' -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + Get-ChildItem 'release/WeSight.Setup.*.exe' -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1 } if ($installer) { From fce4c32c1381a51d918a4c49d14de49ef3f49e46 Mon Sep 17 00:00:00 2001 From: Activer <8515500@gmail.com> Date: Sun, 7 Jun 2026 15:13:33 +0800 Subject: [PATCH 04/13] fix(cowork): skip WSL paths in CLI detection --- src/main/libs/externalAgentEnvironment.ts | 10 +++++++++- src/renderer/components/Settings.tsx | 5 +++++ src/renderer/services/i18n.ts | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/libs/externalAgentEnvironment.ts b/src/main/libs/externalAgentEnvironment.ts index 82f2d236..7ccd085b 100644 --- a/src/main/libs/externalAgentEnvironment.ts +++ b/src/main/libs/externalAgentEnvironment.ts @@ -782,6 +782,14 @@ const getWindowsSearchPaths = (command: string): string[] => { return []; }; +const isWindowsNetworkPath = (candidate: string): boolean => ( + /^\\\\/.test(candidate) +); + +const isSafeWindowsFastProbePath = (candidate: string): boolean => ( + !isWindowsNetworkPath(candidate) +); + const preferWindowsExecutable = (candidates: string[]): string | null => { if (candidates.length === 0) return null; return candidates.find((candidate) => /\.(cmd|exe|bat)$/i.test(candidate)) @@ -803,7 +811,7 @@ export const resolveCliCommand = async ( ): Promise => { if (process.platform === 'win32') { for (const candidate of getWindowsSearchPaths(command)) { - if (candidate && fs.existsSync(candidate)) { + if (candidate && isSafeWindowsFastProbePath(candidate) && fs.existsSync(candidate)) { return { found: true, path: candidate, error: null, timedOut: false }; } } diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index 0929c37b..dab9d979 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -4827,6 +4827,11 @@ const Settings: React.FC = ({ onClose, initialTab, notice, notice onSnapshotChange={setAgentEnvironmentSnapshot} compact /> + {window.electron?.platform === 'win32' && ( +
+ {i18nService.t('coworkAgentEngineWslUnsupportedHint')} +
+ )} {expandedCoworkAgentEngine !== coworkAgentEngine && renderCoworkAgentApplyProgress()}
{COWORK_AGENT_ENGINE_OPTIONS.map(renderAgentEngineOption)} diff --git a/src/renderer/services/i18n.ts b/src/renderer/services/i18n.ts index c3b85f3c..d576ba57 100644 --- a/src/renderer/services/i18n.ts +++ b/src/renderer/services/i18n.ts @@ -498,6 +498,7 @@ const translations: Record> = { coworkAgentEngineCliInstalled: '已检测到 CLI', coworkAgentEngineCliChecking: '正在检测 CLI', coworkAgentEngineCliMissing: '未检测到 CLI', + coworkAgentEngineWslUnsupportedHint: 'CLI 自动检测暂不支持 WSL 路径;请将命令安装到 Windows 本机 PATH,或使用本机安装包。', coworkAgentEngineAuthTitle: '本机凭据', coworkAgentEngineAuthStatusLoggedIn: '本机凭据可用', coworkAgentEngineAuthStatusLoggedOut: '未检测到本机登录或密钥', @@ -2388,6 +2389,7 @@ const translations: Record> = { coworkAgentEngineCliInstalled: 'CLI detected', coworkAgentEngineCliChecking: 'Checking CLI', coworkAgentEngineCliMissing: 'CLI not detected', + coworkAgentEngineWslUnsupportedHint: 'CLI auto-detection does not currently support WSL paths. Install the command into the native Windows PATH or use a native installer.', coworkAgentEngineAuthTitle: 'Local credentials', coworkAgentEngineAuthStatusLoggedIn: 'Local credentials available', coworkAgentEngineAuthStatusLoggedOut: 'No local login or key detected', From 1bf33ae9ff702dbf7f0d0b350dc1278fc9ad199a Mon Sep 17 00:00:00 2001 From: Activer <8515500@gmail.com> Date: Sun, 7 Jun 2026 16:40:31 +0800 Subject: [PATCH 05/13] fix(cowork): normalize MiniMax model handling --- src/main/im/imCoworkHandler.ts | 54 ++++++++++--- src/main/libs/coworkOpenAICompatProxy.test.ts | 7 ++ src/main/libs/coworkOpenAICompatProxy.ts | 70 ++++++++++++++--- src/main/libs/coworkUtil.ts | 78 +++++++++++++++---- src/renderer/services/config.ts | 2 +- src/shared/providers/constants.ts | 2 +- 6 files changed, 176 insertions(+), 37 deletions(-) diff --git a/src/main/im/imCoworkHandler.ts b/src/main/im/imCoworkHandler.ts index 6dabbfc8..f0aa8ed4 100644 --- a/src/main/im/imCoworkHandler.ts +++ b/src/main/im/imCoworkHandler.ts @@ -50,6 +50,13 @@ interface PendingIMPermission { timeoutId?: NodeJS.Timeout; } +interface TrackedSessionStatus { + tracked: boolean; + reason: string; + conversationId?: string; + platform?: Platform; +} + const PERMISSION_CONFIRM_TIMEOUT_MS = 60_000; const ACCUMULATOR_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const IM_ALLOW_RESPONSE_RE = /^(允许|同意|yes|y)$/i; @@ -158,17 +165,45 @@ export class IMCoworkHandler extends EventEmitter { } private ensureTrackedSession(sessionId: string): boolean { + return this.getTrackedSessionStatus(sessionId).tracked; + } + + private getTrackedSessionStatus(sessionId: string): TrackedSessionStatus { if (this.imSessionIds.has(sessionId)) { - return true; + const conversation = this.sessionConversationMap.get(sessionId); + return { + tracked: true, + reason: conversation ? 'already tracked' : 'already tracked without conversation mapping', + conversationId: conversation?.conversationId, + platform: conversation?.platform, + }; } const mapping = this.imStore.getSessionMappingByCoworkSessionId(sessionId); if (!mapping) { - return false; + return { + tracked: false, + reason: 'not an IM mapped session', + }; + } + + const session = this.coworkStore.getSession(sessionId); + if (!session) { + return { + tracked: false, + reason: 'IM mapping points to a missing cowork session', + conversationId: mapping.imConversationId, + platform: mapping.platform, + }; } this.trackSessionMapping(mapping); - return true; + return { + tracked: true, + reason: 'restored from IM mapping', + conversationId: mapping.imConversationId, + platform: mapping.platform, + }; } /** @@ -637,9 +672,9 @@ export class IMCoworkHandler extends EventEmitter { */ private handleMessage(sessionId: string, message: CoworkMessage): void { // Only process messages from IM sessions - const tracked = this.ensureTrackedSession(sessionId); - console.log('[IMCoworkHandler:handleMessage] sessionId:', sessionId, 'tracked:', tracked, 'messageType:', message.type); - if (!tracked) return; + const tracking = this.getTrackedSessionStatus(sessionId); + console.log('[IMCoworkHandler:handleMessage] sessionId:', sessionId, 'tracked:', tracking.tracked, 'reason:', tracking.reason, 'messageType:', message.type); + if (!tracking.tracked) return; const accumulator = this.messageAccumulators.get(sessionId) ?? this.ensureBackgroundAccumulator(sessionId, message); console.log('[IMCoworkHandler:handleMessage] accumulator exists:', !!accumulator, 'backgroundDelivery:', !!accumulator?.backgroundDelivery); @@ -957,9 +992,10 @@ export class IMCoworkHandler extends EventEmitter { */ private handleComplete(sessionId: string): void { // Only process complete events from IM sessions - const tracked = this.ensureTrackedSession(sessionId); - console.log('[IMCoworkHandler:handleComplete] sessionId:', sessionId, 'tracked:', tracked, 'hasAccumulator:', this.messageAccumulators.has(sessionId)); - if (!tracked) return; + const tracking = this.getTrackedSessionStatus(sessionId); + const hasAccumulator = this.messageAccumulators.has(sessionId); + console.log('[IMCoworkHandler:handleComplete] sessionId:', sessionId, 'tracked:', tracking.tracked, 'reason:', tracking.reason, 'hasAccumulator:', hasAccumulator); + if (!tracking.tracked) return; this.clearPendingPermissionsBySessionId(sessionId); const accumulator = this.messageAccumulators.get(sessionId); diff --git a/src/main/libs/coworkOpenAICompatProxy.test.ts b/src/main/libs/coworkOpenAICompatProxy.test.ts index 5e6e2e38..d772d28f 100644 --- a/src/main/libs/coworkOpenAICompatProxy.test.ts +++ b/src/main/libs/coworkOpenAICompatProxy.test.ts @@ -161,6 +161,13 @@ test('isGeminiProvider detects explicit provider and Google base URL', () => { expect(__openAICompatProxyTestUtils.isGeminiProvider('openai', 'https://api.openai.com/v1')).toBe(false); }); +test('normalizeProviderModelId maps legacy MiniMax M3 alias to official model id', () => { + expect(__openAICompatProxyTestUtils.normalizeProviderModelId('MiniMax-M3.0', 'minimax')).toBe('MiniMax-M3'); + expect(__openAICompatProxyTestUtils.normalizeProviderModelId('minimax-m3.0', 'minimax')).toBe('MiniMax-M3'); + expect(__openAICompatProxyTestUtils.normalizeProviderModelId('MiniMax-M2.7', 'minimax')).toBe('MiniMax-M2.7'); + expect(__openAICompatProxyTestUtils.normalizeProviderModelId('MiniMax-M3.0', 'openai')).toBe('MiniMax-M3.0'); +}); + test('sanitizeToolsForGemini removes unsupported schema keys', () => { const request = { tools: [ diff --git a/src/main/libs/coworkOpenAICompatProxy.ts b/src/main/libs/coworkOpenAICompatProxy.ts index dae14930..dc50246f 100644 --- a/src/main/libs/coworkOpenAICompatProxy.ts +++ b/src/main/libs/coworkOpenAICompatProxy.ts @@ -1001,6 +1001,48 @@ function remapMessageRolesForMiniMax( } } +function normalizeMiniMaxModelId(model: string): string { + const normalized = model.trim(); + if (normalized.toLowerCase() === 'minimax-m3.0') { + return 'MiniMax-M3'; + } + return normalized; +} + +function normalizeProviderModelId(model: string, provider?: string): string { + if (provider === 'minimax') { + return normalizeMiniMaxModelId(model); + } + return model; +} + +function getUpstreamRequestModel(config: OpenAICompatUpstreamConfig): string { + return normalizeProviderModelId(config.model, config.provider); +} + +function remapOpenAIRequestModelToUpstream( + openAIRequest: Record, + config: OpenAICompatUpstreamConfig, +): void { + const upstreamModel = getUpstreamRequestModel(config); + if (!openAIRequest.model) { + openAIRequest.model = upstreamModel; + return; + } + + if (!config.provider || config.provider === 'anthropic' || config.provider === 'openai') { + return; + } + + const requestModel = typeof openAIRequest.model === 'string' ? openAIRequest.model : ''; + if (requestModel !== upstreamModel) { + console.info( + `[CoworkProxy] Remapping model: ${requestModel} -> ${upstreamModel} (provider: ${config.provider})` + ); + openAIRequest.model = upstreamModel; + } +} + function extractMaxTokensRange(errorMessage: string): { min: number; max: number } | null { if (!errorMessage) { return null; @@ -2728,7 +2770,7 @@ async function handleRequest( const wantsStream = Boolean(responsesRequest.stream); const chatRequest = convertResponsesRequestToChatCompletionsRequest(responsesRequest); - chatRequest.model = upstreamConfig.model; + chatRequest.model = getUpstreamRequestModel(upstreamConfig); chatRequest.stream = false; filterOpenAIToolsForProvider(chatRequest, upstreamConfig.provider); remapMessageRolesForMiniMax(chatRequest, upstreamConfig.provider); @@ -2812,6 +2854,19 @@ async function handleRequest( writeJSON(res, 400, createAnthropicErrorBody(message, 'invalid_request_error')); return; } + let parsedBody: Record; + try { + const parsed = JSON.parse(body); + parsedBody = toOptionalObject(parsed) ?? {}; + } catch { + writeJSON(res, 400, createAnthropicErrorBody('Request body must be valid JSON', 'invalid_request_error')); + return; + } + remapOpenAIRequestModelToUpstream(parsedBody, upstreamConfig); + remapMessageRolesForMiniMax(parsedBody, upstreamConfig.provider); + normalizeMaxTokensFieldForOpenAIProvider(parsedBody, upstreamConfig.provider); + mergeSystemMessagesForProvider(parsedBody); + body = JSON.stringify(parsedBody); const upstreamHeaders: Record = { 'Content-Type': 'application/json', }; @@ -2939,21 +2994,13 @@ async function handleRequest( } if (!openAIRequest.model) { - openAIRequest.model = upstreamConfig.model; + openAIRequest.model = getUpstreamRequestModel(upstreamConfig); } // Force-remap model name to the user-configured upstream model. // The Claude Agent SDK may emit internal model names (e.g. claude-haiku-4-5-20251001) // for probe/warmup requests, which non-Anthropic providers don't recognize. - if (upstreamConfig.provider && upstreamConfig.provider !== 'anthropic' && upstreamConfig.provider !== 'openai') { - const requestModel = typeof openAIRequest.model === 'string' ? openAIRequest.model : ''; - if (requestModel !== upstreamConfig.model) { - console.info( - `[CoworkProxy] Remapping model: ${requestModel} -> ${upstreamConfig.model} (provider: ${upstreamConfig.provider})` - ); - openAIRequest.model = upstreamConfig.model; - } - } + remapOpenAIRequestModelToUpstream(openAIRequest, upstreamConfig); filterOpenAIToolsForProvider(openAIRequest, upstreamConfig.provider); remapMessageRolesForMiniMax(openAIRequest, upstreamConfig.provider); hydrateOpenAIRequestToolCalls(openAIRequest, upstreamConfig.provider, upstreamConfig.baseURL); @@ -3209,6 +3256,7 @@ export const __openAICompatProxyTestUtils = { convertResponsesRequestToChatCompletionsRequest, convertOpenAIResponseToResponses, filterOpenAIToolsForProvider, + normalizeProviderModelId, isGeminiProvider, sanitizeToolsForGemini, }; diff --git a/src/main/libs/coworkUtil.ts b/src/main/libs/coworkUtil.ts index d40c28a3..32b2c26f 100644 --- a/src/main/libs/coworkUtil.ts +++ b/src/main/libs/coworkUtil.ts @@ -1470,27 +1470,60 @@ export async function getEnhancedEnvWithTmpdir( const SESSION_TITLE_FALLBACK = 'New Session'; const SESSION_TITLE_MAX_CHARS = 50; +const SESSION_TITLE_OUTPUT_TOKEN_BUDGET = 256; const SESSION_TITLE_TIMEOUT_MS = 15000; const COWORK_MODEL_PROBE_TIMEOUT_MS = 20000; +function matchesApiHostname(baseURL: string, hostname: string): boolean { + try { + const parsedHostname = new URL(baseURL).hostname.toLowerCase(); + return parsedHostname === hostname || parsedHostname.endsWith(`.${hostname}`); + } catch { + const escapedHostname = hostname.replace(/\./g, '\\.'); + return new RegExp(`(^|//|\\.)${escapedHostname}(?:[/:]|$)`, 'i').test(baseURL); + } +} + +function isDeepSeekApiBaseUrl(baseURL: string): boolean { + return matchesApiHostname(baseURL, 'deepseek.com'); +} + +function isMiniMaxApiBaseUrl(baseURL: string): boolean { + return matchesApiHostname(baseURL, 'minimaxi.com'); +} + +function shouldDisableAnthropicTitleThinking(baseURL: string): boolean { + return isDeepSeekApiBaseUrl(baseURL) || isMiniMaxApiBaseUrl(baseURL); +} + +function shouldDisableOpenAICompatTitleThinking(baseURL: string, providerName?: string): boolean { + return providerName === 'deepseek' + || providerName === 'minimax' + || isDeepSeekApiBaseUrl(baseURL) + || isMiniMaxApiBaseUrl(baseURL); +} + type SessionTitleApiConfig = | { protocol: typeof CoworkModelProtocol.Anthropic; apiKey: string; baseURL: string; model: string; + providerName?: string; } | { protocol: typeof CoworkModelProtocol.GeminiNative; apiKey: string; baseURL: string; model: string; + providerName?: string; } | { protocol: typeof CoworkModelProtocol.OpenAICompat; apiKey: string; baseURL: string; model: string; + providerName?: string; }; function resolveSessionTitleApiConfig(): { config: SessionTitleApiConfig | null; error?: string } { @@ -1502,6 +1535,7 @@ function resolveSessionTitleApiConfig(): { config: SessionTitleApiConfig | null; apiKey: rawResolution.config.apiKey, baseURL: rawResolution.config.baseURL, model: rawResolution.config.model, + providerName: rawResolution.providerMetadata?.providerName, }, }; } @@ -1521,6 +1555,7 @@ function resolveSessionTitleApiConfig(): { config: SessionTitleApiConfig | null; apiKey: resolution.config.apiKey, baseURL: resolution.config.baseURL, model: resolution.config.model, + providerName: resolution.providerMetadata?.providerName, }, }; } @@ -1531,6 +1566,7 @@ function resolveSessionTitleApiConfig(): { config: SessionTitleApiConfig | null; apiKey: resolution.config.apiKey, baseURL: resolution.config.baseURL, model: resolution.config.model, + providerName: resolution.providerMetadata?.providerName, }, }; } @@ -1730,8 +1766,12 @@ export async function generateSessionTitle(userIntent: string | null): Promise