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/CHANGELOG.md b/CHANGELOG.md index 275e6baf..c33a5970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,36 @@ 发布说明应从对应版本条目生成。 -## Unreleased - 2026-06-05 +## Unreleased - 2026-06-07 + +### 新增 + +- 新增 WeSight agent CLI programmatic smoke test 脚本和验证文档,覆盖外部 CLI 运行、模型代理和配置同步路径。 +- 新增 OpenAI-compatible proxy、external agent config sync、external agent environment、runtime telemetry 和 session title generation 的测试覆盖。 +- 新增 shared session title helper,统一会话标题上下文提取、markdown 清理、fallback 标题和 LLM prompt 构造逻辑。 + +### 变更 + +- 外部 Agent CLI runtime 集成加固,统一环境变量解析、provider 配置同步、runtime telemetry 和 UI 状态处理。 +- Claude Code 通过 WeSight model/proxy 配置路由,减少外部 CLI 与应用内模型设置不一致的问题。 +- MiniMax provider/model 处理逻辑标准化,覆盖 Cowork、IM Cowork session 和 OpenAI-compatible proxy 请求路径。 +- Windows CLI 探测会跳过 WSL 路径,避免选择 Windows 下无法直接执行的 Linux/WSL CLI。 +- Cowork 会话标题生成改为使用规范化后的多行 prompt context,并与 renderer 临时会话标题 fallback 逻辑保持一致。 +- Windows release workflow、Electron builder 配置和 NSIS installer 脚本进一步收敛,统一带日期的构建输出命名。 + +### 修复 + +- 修复外部 Agent CLI 在 provider 配置、环境变量、代理路由和运行状态同步中的多个不稳定点。 +- 修复 Claude Code 未稳定复用 WeSight 模型配置的问题。 +- 修复 MiniMax 模型名和 provider 兼容处理在不同入口之间不一致的问题。 +- 修复会话标题只依赖 prompt 首行、容易生成过泛标题的问题。 + +### 已知问题 + +- 本分支尚未重新记录 `npm run build`、`npm run lint` 和 `npm test` 的完整结果。 +- 正式发布前仍需按 release gate 确认 Windows/macOS 签名、公证、checksum、SmartScreen/Gatekeeper 信任链。 + +## 2026-06-05 ### 新增 @@ -37,7 +66,7 @@ - `npm run lint` 当前通过但仍有 warning 债务,主要集中在 `any`、unused vars 和 React hook deps。 - Renderer 主 bundle 体积仍偏大,拆包和 bundle budget 需要后续推进。 -## 2026.6.2 - 2026-06-03 +## 2026-06-03 ### 新增 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/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/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/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) { 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/agentEngine/externalCliRuntimeAdapter.test.ts b/src/main/libs/agentEngine/externalCliRuntimeAdapter.test.ts index dd253d7f..753863ab 100644 --- a/src/main/libs/agentEngine/externalCliRuntimeAdapter.test.ts +++ b/src/main/libs/agentEngine/externalCliRuntimeAdapter.test.ts @@ -1,3 +1,6 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { describe, expect, test, vi } from 'vitest'; vi.mock('electron', () => ({ @@ -11,7 +14,10 @@ import { CoworkAgentEngine, ExternalAgentConfigSource, } from '../../../shared/cowork/constants'; +import { ProviderName } from '../../../shared/providers'; import type { CoworkMessage, CoworkStore } from '../../coworkStore'; +import { setStoreGetter } from '../claudeSettings'; +import { acquireWesightClaudeRuntimeConfig } from '../externalAgentConfigSync'; import type { ExternalAgentProvider } from '../externalAgentProviderStore'; import { appendNodeRequireOption, @@ -51,16 +57,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, @@ -78,7 +87,7 @@ const createStore = (codexConfigSource = ExternalAgentConfigSource.LocalCli) => }, } as unknown as CoworkStore; - return { store, messages }; + return { store, messages, session }; }; describe('appendNodeRequireOption', () => { @@ -100,7 +109,62 @@ describe('appendNodeRequireOption', () => { }); describe('ExternalCliRuntimeAdapter Codex local config', () => { - test('uses the selected Codex provider when running with local CLI 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({ engine: CoworkAgentEngine.Codex, @@ -126,11 +190,11 @@ describe('ExternalCliRuntimeAdapter Codex local config', () => { }; const selectedProvider = internals.getSelectedProviderForLocalCli(); - expect(selectedProvider).toBe(codexProvider); + expect(selectedProvider).toBeNull(); const codexHomeDir = internals.prepareCodexHomeForExecMode(env, selectedProvider); - expect(codexHomeDir).toBeTruthy(); - expect(env.CODEX_HOME).toBe(codexHomeDir); - expect(env.OPENAI_API_KEY).toBe('sk-test-provider-key'); + expect(codexHomeDir).toBeNull(); + expect(env.CODEX_HOME).toBeUndefined(); + expect(env.OPENAI_API_KEY).toBeUndefined(); const args = internals.buildCommandArgs( 'D:\\LHA\\wesight', @@ -145,7 +209,38 @@ describe('ExternalCliRuntimeAdapter Codex local config', () => { expect(args).toContain('--json'); expect(args).not.toContain('model_provider="ccswitch_tokln"'); expect(args).not.toContain('model="gpt-5.5"'); - internals.cleanupCodexHomeDir(codexHomeDir); + }); + + 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', () => { @@ -170,6 +265,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({ @@ -180,8 +427,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; @@ -189,8 +438,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(), }; @@ -215,6 +466,287 @@ 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('restores Claude Code runtime settings when releasing an active session', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wesight-claude-adapter-release-')); + const settingsPath = path.join(tempDir, 'settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-local', + ANTHROPIC_BASE_URL: 'https://api.minimaxi.com/anthropic', + ANTHROPIC_MODEL: 'MiniMax-M3.0', + }, + }), + 'utf8', + ); + + const { store } = createStore(ExternalAgentConfigSource.WesightModel); + const adapter = new ExternalCliRuntimeAdapter({ + engine: CoworkAgentEngine.ClaudeCode, + store, + }); + const lease = acquireWesightClaudeRuntimeConfig({ + apiKey: 'sk-wesight', + baseURL: 'http://127.0.0.1:57057', + model: 'deepseek-v4-flash', + apiType: 'openai', + }, settingsPath); + const active = { + sessionId: 'session-1', + claudeRuntimeConfigLease: lease, + }; + const internals = adapter as unknown as { + activeSessions: Map; + releaseActiveSession: (active: typeof active) => void; + }; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + internals.activeSessions.set('session-1', active); + internals.releaseActiveSession(active); + + expect(adapter.isSessionActive('session-1')).toBe(false); + const restored = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Record; + expect(restored).toEqual({ + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-local', + ANTHROPIC_BASE_URL: 'https://api.minimaxi.com/anthropic', + ANTHROPIC_MODEL: 'MiniMax-M3.0', + }, + }); + expect(active.claudeRuntimeConfigLease).toBeNull(); + } finally { + logSpy.mockRestore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + 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({ @@ -256,16 +788,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); @@ -281,8 +817,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/agentEngine/externalCliRuntimeAdapter.ts b/src/main/libs/agentEngine/externalCliRuntimeAdapter.ts index 3a6b4050..f8f7085d 100644 --- a/src/main/libs/agentEngine/externalCliRuntimeAdapter.ts +++ b/src/main/libs/agentEngine/externalCliRuntimeAdapter.ts @@ -22,8 +22,20 @@ 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 { + acquireWesightClaudeRuntimeConfig, + applySingleClaudeCredentialEnv, + type ClaudeRuntimeConfigLease, + cleanupWesightManagedCodexConfig, + releaseWesightClaudeRuntimeConfig, +} from '../externalAgentConfigSync'; +import { + buildWindowsCommandShimArgs, + isWindowsCommandShim, + resolveCliCommand, +} from '../externalAgentEnvironment'; import { applyLocalClaudeCodeEnvForPrintMode, buildClaudeCodeConfigDiagnostics, @@ -54,6 +66,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 +140,7 @@ const CodexCliEventType = { ResponseItem: 'response_item', EventMessage: 'event_msg', TurnFailed: 'turn.failed', + TurnCompleted: 'turn.completed', } as const; const CodexCliItemType = { @@ -141,9 +155,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; @@ -153,9 +169,11 @@ type ActiveCliSession = { noContentTimeoutTimer: ReturnType | null; imagePaths: string[]; codexHomeDir: string | null; + claudeRuntimeConfigLease: ClaudeRuntimeConfigLease | null; localClaudeConfig: LocalClaudeCodeEnvLoadResult | null; configSource: ExternalAgentConfigSource; codexGeneratedImageIds: Set; + completedFromEvent: boolean; }; type ExternalCliRuntimeAdapterDeps = { @@ -168,6 +186,7 @@ type SpawnCommandSpec = { command: string; args: string[]; source: string; + windowsVerbatimArguments?: boolean; }; type AssistantOutputStats = { @@ -176,6 +195,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 +232,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 +349,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 +370,28 @@ 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); + this.releaseClaudeRuntimeConfig(active); + } + } + + private releaseClaudeRuntimeConfig(active: ActiveCliSession): void { + if (!active.claudeRuntimeConfigLease) return; + try { + const restored = releaseWesightClaudeRuntimeConfig(active.claudeRuntimeConfigLease); + console.log('[ExternalCliRuntimeAdapter] released Claude Code runtime settings.', { + settingsPath: active.claudeRuntimeConfigLease.settingsPath, + restored, + }); + } catch (error) { + console.warn('[ExternalCliRuntimeAdapter] failed to restore Claude Code runtime settings:', error); + } finally { + active.claudeRuntimeConfigLease = null; + } + } + getSessionConfirmationMode(_sessionId: string): 'modal' | 'text' | null { return null; } @@ -397,6 +456,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 +472,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 +491,49 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun apiConfigOverride, claudeCodePermissionMode, ); - const spawnSpec = this.resolveSpawnCommandSpec(command, args, env); + const spawnSpec = await this.resolveSpawnCommandSpec(command, args, env); + let claudeRuntimeConfigLease: ClaudeRuntimeConfigLease | null = null; + if (this.engine === CoworkAgentEngine.ClaudeCode && configSource === ExternalAgentConfigSource.WesightModel) { + const apiKey = env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || ''; + const baseURL = env.ANTHROPIC_BASE_URL || ''; + const model = env.ANTHROPIC_MODEL + || env.ANTHROPIC_DEFAULT_SONNET_MODEL + || env.ANTHROPIC_SMALL_FAST_MODEL + || ''; + if (!apiKey || !baseURL || !model) { + this.cleanupImagePaths(imagePaths); + this.cleanupCodexHomeDir(codexHomeDir); + this.handleError(sessionId, 'Claude Code could not use WeSight model config: missing API key, base URL, or model.'); + return; + } + try { + claudeRuntimeConfigLease = acquireWesightClaudeRuntimeConfig({ + apiKey, + baseURL, + model, + apiType: 'anthropic', + }); + applySingleClaudeCredentialEnv(env, apiKey, claudeRuntimeConfigLease.credentialKey); + console.log('[ExternalCliRuntimeAdapter] prepared Claude Code runtime settings.', { + settingsPath: claudeRuntimeConfigLease.settingsPath, + credentialKey: claudeRuntimeConfigLease.credentialKey, + baseUrl: claudeRuntimeConfigLease.baseURL, + model: claudeRuntimeConfigLease.model, + }); + } catch (error) { + this.cleanupImagePaths(imagePaths); + this.cleanupCodexHomeDir(codexHomeDir); + const message = error instanceof Error ? error.message : 'Failed to prepare Claude Code runtime settings.'; + this.handleError(sessionId, message); + return; + } + } 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,31 +554,53 @@ 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), promptChars: effectivePrompt.length, }); } - const child = spawn(spawnSpec.command, spawnSpec.args, { - cwd, - env, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: process.platform === 'win32', - }); + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(spawnSpec.command, spawnSpec.args, { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: process.platform === 'win32', + windowsVerbatimArguments: spawnSpec.windowsVerbatimArguments, + }); + } catch (error) { + this.cleanupImagePaths(imagePaths); + this.cleanupCodexHomeDir(codexHomeDir); + if (claudeRuntimeConfigLease) { + try { + releaseWesightClaudeRuntimeConfig(claudeRuntimeConfigLease); + } catch (releaseError) { + console.warn('[ExternalCliRuntimeAdapter] failed to restore Claude Code runtime settings after spawn failure:', releaseError); + } + } + const message = error instanceof Error ? error.message : 'Failed to spawn external CLI.'; + this.handleError(sessionId, `${this.getEngineDisplayName()} failed to start: ${message}`); + return; + } 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, @@ -481,9 +610,11 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun noContentTimeoutTimer: null, imagePaths, codexHomeDir, + claudeRuntimeConfigLease, localClaudeConfig, configSource, codexGeneratedImageIds: new Set(), + completedFromEvent: false, }; active.startupTimer = setTimeout(() => { if (active.sawEvent) return; @@ -521,7 +652,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 +667,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 +730,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 +739,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 +863,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', @@ -783,7 +937,7 @@ export class ExternalCliRuntimeAdapter extends EventEmitter implements CoworkRun return this.getCurrentProvider?.('claude') ?? null; } if (this.engine === CoworkAgentEngine.Codex) { - return this.getCurrentProvider?.('codex') ?? null; + return null; } if (this.engine === CoworkAgentEngine.OpenCode) { return this.getCurrentProvider?.('opencode') ?? null; @@ -826,11 +980,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 +1023,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 +1160,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 +1184,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 +1218,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 +1282,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 +1582,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 +1645,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 +1672,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 +1687,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 +2173,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 +2343,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 +2371,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 +2482,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.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/claudeSettings.ts b/src/main/libs/claudeSettings.ts index e552a739..bb0c83b4 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'; @@ -18,12 +18,20 @@ type ProviderModel = { supportsImage?: boolean; }; +type QwenOAuthCredentials = { + access: string; + refresh?: string; + expires: number; + resourceUrl?: string; +}; + type ProviderConfig = { enabled: boolean; apiKey: string; baseUrl: string; apiFormat?: 'anthropic' | 'openai' | 'native'; codingPlanEnabled?: boolean; + oauthCredentials?: QwenOAuthCredentials; models?: ProviderModel[]; }; @@ -261,7 +269,7 @@ function resolveMatchedProvider( // Check for API key or OAuth credentials const hasApiKey = providerConfig.apiKey?.trim(); - const hasOAuthCreds = providerName === 'qwen' && (providerConfig as any).oauthCredentials; + const hasOAuthCreds = providerName === 'qwen' && providerConfig.oauthCredentials; if (apiFormat === 'anthropic' && providerRequiresApiKey(providerName) && !providerConfig.apiKey?.trim() && !hasApiKey && !hasOAuthCreds) { const serverFallback = tryWesightServerFallback(modelId); if (serverFallback) return { matched: serverFallback }; @@ -315,8 +323,8 @@ export function resolveCurrentApiConfig( let resolvedApiKey = matched.providerConfig.apiKey?.trim() || ''; // Handle Qwen OAuth credentials - if (matched.providerName === 'qwen' && !resolvedApiKey && (matched.providerConfig as any).oauthCredentials) { - const oauthCreds = (matched.providerConfig as any).oauthCredentials; + if (matched.providerName === 'qwen' && !resolvedApiKey && matched.providerConfig.oauthCredentials) { + const oauthCreds = matched.providerConfig.oauthCredentials; // Check if token is still valid (with 5 minute buffer) const expiryBuffer = 5 * 60 * 1000; if (Date.now() < (oauthCreds.expires - expiryBuffer)) { @@ -387,6 +395,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.oauthCredentials) { + const oauthCreds = matched.providerConfig.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 = {}, @@ -417,8 +543,8 @@ export function resolveRawApiConfig(override: ApiConfigOverride = {}): ApiConfig let effectiveApiFormat = matched.apiFormat; // Handle Qwen OAuth credentials for OpenClaw gateway - if (matched.providerName === 'qwen' && !apiKey && (matched.providerConfig as any).oauthCredentials) { - const oauthCreds = (matched.providerConfig as any).oauthCredentials; + if (matched.providerName === 'qwen' && !apiKey && matched.providerConfig.oauthCredentials) { + const oauthCreds = matched.providerConfig.oauthCredentials; // Check if token is still valid (with 5 minute buffer) const expiryBuffer = 5 * 60 * 1000; if (Date.now() < (oauthCreds.expires - expiryBuffer)) { @@ -539,8 +665,8 @@ export function buildEnvForConfig(config: CoworkApiConfig): Record 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('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: [ + { + 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/coworkOpenAICompatProxy.ts b/src/main/libs/coworkOpenAICompatProxy.ts index de0ba135..dc50246f 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(); } @@ -816,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; @@ -1268,6 +1495,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 +2745,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 = getUpstreamRequestModel(upstreamConfig); + 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')); @@ -2374,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', }; @@ -2501,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); @@ -2768,8 +3253,12 @@ export const __openAICompatProxyTestUtils = { findSSEPacketBoundary, processResponsesStreamEvent, convertChatCompletionsRequestToResponsesRequest, + convertResponsesRequestToChatCompletionsRequest, + convertOpenAIResponseToResponses, filterOpenAIToolsForProvider, + normalizeProviderModelId, isGeminiProvider, + sanitizeToolsForGemini, }; export async function startCoworkOpenAICompatProxy(): Promise { diff --git a/src/main/libs/coworkUtil.ts b/src/main/libs/coworkUtil.ts index d40c28a3..ac7f9f92 100644 --- a/src/main/libs/coworkUtil.ts +++ b/src/main/libs/coworkUtil.ts @@ -3,6 +3,12 @@ import { app } from 'electron'; import { chmodSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'fs'; import { delimiter, dirname, join } from 'path'; +import { + buildFallbackSessionTitle, + buildSessionTitleContext, + buildSessionTitlePrompt, + normalizeSessionTitleToPlainText, +} from '../../shared/cowork/sessionTitle'; import { type ApiConfigOverride,buildEnvForConfig, getCurrentApiConfig, resolveCurrentApiConfig, resolveRawApiConfig } from './claudeSettings'; import { coworkLog } from './coworkLogger'; import { @@ -1469,28 +1475,76 @@ 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 normalizedBaseURL = baseURL.toLowerCase(); + const normalizedHostname = hostname.toLowerCase(); + let matchIndex = normalizedBaseURL.indexOf(normalizedHostname); + + while (matchIndex >= 0) { + const before = matchIndex === 0 ? '' : normalizedBaseURL[matchIndex - 1]; + const after = normalizedBaseURL[matchIndex + normalizedHostname.length] || ''; + const hasValidPrefix = matchIndex === 0 || before === '/' || before === '.'; + const hasValidSuffix = !after || after === '/' || after === ':'; + + if (hasValidPrefix && hasValidSuffix) { + return true; + } + + matchIndex = normalizedBaseURL.indexOf(normalizedHostname, matchIndex + normalizedHostname.length); + } + + return false; + } +} + +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 +1556,7 @@ function resolveSessionTitleApiConfig(): { config: SessionTitleApiConfig | null; apiKey: rawResolution.config.apiKey, baseURL: rawResolution.config.baseURL, model: rawResolution.config.model, + providerName: rawResolution.providerMetadata?.providerName, }, }; } @@ -1521,6 +1576,7 @@ function resolveSessionTitleApiConfig(): { config: SessionTitleApiConfig | null; apiKey: resolution.config.apiKey, baseURL: resolution.config.baseURL, model: resolution.config.model, + providerName: resolution.providerMetadata?.providerName, }, }; } @@ -1531,69 +1587,15 @@ function resolveSessionTitleApiConfig(): { config: SessionTitleApiConfig | null; apiKey: resolution.config.apiKey, baseURL: resolution.config.baseURL, model: resolution.config.model, + providerName: resolution.providerMetadata?.providerName, }, }; } -function normalizeTitleToPlainText(value: string, fallback: string): string { - if (!value.trim()) return fallback; - - let title = value.trim(); - const fenced = /```(?:[\w-]+)?\s*([\s\S]*?)```/i.exec(title); - if (fenced?.[1]) { - title = fenced[1].trim(); - } - - title = title - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') - .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1') - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/__([^_]+)__/g, '$1') - .replace(/\*([^*\n]+)\*/g, '$1') - .replace(/_([^_\n]+)_/g, '$1') - .replace(/~~([^~]+)~~/g, '$1') - .replace(/^\s{0,3}#{1,6}\s+/, '') - .replace(/^\s*>\s?/, '') - .replace(/^\s*[-*+]\s+/, '') - .replace(/^\s*\d+\.\s+/, '') - .replace(/\r?\n+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - const labeledTitle = /^(?:title|标题)\s*[::]\s*(.+)$/i.exec(title); - if (labeledTitle?.[1]) { - title = labeledTitle[1].trim(); - } - - title = title - .replace(/^["'`“”‘’]+/, '') - .replace(/["'`“”‘’]+$/, '') - .trim(); - - if (!title) return fallback; - if (title.length > SESSION_TITLE_MAX_CHARS) { - title = title.slice(0, SESSION_TITLE_MAX_CHARS).trim(); - } - return title || fallback; -} - function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError'; } -function buildFallbackSessionTitle(userIntent: string | null): string { - const normalizedInput = typeof userIntent === 'string' ? userIntent.trim() : ''; - if (!normalizedInput) { - return SESSION_TITLE_FALLBACK; - } - const firstLine = normalizedInput - .split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean) || ''; - return normalizeTitleToPlainText(firstLine, SESSION_TITLE_FALLBACK); -} - export async function probeCoworkModelReadiness( timeoutMs = COWORK_MODEL_PROBE_TIMEOUT_MS ): Promise<{ ok: true } | { ok: false; error: string }> { @@ -1707,9 +1709,9 @@ export async function probeCoworkModelReadiness( } export async function generateSessionTitle(userIntent: string | null): Promise { - const normalizedInput = typeof userIntent === 'string' ? userIntent.trim() : ''; - const fallbackTitle = buildFallbackSessionTitle(normalizedInput); - if (!normalizedInput) { + const titleContext = buildSessionTitleContext(userIntent); + const fallbackTitle = buildFallbackSessionTitle(titleContext, SESSION_TITLE_FALLBACK); + if (!titleContext) { return fallbackTitle; } @@ -1730,8 +1732,8 @@ export async function generateSessionTitle(userIntent: string | null): Promise