feat(continuity): support cross-profile continuity inheritance from account profiles

This commit is contained in:
Tam Nhu Tran committed 2026-03-03 02:49:17 +07:00
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();
});
});
+39
View File
@@ -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 });
}
});
});