mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Improve Codex CLI path fallback UX (#1205)
* Improve Codex CLI path fallback UX (#1193) * Handle ENOENT Codex shim fallback
This commit is contained in:
@@ -1077,19 +1077,36 @@ function configuredExecutableOverride(def, configuredEnv = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAgentExecutable(def, configuredEnv = {}) {
|
||||
if (!def?.bin) return null;
|
||||
const configured = configuredExecutableOverride(def, configuredEnv);
|
||||
if (configured) return configured;
|
||||
export function inspectAgentExecutableResolution(def, configuredEnv = {}) {
|
||||
if (!def?.bin) {
|
||||
return {
|
||||
configuredOverridePath: null,
|
||||
pathResolvedPath: null,
|
||||
selectedPath: null,
|
||||
};
|
||||
}
|
||||
const configuredOverridePath = configuredExecutableOverride(def, configuredEnv);
|
||||
const candidates = [
|
||||
def.bin,
|
||||
...(Array.isArray(def.fallbackBins) ? def.fallbackBins : []),
|
||||
];
|
||||
let pathResolvedPath = null;
|
||||
for (const bin of candidates) {
|
||||
const resolved = resolveOnPath(bin);
|
||||
if (resolved) return resolved;
|
||||
if (resolved) {
|
||||
pathResolvedPath = resolved;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return {
|
||||
configuredOverridePath,
|
||||
pathResolvedPath,
|
||||
selectedPath: configuredOverridePath || pathResolvedPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentExecutable(def, configuredEnv = {}) {
|
||||
return inspectAgentExecutableResolution(def, configuredEnv).selectedPath;
|
||||
}
|
||||
|
||||
async function fetchModels(def, resolvedBin, env) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
getAgentDef,
|
||||
inspectAgentExecutableResolution,
|
||||
resolveAgentBin,
|
||||
spawnEnvForAgent,
|
||||
} from './agents.js';
|
||||
@@ -32,6 +33,7 @@ import { createClaudeStreamHandler } from './claude-stream.js';
|
||||
import { createCopilotStreamHandler } from './copilot-stream.js';
|
||||
import { createJsonEventStreamHandler } from './json-event-stream.js';
|
||||
import { agentCliEnvForAgent, validateAgentCliEnv } from './app-config.js';
|
||||
import type { AgentCliEnvPrefs } from './app-config.js';
|
||||
import {
|
||||
isLoopbackApiHost,
|
||||
validateBaseUrl,
|
||||
@@ -62,6 +64,56 @@ const SAMPLE_MAX_CHARS = 120;
|
||||
const PROVIDER_MAX_TOKENS = 100;
|
||||
const SMOKE_PROMPT = 'Reply with only: ok';
|
||||
|
||||
function codexExecutableGuidance(
|
||||
agentId: string,
|
||||
configuredOverridePath: string | null,
|
||||
pathResolvedPath: string | null,
|
||||
): string {
|
||||
if (
|
||||
agentId !== 'codex' ||
|
||||
!configuredOverridePath ||
|
||||
!pathResolvedPath ||
|
||||
configuredOverridePath === pathResolvedPath
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return ` Configured Codex path failed: ${configuredOverridePath}. Open Design also detected a PATH Codex CLI at ${pathResolvedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`;
|
||||
}
|
||||
|
||||
function codexExecutableFallbackSuccessDetail(
|
||||
configuredOverridePath: string,
|
||||
pathResolvedPath: string,
|
||||
): string {
|
||||
return `Configured Codex path failed: ${configuredOverridePath}. This test succeeded with the PATH Codex CLI at ${pathResolvedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`;
|
||||
}
|
||||
|
||||
function codexConfiguredPathSuccessDetail(
|
||||
configuredOverridePath: string,
|
||||
): string {
|
||||
return `This test used the configured Codex path: ${configuredOverridePath}.`;
|
||||
}
|
||||
|
||||
function codexInvalidConfiguredPathFallbackDetail(
|
||||
configuredValue: string,
|
||||
pathResolvedPath: string,
|
||||
): string {
|
||||
return `Configured Codex path is invalid or not executable: ${configuredValue}. This test used the PATH Codex CLI at ${pathResolvedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`;
|
||||
}
|
||||
|
||||
function stripCodexBinOverride(
|
||||
prefs: AgentCliEnvPrefs | undefined,
|
||||
): AgentCliEnvPrefs | undefined {
|
||||
if (!prefs?.codex?.CODEX_BIN) return prefs;
|
||||
const nextCodex = { ...prefs.codex };
|
||||
delete nextCodex.CODEX_BIN;
|
||||
const next: AgentCliEnvPrefs = {
|
||||
...prefs,
|
||||
codex: nextCodex,
|
||||
};
|
||||
if (Object.keys(nextCodex).length === 0) delete next.codex;
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
// Catches `Bearer …`, `x-api-key`/`api-key`/`x-goog-api-key` headers, and
|
||||
// `?key=…` query strings. The provider helpers all funnel error text
|
||||
// through this before logging; if a vendor surfaces the key in body text
|
||||
@@ -862,7 +914,7 @@ function delay(ms: number): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function testAgentConnection(
|
||||
async function testAgentConnectionInternal(
|
||||
input: AgentConnectionInput,
|
||||
): Promise<ConnectionTestResponse> {
|
||||
const start = Date.now();
|
||||
@@ -885,6 +937,10 @@ export async function testAgentConnection(
|
||||
validateAgentCliEnv(input.agentCliEnv),
|
||||
input.agentId,
|
||||
);
|
||||
const executableResolution = inspectAgentExecutableResolution(
|
||||
def,
|
||||
configuredAgentEnv,
|
||||
);
|
||||
const resolvedBin = resolveAgentBin(input.agentId, configuredAgentEnv);
|
||||
if (!resolvedBin) {
|
||||
return {
|
||||
@@ -1052,10 +1108,17 @@ export async function testAgentConnection(
|
||||
if (winner.kind === 'spawnError') {
|
||||
const latencyMs = Date.now() - start;
|
||||
const detail = redactSecrets(winner.error.message);
|
||||
const guidance = redactSecrets(
|
||||
codexExecutableGuidance(
|
||||
input.agentId,
|
||||
executableResolution.configuredOverridePath,
|
||||
executableResolution.pathResolvedPath,
|
||||
),
|
||||
);
|
||||
const errnoCode = (winner.error as NodeJS.ErrnoException).code;
|
||||
const isMissing = errnoCode === 'ENOENT';
|
||||
console.warn(
|
||||
`[test:agent] ${def.name} → spawn_failed: ${detail}`,
|
||||
`[test:agent] ${def.name} → spawn_failed: ${detail}${guidance}`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1063,7 +1126,7 @@ export async function testAgentConnection(
|
||||
latencyMs,
|
||||
model,
|
||||
agentName: def.name,
|
||||
detail,
|
||||
detail: `${detail}${guidance}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1089,9 +1152,16 @@ export async function testAgentConnection(
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
);
|
||||
const guidance = redactSecrets(
|
||||
codexExecutableGuidance(
|
||||
input.agentId,
|
||||
executableResolution.configuredOverridePath,
|
||||
executableResolution.pathResolvedPath,
|
||||
),
|
||||
);
|
||||
const label = buffered ? 'exit_failed' : 'no_text';
|
||||
console.warn(
|
||||
`[test:agent] ${def.name} → ${label} (${detail || 'no detail'})`,
|
||||
`[test:agent] ${def.name} → ${label} (${detail || 'no detail'}${guidance})`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1099,7 +1169,8 @@ export async function testAgentConnection(
|
||||
latencyMs,
|
||||
model,
|
||||
agentName: def.name,
|
||||
detail: detail || 'Agent exited without producing assistant text',
|
||||
detail:
|
||||
`${detail || 'Agent exited without producing assistant text'}${guidance}`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1203,3 +1274,89 @@ export async function testAgentConnection(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function testAgentConnection(
|
||||
input: AgentConnectionInput,
|
||||
): Promise<ConnectionTestResponse> {
|
||||
const primaryResult = await testAgentConnectionInternal(input);
|
||||
const validatedPrefs = validateAgentCliEnv(input.agentCliEnv);
|
||||
const configuredCodexBin = validatedPrefs?.codex?.CODEX_BIN?.trim() || '';
|
||||
const configuredAgentEnv = agentCliEnvForAgent(validatedPrefs, input.agentId);
|
||||
const def = getAgentDef(input.agentId);
|
||||
const executableResolution = def
|
||||
? inspectAgentExecutableResolution(def, configuredAgentEnv)
|
||||
: {
|
||||
configuredOverridePath: null,
|
||||
pathResolvedPath: null,
|
||||
selectedPath: null,
|
||||
};
|
||||
if (
|
||||
input.agentId === 'codex' &&
|
||||
primaryResult.ok &&
|
||||
configuredCodexBin
|
||||
) {
|
||||
if (executableResolution.configuredOverridePath) {
|
||||
return {
|
||||
...primaryResult,
|
||||
configuredExecutablePath: executableResolution.configuredOverridePath,
|
||||
usedExecutablePath: executableResolution.configuredOverridePath,
|
||||
usedExecutableSource: 'configured',
|
||||
...(executableResolution.pathResolvedPath
|
||||
? { detectedExecutablePath: executableResolution.pathResolvedPath }
|
||||
: {}),
|
||||
detail: redactSecrets(
|
||||
codexConfiguredPathSuccessDetail(
|
||||
executableResolution.configuredOverridePath,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (executableResolution.pathResolvedPath) {
|
||||
return {
|
||||
...primaryResult,
|
||||
configuredExecutablePath: configuredCodexBin,
|
||||
detectedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutableSource: 'fallback_invalid',
|
||||
detail: redactSecrets(
|
||||
codexInvalidConfiguredPathFallbackDetail(
|
||||
configuredCodexBin,
|
||||
executableResolution.pathResolvedPath,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
input.agentId !== 'codex' ||
|
||||
primaryResult.ok ||
|
||||
!new Set<ConnectionTestKind>(['agent_spawn_failed', 'agent_not_installed', 'unknown']).has(primaryResult.kind) ||
|
||||
!executableResolution.configuredOverridePath ||
|
||||
!executableResolution.pathResolvedPath ||
|
||||
executableResolution.configuredOverridePath === executableResolution.pathResolvedPath
|
||||
) {
|
||||
return primaryResult;
|
||||
}
|
||||
const fallbackResult = await testAgentConnectionInternal(
|
||||
{
|
||||
...input,
|
||||
agentCliEnv: stripCodexBinOverride(validatedPrefs),
|
||||
},
|
||||
);
|
||||
if (!fallbackResult.ok) {
|
||||
return primaryResult;
|
||||
}
|
||||
return {
|
||||
...fallbackResult,
|
||||
configuredExecutablePath: executableResolution.configuredOverridePath,
|
||||
detectedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutableSource: 'fallback_failed',
|
||||
detail: redactSecrets(
|
||||
codexExecutableFallbackSuccessDetail(
|
||||
executableResolution.configuredOverridePath,
|
||||
executableResolution.pathResolvedPath,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
checkWindowsCmdShimCommandLineBudget,
|
||||
checkWindowsDirectExeCommandLineBudget,
|
||||
detectAgents,
|
||||
inspectAgentExecutableResolution,
|
||||
resolveAgentExecutable,
|
||||
spawnEnvForAgent,
|
||||
} from '../src/agents.js';
|
||||
@@ -1953,6 +1954,33 @@ test('resolveAgentExecutable prefers a configured CODEX_BIN override over PATH r
|
||||
}
|
||||
});
|
||||
|
||||
test('inspectAgentExecutableResolution reports configured and PATH Codex binaries separately', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-codex-bin-inspect-'));
|
||||
try {
|
||||
const configured = join(dir, 'codex-custom');
|
||||
const fallback = join(dir, 'codex');
|
||||
writeFileSync(configured, '#!/bin/sh\nexit 0\n');
|
||||
writeFileSync(fallback, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(configured, 0o755);
|
||||
chmodSync(fallback, 0o755);
|
||||
process.env.PATH = dir;
|
||||
process.env.OD_AGENT_HOME = dir;
|
||||
|
||||
const resolution = inspectAgentExecutableResolution(
|
||||
minimalAgentDef({ id: 'codex', bin: 'codex' }),
|
||||
{ CODEX_BIN: configured },
|
||||
);
|
||||
|
||||
assert.deepEqual(resolution, {
|
||||
configuredOverridePath: configured,
|
||||
pathResolvedPath: fallback,
|
||||
selectedPath: configured,
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveAgentExecutable supports configured binary overrides for non-Codex adapters', () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
['claude', 'claude', 'CLAUDE_BIN'],
|
||||
|
||||
@@ -1223,13 +1223,135 @@ console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_messag
|
||||
ok: true,
|
||||
kind: 'success',
|
||||
agentName: 'Codex CLI',
|
||||
usedExecutableSource: 'configured',
|
||||
configuredExecutablePath: bin,
|
||||
usedExecutablePath: bin,
|
||||
});
|
||||
expect(result.detail).toContain(`This test used the configured Codex path: ${bin}.`);
|
||||
} finally {
|
||||
process.env.PATH = oldPath;
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces when an invalid configured CODEX_BIN was ignored in favor of PATH', async () => {
|
||||
await withFakeCodex(
|
||||
`console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));\n`,
|
||||
async () => {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-invalid-'));
|
||||
try {
|
||||
const invalidBin = path.join(dir, 'codex-missing');
|
||||
const result = await testAgentConnection({
|
||||
agentId: 'codex',
|
||||
agentCliEnv: {
|
||||
codex: {
|
||||
CODEX_BIN: invalidBin,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
kind: 'success',
|
||||
agentName: 'Codex CLI',
|
||||
sample: 'ok',
|
||||
usedExecutableSource: 'fallback_invalid',
|
||||
configuredExecutablePath: invalidBin,
|
||||
detectedExecutablePath: expect.any(String),
|
||||
usedExecutablePath: expect.any(String),
|
||||
});
|
||||
expect(result.detail).toContain(`Configured Codex path is invalid or not executable: ${invalidBin}.`);
|
||||
expect(result.detail).toContain('This test used the PATH Codex CLI at');
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to PATH Codex during connection tests when a configured CODEX_BIN fails', async () => {
|
||||
await withFakeCodex(
|
||||
`console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));\n`,
|
||||
async () => {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-fallback-'));
|
||||
try {
|
||||
const bin = path.join(dir, 'codex-bad');
|
||||
await fsp.writeFile(
|
||||
bin,
|
||||
`#!/usr/bin/env node\nconsole.error('macOS blocked this Codex binary');\nprocess.exit(1);\n`,
|
||||
);
|
||||
await fsp.chmod(bin, 0o755);
|
||||
|
||||
const result = await testAgentConnection({
|
||||
agentId: 'codex',
|
||||
agentCliEnv: {
|
||||
codex: {
|
||||
CODEX_BIN: bin,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
kind: 'success',
|
||||
agentName: 'Codex CLI',
|
||||
sample: 'ok',
|
||||
usedExecutableSource: 'fallback_failed',
|
||||
configuredExecutablePath: bin,
|
||||
detectedExecutablePath: expect.any(String),
|
||||
usedExecutablePath: expect.any(String),
|
||||
});
|
||||
expect(result.detail).toContain(`Configured Codex path failed: ${bin}.`);
|
||||
expect(result.detail).toContain('This test succeeded with the PATH Codex CLI at');
|
||||
expect(result.detail).toContain('Update CODEX_BIN or clear the custom path');
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to PATH Codex when a configured shim spawns ENOENT', async () => {
|
||||
await withFakeCodex(
|
||||
`console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));\n`,
|
||||
async () => {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-stale-shim-'));
|
||||
try {
|
||||
const bin = path.join(dir, 'codex-stale-shim');
|
||||
await fsp.writeFile(
|
||||
bin,
|
||||
'#!/definitely/missing/node\nconsole.log("never runs");\n',
|
||||
);
|
||||
await fsp.chmod(bin, 0o755);
|
||||
|
||||
const result = await testAgentConnection({
|
||||
agentId: 'codex',
|
||||
agentCliEnv: {
|
||||
codex: {
|
||||
CODEX_BIN: bin,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
kind: 'success',
|
||||
agentName: 'Codex CLI',
|
||||
sample: 'ok',
|
||||
usedExecutableSource: 'fallback_failed',
|
||||
configuredExecutablePath: bin,
|
||||
detectedExecutablePath: expect.any(String),
|
||||
usedExecutablePath: expect.any(String),
|
||||
});
|
||||
expect(result.detail).toContain(`Configured Codex path failed: ${bin}.`);
|
||||
expect(result.detail).toContain('This test succeeded with the PATH Codex CLI at');
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('reports OpenCode structured errors without treating them as raw output', async () => {
|
||||
await withFakeOpenCode(
|
||||
`
|
||||
|
||||
@@ -239,6 +239,44 @@ const API_PROTOCOL_LABELS: Record<ApiProtocol, string> = {
|
||||
ollama: 'Ollama Cloud API',
|
||||
};
|
||||
|
||||
function codexPathStrings(locale: Locale) {
|
||||
if (locale === 'zh-CN') {
|
||||
return {
|
||||
repairHint: '当前保存的 Codex 路径不适合继续使用。',
|
||||
useDetected: '使用检测到的 Codex',
|
||||
clearCustom: '清空自定义路径',
|
||||
configuredSuccess: (path: string) => `本次测试使用的是已配置的 Codex 路径:${path}。`,
|
||||
invalidFallback: (configuredPath: string, detectedPath: string) =>
|
||||
`已配置的 Codex 路径无效或不可执行:${configuredPath}。本次测试改用 PATH 中的 Codex CLI:${detectedPath}。建议更新 CODEX_BIN 或清空自定义路径。`,
|
||||
failedFallback: (configuredPath: string, detectedPath: string) =>
|
||||
`已配置的 Codex 路径启动失败:${configuredPath}。本次测试改用 PATH 中的 Codex CLI:${detectedPath}。建议更新 CODEX_BIN 或清空自定义路径。`,
|
||||
};
|
||||
}
|
||||
if (locale === 'zh-TW') {
|
||||
return {
|
||||
repairHint: '目前儲存的 Codex 路徑不適合繼續使用。',
|
||||
useDetected: '使用偵測到的 Codex',
|
||||
clearCustom: '清除自訂路徑',
|
||||
configuredSuccess: (path: string) => `本次測試使用的是已設定的 Codex 路徑:${path}。`,
|
||||
invalidFallback: (configuredPath: string, detectedPath: string) =>
|
||||
`已設定的 Codex 路徑無效或不可執行:${configuredPath}。本次測試改用 PATH 中的 Codex CLI:${detectedPath}。建議更新 CODEX_BIN 或清除自訂路徑。`,
|
||||
failedFallback: (configuredPath: string, detectedPath: string) =>
|
||||
`已設定的 Codex 路徑啟動失敗:${configuredPath}。本次測試改用 PATH 中的 Codex CLI:${detectedPath}。建議更新 CODEX_BIN 或清除自訂路徑。`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
repairHint: 'The saved Codex path is not the binary this test should keep using.',
|
||||
useDetected: 'Use detected Codex',
|
||||
clearCustom: 'Clear custom path',
|
||||
configuredSuccess: (path: string) =>
|
||||
`This test used the configured Codex path: ${path}.`,
|
||||
invalidFallback: (configuredPath: string, detectedPath: string) =>
|
||||
`Configured Codex path is invalid or not executable: ${configuredPath}. This test used the PATH Codex CLI at ${detectedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`,
|
||||
failedFallback: (configuredPath: string, detectedPath: string) =>
|
||||
`Configured Codex path failed: ${configuredPath}. This test succeeded with the PATH Codex CLI at ${detectedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeHttpsUrl(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
@@ -539,6 +577,24 @@ function apiModelOptionLabel(model: ProviderModelOption): string {
|
||||
: model.id;
|
||||
}
|
||||
|
||||
function codexPathRepairState(
|
||||
result: ConnectionTestResponse,
|
||||
): { detectedPath: string; canUseDetected: boolean } | null {
|
||||
if (!result.ok) return null;
|
||||
if (
|
||||
result.usedExecutableSource !== 'fallback_invalid' &&
|
||||
result.usedExecutableSource !== 'fallback_failed'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const detectedPath = result.detectedExecutablePath?.trim() || '';
|
||||
if (!detectedPath) return null;
|
||||
return {
|
||||
detectedPath,
|
||||
canUseDetected: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the modal's footer Save button should be enabled for the
|
||||
* currently active sidebar section.
|
||||
@@ -1034,9 +1090,39 @@ export function SettingsDialog({
|
||||
const agentName = result.agentName ?? '';
|
||||
const testedModel = result.model ?? cfg.model;
|
||||
if (result.ok) {
|
||||
return kindForSuccess === 'api'
|
||||
const baseMessage = kindForSuccess === 'api'
|
||||
? t('settings.testSuccessApi', { ms, sample })
|
||||
: t('settings.testSuccessCli', { agentName, ms, sample });
|
||||
if (kindForSuccess === 'cli' && cfg.agentId === 'codex') {
|
||||
const codexStrings = codexPathStrings(locale);
|
||||
if (
|
||||
result.usedExecutableSource === 'configured' &&
|
||||
result.configuredExecutablePath
|
||||
) {
|
||||
return `${baseMessage} ${codexStrings.configuredSuccess(result.configuredExecutablePath)}`;
|
||||
}
|
||||
if (
|
||||
result.usedExecutableSource === 'fallback_invalid' &&
|
||||
result.configuredExecutablePath &&
|
||||
result.detectedExecutablePath
|
||||
) {
|
||||
return `${baseMessage} ${codexStrings.invalidFallback(
|
||||
result.configuredExecutablePath,
|
||||
result.detectedExecutablePath,
|
||||
)}`;
|
||||
}
|
||||
if (
|
||||
result.usedExecutableSource === 'fallback_failed' &&
|
||||
result.configuredExecutablePath &&
|
||||
result.detectedExecutablePath
|
||||
) {
|
||||
return `${baseMessage} ${codexStrings.failedFallback(
|
||||
result.configuredExecutablePath,
|
||||
result.detectedExecutablePath,
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
return result.detail ? `${baseMessage} ${result.detail}` : baseMessage;
|
||||
}
|
||||
switch (result.kind) {
|
||||
case 'auth_failed':
|
||||
@@ -1099,6 +1185,16 @@ export function SettingsDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const applyCodexDetectedPath = (detectedPath: string) => {
|
||||
setCfg((c) => updateAgentCliEnvValue(c, 'codex', 'CODEX_BIN', detectedPath));
|
||||
setAgentTestState({ status: 'idle' });
|
||||
};
|
||||
|
||||
const clearCodexCustomPath = () => {
|
||||
setCfg((c) => updateAgentCliEnvValue(c, 'codex', 'CODEX_BIN', ''));
|
||||
setAgentTestState({ status: 'idle' });
|
||||
};
|
||||
|
||||
const apiProtocol = cfg.apiProtocol ?? 'anthropic';
|
||||
const baseUrlValid = isValidApiBaseUrl(cfg.baseUrl);
|
||||
const baseUrlInvalid = Boolean(cfg.baseUrl.trim() && !baseUrlValid);
|
||||
@@ -1716,15 +1812,47 @@ export function SettingsDialog({
|
||||
{t('settings.testRunning')}
|
||||
</p>
|
||||
) : agentTestState.status === 'done' ? (
|
||||
<p
|
||||
className={
|
||||
'settings-test-status ' +
|
||||
testStatusVariant(agentTestState.result)
|
||||
}
|
||||
role={agentTestState.result.ok ? 'status' : 'alert'}
|
||||
>
|
||||
{renderTestMessage(agentTestState.result, 'cli')}
|
||||
</p>
|
||||
<>
|
||||
<p
|
||||
className={
|
||||
'settings-test-status ' +
|
||||
testStatusVariant(agentTestState.result)
|
||||
}
|
||||
role={agentTestState.result.ok ? 'status' : 'alert'}
|
||||
>
|
||||
{renderTestMessage(agentTestState.result, 'cli')}
|
||||
</p>
|
||||
{cfg.agentId === 'codex' && (() => {
|
||||
const repair = codexPathRepairState(agentTestState.result);
|
||||
if (!repair) return null;
|
||||
const codexStrings = codexPathStrings(locale);
|
||||
return (
|
||||
<div className="settings-test-actions">
|
||||
<span className="settings-test-actions-hint">
|
||||
{codexStrings.repairHint}
|
||||
</span>
|
||||
<div className="settings-test-actions-row">
|
||||
{repair.canUseDetected ? (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-test-btn"
|
||||
onClick={() => applyCodexDetectedPath(repair.detectedPath)}
|
||||
>
|
||||
{codexStrings.useDetected}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost icon-btn settings-rescan-btn"
|
||||
onClick={clearCodexCustomPath}
|
||||
>
|
||||
{codexStrings.clearCustom}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
) : null}
|
||||
{agents.length === 0 ? (
|
||||
<div className="empty-card">
|
||||
|
||||
@@ -1941,6 +1941,21 @@ code {
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.settings-test-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.settings-test-actions-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.settings-test-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.settings-test-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -170,4 +170,11 @@ export interface ConnectionTestResponse {
|
||||
// Free-form, redacted detail line — surfaced in the `unknown`,
|
||||
// `agent_spawn_failed`, and `upstream_unavailable` copy.
|
||||
detail?: string;
|
||||
// Optional executable-path diagnostics for Local CLI tests. Used by
|
||||
// Settings to explain whether a saved custom path worked, was ignored,
|
||||
// or required a PATH fallback.
|
||||
configuredExecutablePath?: string;
|
||||
detectedExecutablePath?: string;
|
||||
usedExecutablePath?: string;
|
||||
usedExecutableSource?: 'configured' | 'path' | 'fallback_invalid' | 'fallback_failed';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user