mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 02:16:27 +00:00
feat(continuity): support cross-profile continuity inheritance from account profiles
This commit is contained in:
1 parent
dbe8f6f68c
commit
0b9f9826e2
16 files changed
+646
-6
No files matched your search
@@ -0,0 +1,213 @@
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as configLoader from '../../../src/config/unified-config-loader';
|
||||
import ProfileRegistry from '../../../src/auth/profile-registry';
|
||||
import InstanceManager from '../../../src/management/instance-manager';
|
||||
import { resolveProfileContinuityInheritance } from '../../../src/auth/profile-continuity-inheritance';
|
||||
|
||||
describe('resolveProfileContinuityInheritance', () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('returns no inheritance for non-claude targets', async () => {
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'glm',
|
||||
profileType: 'settings',
|
||||
target: 'droid',
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('resolves mapped account and ensures inherited instance path', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
continuity: {
|
||||
inherit_from_account: {
|
||||
glm: 'pro',
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
|
||||
const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||
'/tmp/.ccs/instances/pro'
|
||||
);
|
||||
const getProfilesSpy = spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue(
|
||||
{
|
||||
pro: {
|
||||
type: 'account',
|
||||
created: '2026-03-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'shared',
|
||||
context_group: 'Team Alpha',
|
||||
continuity_mode: 'deeper',
|
||||
},
|
||||
}
|
||||
);
|
||||
const hasUnifiedSpy = spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(true);
|
||||
const touchUnifiedSpy = spyOn(ProfileRegistry.prototype, 'touchAccountUnified').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
const touchLegacySpy = spyOn(ProfileRegistry.prototype, 'touchProfile').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'glm',
|
||||
profileType: 'settings',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceAccount: 'pro',
|
||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
||||
});
|
||||
expect(getProfilesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(hasUnifiedSpy).toHaveBeenCalledWith('pro');
|
||||
expect(touchUnifiedSpy).toHaveBeenCalledWith('pro');
|
||||
expect(touchLegacySpy).not.toHaveBeenCalled();
|
||||
expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', {
|
||||
mode: 'shared',
|
||||
group: 'team-alpha',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
});
|
||||
|
||||
it('supports legacy continuity_inherit_from_account fallback', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
spyOn(configLoader, 'isUnifiedMode').mockReturnValue(false);
|
||||
spyOn(configLoader, 'getConfigJsonPath').mockReturnValue('/tmp/ccs-test-config.json');
|
||||
spyOn(fs, 'existsSync').mockImplementation((filePath) => {
|
||||
return filePath === '/tmp/ccs-test-config.json';
|
||||
});
|
||||
spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
||||
if (filePath === '/tmp/ccs-test-config.json') {
|
||||
return JSON.stringify({
|
||||
continuity_inherit_from_account: {
|
||||
copilot: 'work',
|
||||
},
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
work: {
|
||||
type: 'account',
|
||||
created: '2026-03-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
},
|
||||
});
|
||||
spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(false);
|
||||
spyOn(ProfileRegistry.prototype, 'hasProfile').mockReturnValue(true);
|
||||
const touchLegacySpy = spyOn(ProfileRegistry.prototype, 'touchProfile').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/work');
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'copilot',
|
||||
profileType: 'copilot',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceAccount: 'work',
|
||||
claudeConfigDir: '/tmp/.ccs/instances/work',
|
||||
});
|
||||
expect(touchLegacySpy).toHaveBeenCalledWith('work');
|
||||
});
|
||||
|
||||
it('returns empty result when mapped source account does not exist', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
continuity: {
|
||||
inherit_from_account: {
|
||||
glm: 'missing',
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({});
|
||||
const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance');
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'glm',
|
||||
profileType: 'settings',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(ensureInstanceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not read legacy config fallback when unified mode is active', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
spyOn(configLoader, 'isUnifiedMode').mockReturnValue(true);
|
||||
spyOn(configLoader, 'getConfigJsonPath').mockReturnValue('/tmp/ccs-test-config.json');
|
||||
const existsSpy = spyOn(fs, 'existsSync').mockImplementation((filePath) => {
|
||||
return filePath === '/tmp/ccs-test-config.json';
|
||||
});
|
||||
const readSpy = spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
||||
if (filePath === '/tmp/ccs-test-config.json') {
|
||||
return JSON.stringify({
|
||||
continuity_inherit_from_account: {
|
||||
glm: 'pro',
|
||||
},
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({});
|
||||
const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance');
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'glm',
|
||||
profileType: 'settings',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(ensureInstanceSpy).not.toHaveBeenCalled();
|
||||
expect(existsSpy).not.toHaveBeenCalled();
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports default profile inheritance when explicitly mapped', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
continuity: {
|
||||
inherit_from_account: {
|
||||
default: 'pro',
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
pro: {
|
||||
type: 'account',
|
||||
created: '2026-03-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
},
|
||||
});
|
||||
spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(true);
|
||||
spyOn(ProfileRegistry.prototype, 'touchAccountUnified').mockImplementation(() => undefined);
|
||||
spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/pro');
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'default',
|
||||
profileType: 'default',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceAccount: 'pro',
|
||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -92,4 +92,24 @@ describe('buildClaudeEnvironment codex fallback normalization', () => {
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex');
|
||||
});
|
||||
|
||||
it('propagates inherited CLAUDE_CONFIG_DIR when provided', () => {
|
||||
const settingsPath = createCodexSettingsFile({
|
||||
defaultModel: 'gpt-5.3-codex',
|
||||
opusModel: 'gpt-5.3-codex',
|
||||
sonnetModel: 'gpt-5.3-codex',
|
||||
haikuModel: 'gpt-5-mini',
|
||||
});
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
customSettingsPath: settingsPath,
|
||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { generateCopilotEnv } from '../../../src/copilot/copilot-executor';
|
||||
import type { CopilotConfig } from '../../../src/config/unified-config-types';
|
||||
|
||||
const baseConfig: CopilotConfig = {
|
||||
enabled: true,
|
||||
auto_start: false,
|
||||
port: 4141,
|
||||
account_type: 'individual',
|
||||
rate_limit: null,
|
||||
wait_on_limit: true,
|
||||
model: 'gpt-4.1',
|
||||
};
|
||||
|
||||
describe('generateCopilotEnv', () => {
|
||||
it('includes inherited CLAUDE_CONFIG_DIR when provided', () => {
|
||||
const env = generateCopilotEnv(baseConfig, '/tmp/.ccs/instances/pro');
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro');
|
||||
});
|
||||
|
||||
it('omits CLAUDE_CONFIG_DIR when inheritance is not configured', () => {
|
||||
const env = generateCopilotEnv(baseConfig);
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* Unit tests for unified config module
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// Import only modules without transitive dependencies on config-manager
|
||||
import {
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
UNIFIED_CONFIG_VERSION,
|
||||
} from '../../src/config/unified-config-types';
|
||||
import { isUnifiedConfigEnabled } from '../../src/config/feature-flags';
|
||||
import { loadOrCreateUnifiedConfig } from '../../src/config/unified-config-loader';
|
||||
|
||||
// Inline helper to test secret key detection (utility kept for potential reuse)
|
||||
function isSecretKey(key: string): boolean {
|
||||
@@ -188,3 +192,38 @@ describe('feature-flags', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('continuity-inheritance-config', () => {
|
||||
it('normalizes legacy continuity_inherit_from_account into continuity.inherit_from_account', () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-continuity-home-'));
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'continuity_inherit_from_account:',
|
||||
' glm: pro',
|
||||
' empty: ""',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.continuity?.inherit_from_account).toEqual({
|
||||
glm: 'pro',
|
||||
});
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user