fix(image-analysis): surface runtime readiness

This commit is contained in:
Tam Nhu Tran committed 2026-04-01 15:32:37 -04:00
1 parent d394772f7c
commit 1a01c6fc68
12 files changed
+788 -123

No files matched your search

@@ -1,5 +1,8 @@
import { describe, expect, it } from 'bun:test';
import { generateCopilotEnv } from '../../../src/copilot/copilot-executor';
import {
generateCopilotEnv,
resolveCopilotImageAnalysisEnv,
} from '../../../src/copilot/copilot-executor';
import type { CopilotConfig } from '../../../src/config/unified-config-types';
const baseConfig: CopilotConfig = {
@@ -50,4 +53,94 @@ describe('generateCopilotEnv', () => {
const env = generateCopilotEnv(baseConfig);
expect(env.CLAUDE_CONFIG_DIR).toBeUndefined();
});
it('falls back to native read when copilot image analysis auth is missing', async () => {
const result = await resolveCopilotImageAnalysisEnv(false, {
getImageAnalysisHookEnv: () => ({
CCS_CURRENT_PROVIDER: 'ghcp',
CCS_IMAGE_ANALYSIS_SKIP: '0',
}),
resolveImageAnalysisRuntimeStatus: async () => ({
enabled: true,
supported: true,
status: 'active',
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
resolutionSource: 'copilot-alias',
reason: null,
shouldPersistHook: true,
persistencePath: 'copilot.settings.json',
runtimePath: '/api/provider/ghcp',
usesCurrentTarget: true,
usesCurrentAuthToken: true,
hookInstalled: true,
sharedHookInstalled: true,
authReadiness: 'missing',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
authReason:
'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.',
proxyReadiness: 'stopped',
proxyReason:
'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.',
effectiveRuntimeMode: 'native-read',
effectiveRuntimeReason:
'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.',
}),
});
expect(result.env.CCS_CURRENT_PROVIDER).toBe('');
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1');
expect(result.warning).toContain('ccs ghcp --auth');
});
it('starts local CLIProxy on demand when copilot image analysis is launchable', async () => {
let ensureCalls = 0;
const result = await resolveCopilotImageAnalysisEnv(false, {
getImageAnalysisHookEnv: () => ({
CCS_CURRENT_PROVIDER: 'ghcp',
CCS_IMAGE_ANALYSIS_SKIP: '0',
}),
resolveImageAnalysisRuntimeStatus: async () => ({
enabled: true,
supported: true,
status: 'active',
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
resolutionSource: 'copilot-alias',
reason: null,
shouldPersistHook: true,
persistencePath: 'copilot.settings.json',
runtimePath: '/api/provider/ghcp',
usesCurrentTarget: true,
usesCurrentAuthToken: true,
hookInstalled: true,
sharedHookInstalled: true,
authReadiness: 'ready',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
authReason: null,
proxyReadiness: 'stopped',
proxyReason:
'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.',
effectiveRuntimeMode: 'cliproxy-image-analysis',
effectiveRuntimeReason: null,
}),
ensureCliproxyService: async () => {
ensureCalls += 1;
return {
started: true,
alreadyRunning: false,
port: 8317,
};
},
});
expect(ensureCalls).toBe(1);
expect(result.env.CCS_CURRENT_PROVIDER).toBe('ghcp');
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
expect(result.warning).toBeNull();
});
});
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'bun:test';
import { hydrateImageAnalysisRuntimeStatus } from '../../../../src/utils/hooks/image-analysis-runtime-status';
import type { ImageAnalysisStatus } from '../../../../src/utils/hooks/image-analysis-backend-resolver';
function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalysisStatus {
return {
enabled: true,
supported: true,
status: 'active',
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
resolutionSource: 'profile-backend',
reason: null,
shouldPersistHook: true,
persistencePath: '/tmp/orq.settings.json',
runtimePath: '/api/provider/ghcp',
usesCurrentTarget: true,
usesCurrentAuthToken: true,
hookInstalled: true,
sharedHookInstalled: true,
authReadiness: 'unknown',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
authReason: 'Auth readiness has not been verified yet.',
proxyReadiness: 'unknown',
proxyReason: 'CLIProxy runtime readiness has not been verified yet.',
effectiveRuntimeMode: 'native-read',
effectiveRuntimeReason: null,
...overrides,
};
}
describe('image-analysis-runtime-status', () => {
it('falls back to native read when provider auth is missing', async () => {
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
getProxyTarget: () => ({
host: '127.0.0.1',
port: 8317,
protocol: 'http',
isRemote: false,
}),
initializeAccounts: () => {},
getAuthStatus: () => ({
provider: 'ghcp',
authenticated: false,
tokenDir: '/tmp/auth',
tokenFiles: [],
accounts: [],
defaultAccount: undefined,
}),
isCliproxyRunning: async () => true,
});
expect(status.authReadiness).toBe('missing');
expect(status.effectiveRuntimeMode).toBe('native-read');
expect(status.effectiveRuntimeReason).toContain('ccs ghcp --auth');
});
it('marks an idle local proxy as launchable when auth is ready', async () => {
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
getProxyTarget: () => ({
host: '127.0.0.1',
port: 8317,
protocol: 'http',
isRemote: false,
}),
initializeAccounts: () => {},
getAuthStatus: () => ({
provider: 'ghcp',
authenticated: true,
tokenDir: '/tmp/auth',
tokenFiles: ['github-copilot-test.json'],
accounts: [],
defaultAccount: undefined,
}),
isCliproxyRunning: async () => false,
});
expect(status.authReadiness).toBe('ready');
expect(status.proxyReadiness).toBe('stopped');
expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis');
});
it('treats an unreachable remote proxy as unavailable', async () => {
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
getProxyTarget: () => ({
host: 'remote.example',
port: 443,
protocol: 'https',
authToken: 'token',
managementKey: 'secret',
isRemote: true,
}),
fetchRemoteAuthStatus: async () => [
{
provider: 'ghcp',
displayName: 'GitHub Copilot (OAuth)',
authenticated: true,
tokenFiles: 1,
accounts: [],
defaultAccount: null,
source: 'remote',
},
],
isCliproxyRunning: async () => false,
});
expect(status.authReadiness).toBe('ready');
expect(status.proxyReadiness).toBe('unavailable');
expect(status.effectiveRuntimeMode).toBe('native-read');
expect(status.effectiveRuntimeReason).toContain('remote.example:443');
});
it('keeps hook-missing on native read even when auth and proxy are ready', async () => {
const status = await hydrateImageAnalysisRuntimeStatus(
createStatus({
status: 'hook-missing',
reason: 'Profile hook is missing from the persisted settings file.',
}),
{
getProxyTarget: () => ({
host: '127.0.0.1',
port: 8317,
protocol: 'http',
isRemote: false,
}),
initializeAccounts: () => {},
getAuthStatus: () => ({
provider: 'ghcp',
authenticated: true,
tokenDir: '/tmp/auth',
tokenFiles: ['github-copilot-test.json'],
accounts: [],
defaultAccount: undefined,
}),
isCliproxyRunning: async () => true,
}
);
expect(status.authReadiness).toBe('ready');
expect(status.proxyReadiness).toBe('ready');
expect(status.effectiveRuntimeMode).toBe('native-read');
expect(status.effectiveRuntimeReason).toContain('Profile hook is missing');
});
});
@@ -103,6 +103,8 @@ describe('settings-routes image-analysis status', () => {
resolutionSource: string;
model: string | null;
persistencePath: string | null;
authReadiness: string;
effectiveRuntimeMode: string;
};
};
@@ -111,6 +113,8 @@ describe('settings-routes image-analysis status', () => {
expect(body.imageAnalysisStatus.resolutionSource).toBe('fallback-backend');
expect(body.imageAnalysisStatus.model).toBe('gemini-2.5-flash');
expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json');
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read');
});
it('keeps direct Anthropic settings profiles on native read diagnostics', async () => {
@@ -130,6 +134,8 @@ describe('settings-routes image-analysis status', () => {
shouldPersistHook: boolean;
runtimePath: string | null;
reason: string | null;
authReadiness: string;
proxyReadiness: string;
};
};
@@ -138,6 +144,8 @@ describe('settings-routes image-analysis status', () => {
expect(body.imageAnalysisStatus.shouldPersistHook).toBe(false);
expect(body.imageAnalysisStatus.runtimePath).toBeNull();
expect(body.imageAnalysisStatus.reason).toContain('native file access');
expect(body.imageAnalysisStatus.authReadiness).toBe('not-needed');
expect(body.imageAnalysisStatus.proxyReadiness).toBe('not-needed');
});
it('returns explicit mapped status for custom aliases', async () => {
@@ -169,6 +177,8 @@ describe('settings-routes image-analysis status', () => {
backendId: string | null;
resolutionSource: string;
model: string | null;
authReadiness: string;
effectiveRuntimeMode: string;
};
};
@@ -176,6 +186,8 @@ describe('settings-routes image-analysis status', () => {
expect(body.imageAnalysisStatus.backendId).toBe('ghcp');
expect(body.imageAnalysisStatus.resolutionSource).toBe('profile-backend');
expect(body.imageAnalysisStatus.model).toBe('claude-haiku-4.5');
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read');
});
it('uses the configured custom settings path for status and persistence diagnostics', async () => {
@@ -235,10 +247,12 @@ describe('settings-routes image-analysis status', () => {
imageAnalysisStatus: {
backendId: string | null;
resolutionSource: string;
authReadiness: string;
};
};
expect(body.imageAnalysisStatus.backendId).toBe('ghcp');
expect(body.imageAnalysisStatus.resolutionSource).toBe('cliproxy-bridge');
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
});
});