mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-16 16:15:19 +00:00
feat!: delegation system overhaul and .claude/ shipping (v4.1.0) (#8)
## 🚀 Release v4.1.0 - Major Update **Breaking changes from v3.5.0**. This release includes the complete v4.0.0 delegation overhaul plus v4.1.0 architecture improvements. --- ## 🎯 v4.0.0: Delegation System Overhaul **Complete rewrite of the delegation infrastructure with enhanced decision-making and streaming support.** ### New Delegation Features **Stream-JSON Communication Protocol:** - Real-time token streaming with `{type: "content", data: "..."}` format - Progress indicators during delegation execution - Clean separation: stdout for data, stderr for errors - Handles tool calls, thinking blocks, and text content **Enhanced Decision Framework:** - `/ccs:glm` and `/ccs:kimi` slash commands with auto-enhancement - `[AUTO ENHANCE]` prompts for better model understanding - Context-aware task delegation with clear boundaries - Continuation support: `/ccs:glm:continue` and `/ccs:kimi:continue` **Robust Error Handling:** - Graceful degradation when profiles unconfigured - Clear error messages with actionable fixes - Signal handling (SIGINT/SIGTERM) for clean child process termination - Session state recovery on interruption **Performance & Reliability:** - Headless mode (`-p` flag) for background execution - Slash command detection and auto-routing - Validation system with `DelegationValidator` - Profile readiness checks in `ccs --version` ### Delegation Components **New Files:** - `bin/delegation/delegation-handler.js` - Core delegation orchestrator - `bin/delegation/stream-processor.js` - Real-time output handling - `bin/utils/delegation-validator.js` - Profile validation - `.claude/commands/ccs/*.md` - Slash command definitions - `.claude/skills/ccs-delegation/` - AI decision framework - `.claude/agents/ccs-delegator.md` - Proactive delegation agent **Documentation:** - Complete delegation workflows with mermaid diagrams - Troubleshooting guides for common issues - Headless execution patterns --- ## ✨ v4.1.0: Selective Symlinking Architecture **Single source of truth for CCS items with automatic propagation.** ### New Architecture **Ship .claude/ Directory:** - CCS items now ship with npm/sh/ps1 packages - Selective item-level symlinks: `~/.ccs/.claude/` → `~/.claude/` - Auto-propagation on `npm update` - zero manual sync - Backward compatible with existing `~/.ccs/shared/` mechanism **Symlink Chain:** ``` ~/.ccs/.claude/ (source) ↓ selective symlinks ~/.claude/ (CCS items installed here) ↑ symlinked by ~/.ccs/shared/ ↑ symlinked by profiles (work, personal, team) ``` ### New Commands **Maintenance Tools:** - `ccs update` - Re-install CCS symlinks to ~/.claude/ - `ccs doctor` - Added Check 9: CCS symlinks health verification **Safe Installation:** - Automatic conflict backup before symlinking - Idempotent operations (safe to run multiple times) - Health monitoring and recovery ### New Components - `bin/utils/claude-symlink-manager.js` - Manages selective symlinks - Updated all 3 installers (npm postinstall, install.sh, install.ps1) - Enhanced `ccs doctor` with symlink health checks --- ## 💥 Breaking Changes **From v3.5.0 → v4.x:** 1. **Delegation commands moved**: - Old: User manually created in `~/.claude/commands/` - New: Auto-shipped in `~/.ccs/.claude/`, symlinked to `~/.claude/commands/ccs/` 2. **Slash command format**: - New: `/ccs:glm`, `/ccs:kimi`, `/ccs:glm:continue` - Old custom commands may need migration 3. **Profile validation**: - Placeholders (`YOUR_API_KEY_HERE`) now detected and marked invalid - Must configure real API keys for delegation to work 4. **Stream output format**: - Headless mode (`-p`) now uses stream-JSON protocol - Old text output replaced with structured `{type, data}` format
This commit is contained in:
1 parent
f40e9647ec
commit
4df5a7d357
36 files changed
+4908
-654
No files matched your search
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { HeadlessExecutor } = require('../../../bin/delegation/headless-executor');
|
||||
|
||||
/**
|
||||
* Test runner
|
||||
*/
|
||||
class TestRunner {
|
||||
constructor() {
|
||||
this.tests = [];
|
||||
this.passed = 0;
|
||||
this.failed = 0;
|
||||
}
|
||||
|
||||
test(name, fn) {
|
||||
this.tests.push({ name, fn });
|
||||
}
|
||||
|
||||
async run() {
|
||||
console.log('\n=== Permission Mode Tests ===\n');
|
||||
|
||||
for (const { name, fn } of this.tests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`[OK] ${name}`);
|
||||
this.passed++;
|
||||
} catch (error) {
|
||||
console.error(`[X] ${name}`);
|
||||
console.error(` Error: ${error.message}`);
|
||||
this.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${this.passed} passed, ${this.failed} failed`);
|
||||
process.exit(this.failed > 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message || 'Assertion failed');
|
||||
}
|
||||
|
||||
// Test suite
|
||||
const runner = new TestRunner();
|
||||
|
||||
/**
|
||||
* Test 1: Validation accepts valid modes
|
||||
*/
|
||||
runner.test('Validate acceptEdits mode', () => {
|
||||
// Should not throw
|
||||
HeadlessExecutor._validatePermissionMode('acceptEdits');
|
||||
});
|
||||
|
||||
runner.test('Validate plan mode', () => {
|
||||
HeadlessExecutor._validatePermissionMode('plan');
|
||||
});
|
||||
|
||||
runner.test('Validate default mode', () => {
|
||||
HeadlessExecutor._validatePermissionMode('default');
|
||||
});
|
||||
|
||||
runner.test('Validate bypassPermissions mode', () => {
|
||||
HeadlessExecutor._validatePermissionMode('bypassPermissions');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 2: Validation rejects invalid modes
|
||||
*/
|
||||
runner.test('Reject invalid mode', () => {
|
||||
let thrown = false;
|
||||
try {
|
||||
HeadlessExecutor._validatePermissionMode('invalidMode');
|
||||
} catch (error) {
|
||||
thrown = true;
|
||||
assert(error.message.includes('Invalid permission mode'), 'Error message should mention invalid mode');
|
||||
assert(error.message.includes('invalidMode'), 'Error should show the invalid value');
|
||||
}
|
||||
assert(thrown, 'Should throw error for invalid mode');
|
||||
});
|
||||
|
||||
runner.test('Reject empty mode', () => {
|
||||
let thrown = false;
|
||||
try {
|
||||
HeadlessExecutor._validatePermissionMode('');
|
||||
} catch (error) {
|
||||
thrown = true;
|
||||
}
|
||||
assert(thrown, 'Should throw error for empty mode');
|
||||
});
|
||||
|
||||
runner.test('Reject null mode', () => {
|
||||
let thrown = false;
|
||||
try {
|
||||
HeadlessExecutor._validatePermissionMode(null);
|
||||
} catch (error) {
|
||||
thrown = true;
|
||||
}
|
||||
assert(thrown, 'Should throw error for null mode');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 3: CLI args construction (simulation)
|
||||
*/
|
||||
runner.test('Build args for acceptEdits mode', () => {
|
||||
const args = ['-p', 'test', '--settings', '/path/settings.json'];
|
||||
const permissionMode = 'acceptEdits';
|
||||
|
||||
if (permissionMode && permissionMode !== 'default') {
|
||||
if (permissionMode === 'bypassPermissions') {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
} else {
|
||||
args.push('--permission-mode', permissionMode);
|
||||
}
|
||||
}
|
||||
|
||||
assert(args.includes('--permission-mode'), 'Should have permission-mode flag');
|
||||
assert(args.includes('acceptEdits'), 'Should have acceptEdits value');
|
||||
assert(!args.includes('--dangerously-skip-permissions'), 'Should not have bypass flag');
|
||||
});
|
||||
|
||||
runner.test('Build args for plan mode', () => {
|
||||
const args = ['-p', 'test', '--settings', '/path/settings.json'];
|
||||
const permissionMode = 'plan';
|
||||
|
||||
if (permissionMode && permissionMode !== 'default') {
|
||||
if (permissionMode === 'bypassPermissions') {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
} else {
|
||||
args.push('--permission-mode', permissionMode);
|
||||
}
|
||||
}
|
||||
|
||||
assert(args.includes('--permission-mode'), 'Should have permission-mode flag');
|
||||
assert(args.includes('plan'), 'Should have plan value');
|
||||
});
|
||||
|
||||
runner.test('Build args for bypassPermissions mode', () => {
|
||||
const args = ['-p', 'test', '--settings', '/path/settings.json'];
|
||||
const permissionMode = 'bypassPermissions';
|
||||
|
||||
if (permissionMode && permissionMode !== 'default') {
|
||||
if (permissionMode === 'bypassPermissions') {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
} else {
|
||||
args.push('--permission-mode', permissionMode);
|
||||
}
|
||||
}
|
||||
|
||||
assert(args.includes('--dangerously-skip-permissions'), 'Should have bypass flag');
|
||||
assert(!args.includes('--permission-mode'), 'Should not have permission-mode flag');
|
||||
});
|
||||
|
||||
runner.test('Build args for default mode (no flag)', () => {
|
||||
const args = ['-p', 'test', '--settings', '/path/settings.json'];
|
||||
const permissionMode = 'default';
|
||||
|
||||
if (permissionMode && permissionMode !== 'default') {
|
||||
if (permissionMode === 'bypassPermissions') {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
} else {
|
||||
args.push('--permission-mode', permissionMode);
|
||||
}
|
||||
}
|
||||
|
||||
assert(!args.includes('--permission-mode'), 'Should not add permission-mode for default');
|
||||
assert(!args.includes('--dangerously-skip-permissions'), 'Should not add bypass for default');
|
||||
assert(args.length === 4, 'Should only have base args');
|
||||
});
|
||||
|
||||
// Run tests
|
||||
runner.run();
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { ResultFormatter } = require('../../../bin/delegation/result-formatter');
|
||||
|
||||
/**
|
||||
* Simple test runner (no external dependencies)
|
||||
*/
|
||||
class TestRunner {
|
||||
constructor() {
|
||||
this.tests = [];
|
||||
this.passed = 0;
|
||||
this.failed = 0;
|
||||
}
|
||||
|
||||
test(name, fn) {
|
||||
this.tests.push({ name, fn });
|
||||
}
|
||||
|
||||
async run() {
|
||||
console.log('\n=== ResultFormatter Tests ===\n');
|
||||
|
||||
for (const { name, fn } of this.tests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`[OK] ${name}`);
|
||||
this.passed++;
|
||||
} catch (error) {
|
||||
console.error(`[X] ${name}`);
|
||||
console.error(` Error: ${error.message}`);
|
||||
this.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${this.passed} passed, ${this.failed} failed`);
|
||||
process.exit(this.failed > 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assertion helpers
|
||||
*/
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message || 'Assertion failed');
|
||||
}
|
||||
|
||||
function assertIncludes(haystack, needle, message) {
|
||||
if (!haystack.includes(needle)) {
|
||||
throw new Error(message || `Expected to include "${needle}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run tests
|
||||
*/
|
||||
const runner = new TestRunner();
|
||||
|
||||
// Test 1: Basic formatting
|
||||
runner.test('Should format successful result', () => {
|
||||
const result = {
|
||||
profile: 'glm',
|
||||
cwd: '/home/user/project',
|
||||
exitCode: 0,
|
||||
stdout: 'Task completed successfully',
|
||||
stderr: '',
|
||||
duration: 2300,
|
||||
success: true
|
||||
};
|
||||
|
||||
const formatted = ResultFormatter.format(result);
|
||||
|
||||
assertIncludes(formatted, 'Delegated to GLM-4.6', 'Should mention model');
|
||||
assertIncludes(formatted, 'ccs:glm', 'Should mention profile');
|
||||
assertIncludes(formatted, '/home/user/project', 'Should include CWD');
|
||||
assertIncludes(formatted, '2.3s', 'Should format duration');
|
||||
assertIncludes(formatted, 'Exit Code: 0', 'Should show exit code');
|
||||
assertIncludes(formatted, '[OK]', 'Should show success');
|
||||
});
|
||||
|
||||
// Test 2: Failed result
|
||||
runner.test('Should format failed result', () => {
|
||||
const result = {
|
||||
profile: 'glm',
|
||||
cwd: '/home/user/project',
|
||||
exitCode: 1,
|
||||
stdout: 'Error occurred',
|
||||
stderr: 'Command failed',
|
||||
duration: 1500,
|
||||
success: false
|
||||
};
|
||||
|
||||
const formatted = ResultFormatter.format(result);
|
||||
|
||||
assertIncludes(formatted, '[X]', 'Should show failure indicator');
|
||||
assertIncludes(formatted, 'Exit Code: 1', 'Should show non-zero exit code');
|
||||
assertIncludes(formatted, 'Delegation failed', 'Should indicate failure');
|
||||
assertIncludes(formatted, 'Stderr:', 'Should include stderr section');
|
||||
assertIncludes(formatted, 'Command failed', 'Should show stderr content');
|
||||
});
|
||||
|
||||
// Test 3: Extract created files
|
||||
runner.test('Should extract created files from output', () => {
|
||||
const output = 'Created: src/auth.js\nCreated: tests/auth.test.js';
|
||||
|
||||
const { created, modified } = ResultFormatter.extractFileChanges(output);
|
||||
|
||||
assert(created.length === 2, 'Should find 2 created files');
|
||||
assertIncludes(created[0], 'src/auth.js', 'Should include first file');
|
||||
assertIncludes(created[1], 'tests/auth.test.js', 'Should include second file');
|
||||
});
|
||||
|
||||
// Test 4: Extract modified files
|
||||
runner.test('Should extract modified files from output', () => {
|
||||
const output = 'Modified: src/index.js\nUpdated: package.json';
|
||||
|
||||
const { created, modified } = ResultFormatter.extractFileChanges(output);
|
||||
|
||||
assert(modified.length === 2, 'Should find 2 modified files');
|
||||
assertIncludes(modified[0], 'src/index.js', 'Should include first file');
|
||||
assertIncludes(modified[1], 'package.json', 'Should include second file');
|
||||
});
|
||||
|
||||
// Test 5: Extract mixed file changes
|
||||
runner.test('Should extract both created and modified files', () => {
|
||||
const output = 'Created: src/new.js\nModified: src/old.js\nCreated: tests/new.test.js';
|
||||
|
||||
const { created, modified } = ResultFormatter.extractFileChanges(output);
|
||||
|
||||
assert(created.length === 2, 'Should find 2 created files');
|
||||
assert(modified.length === 1, 'Should find 1 modified file');
|
||||
});
|
||||
|
||||
// Test 6: No duplicate files in lists
|
||||
runner.test('Should not duplicate files in created/modified lists', () => {
|
||||
const output = 'Created: src/file.js\nCreated: src/file.js\nModified: src/file.js';
|
||||
|
||||
const { created, modified } = ResultFormatter.extractFileChanges(output);
|
||||
|
||||
assert(created.length === 1, 'Should deduplicate created files');
|
||||
assert(modified.length === 0, 'Should not list created files as modified');
|
||||
});
|
||||
|
||||
// Test 7: Format file lists
|
||||
runner.test('Should format file lists in output', () => {
|
||||
const result = {
|
||||
profile: 'glm',
|
||||
cwd: '/home/user/project',
|
||||
exitCode: 0,
|
||||
stdout: 'Created: src/new.js\nModified: src/old.js',
|
||||
stderr: '',
|
||||
duration: 1000,
|
||||
success: true
|
||||
};
|
||||
|
||||
const formatted = ResultFormatter.format(result);
|
||||
|
||||
assertIncludes(formatted, '[i] Created Files:', 'Should have created header');
|
||||
assertIncludes(formatted, 'src/new.js', 'Should list created file');
|
||||
assertIncludes(formatted, '[i] Modified Files:', 'Should have modified header');
|
||||
assertIncludes(formatted, 'src/old.js', 'Should list modified file');
|
||||
});
|
||||
|
||||
// Test 8: ASCII box formatting
|
||||
runner.test('Should use ASCII box characters', () => {
|
||||
const result = {
|
||||
profile: 'glm',
|
||||
cwd: '/home/user/project',
|
||||
exitCode: 0,
|
||||
stdout: 'Done',
|
||||
stderr: '',
|
||||
duration: 1000,
|
||||
success: true
|
||||
};
|
||||
|
||||
const formatted = ResultFormatter.format(result);
|
||||
|
||||
assertIncludes(formatted, '╔', 'Should have top-left corner');
|
||||
assertIncludes(formatted, '╗', 'Should have top-right corner');
|
||||
assertIncludes(formatted, '╚', 'Should have bottom-left corner');
|
||||
assertIncludes(formatted, '╝', 'Should have bottom-right corner');
|
||||
assertIncludes(formatted, '║', 'Should have vertical borders');
|
||||
assertIncludes(formatted, '═', 'Should have horizontal borders');
|
||||
});
|
||||
|
||||
// Test 9: Model display names
|
||||
runner.test('Should use correct model display names', () => {
|
||||
const glmResult = {
|
||||
profile: 'glm',
|
||||
cwd: '/test',
|
||||
exitCode: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
duration: 1000,
|
||||
success: true
|
||||
};
|
||||
|
||||
const glmFormatted = ResultFormatter.format(glmResult);
|
||||
assertIncludes(glmFormatted, 'GLM-4.6', 'Should show GLM-4.6');
|
||||
|
||||
const kimiResult = { ...glmResult, profile: 'kimi' };
|
||||
const kimiFormatted = ResultFormatter.format(kimiResult);
|
||||
assertIncludes(kimiFormatted, 'Kimi', 'Should show Kimi');
|
||||
});
|
||||
|
||||
// Test 10: Duration formatting
|
||||
runner.test('Should format duration correctly', () => {
|
||||
const result = {
|
||||
profile: 'glm',
|
||||
cwd: '/test',
|
||||
exitCode: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
duration: 12345,
|
||||
success: true
|
||||
};
|
||||
|
||||
const formatted = ResultFormatter.format(result);
|
||||
|
||||
assertIncludes(formatted, '12.3s', 'Should format to 1 decimal place');
|
||||
});
|
||||
|
||||
// Test 11: Empty output handling
|
||||
runner.test('Should handle empty output', () => {
|
||||
const result = {
|
||||
profile: 'glm',
|
||||
cwd: '/test',
|
||||
exitCode: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
duration: 1000,
|
||||
success: true
|
||||
};
|
||||
|
||||
const formatted = ResultFormatter.format(result);
|
||||
|
||||
assertIncludes(formatted, 'No output', 'Should indicate no output');
|
||||
});
|
||||
|
||||
// Test 12: Minimal format
|
||||
runner.test('Should support minimal format', () => {
|
||||
const result = {
|
||||
profile: 'glm',
|
||||
cwd: '/test',
|
||||
exitCode: 0,
|
||||
stdout: 'Done',
|
||||
stderr: '',
|
||||
duration: 1500,
|
||||
success: true
|
||||
};
|
||||
|
||||
const minimal = ResultFormatter.formatMinimal(result);
|
||||
|
||||
assertIncludes(minimal, '[OK]', 'Should show success');
|
||||
assertIncludes(minimal, 'GLM-4.6', 'Should show model');
|
||||
assertIncludes(minimal, '1.5s', 'Should show duration');
|
||||
assert(minimal.split('\n').length <= 3, 'Should be concise');
|
||||
});
|
||||
|
||||
// Test 13: Case-insensitive file pattern matching
|
||||
runner.test('Should match file patterns case-insensitively', () => {
|
||||
const output = 'CREATED: src/file.js\nMODIFIED: src/other.js';
|
||||
|
||||
const { created, modified } = ResultFormatter.extractFileChanges(output);
|
||||
|
||||
assert(created.length === 1, 'Should find created file (uppercase)');
|
||||
assert(modified.length === 1, 'Should find modified file (uppercase)');
|
||||
});
|
||||
|
||||
// Test 14: File count in info box
|
||||
runner.test('Should show file counts in info box', () => {
|
||||
const result = {
|
||||
profile: 'glm',
|
||||
cwd: '/test',
|
||||
exitCode: 0,
|
||||
stdout: 'Created: a.js\nCreated: b.js\nModified: c.js',
|
||||
stderr: '',
|
||||
duration: 1000,
|
||||
success: true
|
||||
};
|
||||
|
||||
const formatted = ResultFormatter.format(result);
|
||||
|
||||
assertIncludes(formatted, 'Files Created: 2', 'Should show created count');
|
||||
assertIncludes(formatted, 'Files Modified: 1', 'Should show modified count');
|
||||
});
|
||||
|
||||
// Run all tests
|
||||
runner.run();
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { SessionManager } = require('../../../bin/delegation/session-manager');
|
||||
|
||||
/**
|
||||
* Test runner
|
||||
*/
|
||||
class TestRunner {
|
||||
constructor() {
|
||||
this.tests = [];
|
||||
this.passed = 0;
|
||||
this.failed = 0;
|
||||
}
|
||||
|
||||
test(name, fn) {
|
||||
this.tests.push({ name, fn });
|
||||
}
|
||||
|
||||
async run() {
|
||||
console.log('\n=== Session Manager Tests ===\n');
|
||||
|
||||
for (const { name, fn } of this.tests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`[OK] ${name}`);
|
||||
this.passed++;
|
||||
} catch (error) {
|
||||
console.error(`[X] ${name}`);
|
||||
console.error(` Error: ${error.message}`);
|
||||
this.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${this.passed} passed, ${this.failed} failed`);
|
||||
process.exit(this.failed > 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message || 'Assertion failed');
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, message) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(message || `Expected ${expected}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test suite
|
||||
const runner = new TestRunner();
|
||||
|
||||
// Cleanup test sessions before/after
|
||||
const testSessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json');
|
||||
function cleanupTestSessions() {
|
||||
if (fs.existsSync(testSessionsPath)) {
|
||||
fs.unlinkSync(testSessionsPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test 1: Store and retrieve session
|
||||
*/
|
||||
runner.test('Store new session', () => {
|
||||
cleanupTestSessions();
|
||||
|
||||
const mgr = new SessionManager();
|
||||
mgr.storeSession('glm', {
|
||||
sessionId: 'test123',
|
||||
totalCost: 0.0025,
|
||||
cwd: '/home/test'
|
||||
});
|
||||
|
||||
const session = mgr.getLastSession('glm');
|
||||
assert(session, 'Session should exist');
|
||||
assertEqual(session.sessionId, 'test123', 'Session ID should match');
|
||||
assertEqual(session.totalCost, 0.0025, 'Cost should match');
|
||||
assertEqual(session.turns, 1, 'Should have 1 turn initially');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 2: Update session
|
||||
*/
|
||||
runner.test('Update existing session', () => {
|
||||
const mgr = new SessionManager();
|
||||
|
||||
// Store initial
|
||||
mgr.storeSession('glm', {
|
||||
sessionId: 'test456',
|
||||
totalCost: 0.001,
|
||||
cwd: '/home/test'
|
||||
});
|
||||
|
||||
// Update
|
||||
mgr.updateSession('glm', 'test456', {
|
||||
totalCost: 0.002
|
||||
});
|
||||
|
||||
const session = mgr.getLastSession('glm');
|
||||
assertEqual(session.totalCost, 0.003, 'Cost should be aggregated (0.001 + 0.002)');
|
||||
assertEqual(session.turns, 2, 'Should have 2 turns');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 3: Multiple profiles
|
||||
*/
|
||||
runner.test('Manage multiple profiles', () => {
|
||||
const mgr = new SessionManager();
|
||||
|
||||
mgr.storeSession('glm', {
|
||||
sessionId: 'glm123',
|
||||
totalCost: 0.001,
|
||||
cwd: '/home/test'
|
||||
});
|
||||
|
||||
mgr.storeSession('kimi', {
|
||||
sessionId: 'kimi123',
|
||||
totalCost: 0.002,
|
||||
cwd: '/home/test'
|
||||
});
|
||||
|
||||
const glmSession = mgr.getLastSession('glm');
|
||||
const kimiSession = mgr.getLastSession('kimi');
|
||||
|
||||
assertEqual(glmSession.sessionId, 'glm123', 'GLM session should be separate');
|
||||
assertEqual(kimiSession.sessionId, 'kimi123', 'Kimi session should be separate');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 4: No session for profile
|
||||
*/
|
||||
runner.test('Return null for non-existent profile', () => {
|
||||
const mgr = new SessionManager();
|
||||
|
||||
const session = mgr.getLastSession('nonexistent');
|
||||
assertEqual(session, null, 'Should return null for unknown profile');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 5: Clear profile
|
||||
*/
|
||||
runner.test('Clear profile sessions', () => {
|
||||
const mgr = new SessionManager();
|
||||
|
||||
mgr.storeSession('glm', {
|
||||
sessionId: 'test789',
|
||||
totalCost: 0.001,
|
||||
cwd: '/home/test'
|
||||
});
|
||||
|
||||
mgr.clearProfile('glm');
|
||||
|
||||
const session = mgr.getLastSession('glm');
|
||||
assertEqual(session, null, 'Session should be cleared');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 6: Cleanup expired sessions
|
||||
*/
|
||||
runner.test('Cleanup expired sessions', () => {
|
||||
const mgr = new SessionManager();
|
||||
|
||||
// Store session with old timestamp (31 days ago)
|
||||
const sessions = {};
|
||||
const oldTime = Date.now() - (31 * 24 * 60 * 60 * 1000);
|
||||
sessions['glm:latest'] = {
|
||||
sessionId: 'old123',
|
||||
profile: 'glm',
|
||||
startTime: oldTime,
|
||||
lastTurnTime: oldTime,
|
||||
totalCost: 0.001,
|
||||
turns: 1,
|
||||
cwd: '/home/test'
|
||||
};
|
||||
|
||||
// Save manually
|
||||
const dir = path.dirname(mgr.sessionsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(mgr.sessionsPath, JSON.stringify(sessions));
|
||||
|
||||
// Cleanup
|
||||
mgr.cleanupExpired();
|
||||
|
||||
const session = mgr.getLastSession('glm');
|
||||
assertEqual(session, null, 'Expired session should be removed');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 7: Don't cleanup recent sessions
|
||||
*/
|
||||
runner.test('Keep recent sessions during cleanup', () => {
|
||||
const mgr = new SessionManager();
|
||||
|
||||
mgr.storeSession('glm', {
|
||||
sessionId: 'recent123',
|
||||
totalCost: 0.001,
|
||||
cwd: '/home/test'
|
||||
});
|
||||
|
||||
mgr.cleanupExpired();
|
||||
|
||||
const session = mgr.getLastSession('glm');
|
||||
assert(session, 'Recent session should not be removed');
|
||||
assertEqual(session.sessionId, 'recent123');
|
||||
});
|
||||
|
||||
// Cleanup after all tests
|
||||
runner.run().finally(() => {
|
||||
cleanupTestSessions();
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { SettingsParser } = require('../../../bin/delegation/settings-parser');
|
||||
|
||||
/**
|
||||
* Test runner
|
||||
*/
|
||||
class TestRunner {
|
||||
constructor() {
|
||||
this.tests = [];
|
||||
this.passed = 0;
|
||||
this.failed = 0;
|
||||
}
|
||||
|
||||
test(name, fn) {
|
||||
this.tests.push({ name, fn });
|
||||
}
|
||||
|
||||
async run() {
|
||||
console.log('\n=== Settings Parser Tests ===\n');
|
||||
|
||||
for (const { name, fn } of this.tests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`[OK] ${name}`);
|
||||
this.passed++;
|
||||
} catch (error) {
|
||||
console.error(`[X] ${name}`);
|
||||
console.error(` Error: ${error.message}`);
|
||||
this.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${this.passed} passed, ${this.failed} failed`);
|
||||
process.exit(this.failed > 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message || 'Assertion failed');
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, message) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(message || `Expected ${expected}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test suite
|
||||
const runner = new TestRunner();
|
||||
|
||||
// Test fixture directory
|
||||
const testDir = path.join(os.tmpdir(), 'ccs-test-settings');
|
||||
const claudeDir = path.join(testDir, '.claude');
|
||||
|
||||
// Cleanup helpers
|
||||
function setupTestDir() {
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
function cleanupTestDir() {
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test 1: No settings files
|
||||
*/
|
||||
runner.test('Return empty arrays when no settings files', () => {
|
||||
setupTestDir();
|
||||
|
||||
const restrictions = SettingsParser.parseToolRestrictions(testDir);
|
||||
|
||||
assertEqual(restrictions.allowedTools.length, 0, 'Should have 0 allowed tools');
|
||||
assertEqual(restrictions.disallowedTools.length, 0, 'Should have 0 disallowed tools');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 2: Parse shared settings.json
|
||||
*/
|
||||
runner.test('Parse shared settings.json', () => {
|
||||
setupTestDir();
|
||||
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({
|
||||
permissions: {
|
||||
allow: ['Bash(git:*)', 'Read'],
|
||||
deny: ['Bash(rm:*)']
|
||||
}
|
||||
}));
|
||||
|
||||
const restrictions = SettingsParser.parseToolRestrictions(testDir);
|
||||
|
||||
assertEqual(restrictions.allowedTools.length, 2, 'Should have 2 allowed tools');
|
||||
assertEqual(restrictions.disallowedTools.length, 1, 'Should have 1 disallowed tool');
|
||||
assert(restrictions.allowedTools.includes('Bash(git:*)'), 'Should include git bash');
|
||||
assert(restrictions.disallowedTools.includes('Bash(rm:*)'), 'Should include rm deny');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 3: Parse local settings overriding shared
|
||||
*/
|
||||
runner.test('Local settings override shared', () => {
|
||||
setupTestDir();
|
||||
|
||||
// Shared settings
|
||||
fs.writeFileSync(path.join(claudeDir, 'settings.json'), JSON.stringify({
|
||||
permissions: {
|
||||
allow: ['Read'],
|
||||
deny: []
|
||||
}
|
||||
}));
|
||||
|
||||
// Local settings (adds more permissions)
|
||||
fs.writeFileSync(path.join(claudeDir, 'settings.local.json'), JSON.stringify({
|
||||
permissions: {
|
||||
allow: ['Bash(git:*)'],
|
||||
deny: ['Bash(rm:*)']
|
||||
}
|
||||
}));
|
||||
|
||||
const restrictions = SettingsParser.parseToolRestrictions(testDir);
|
||||
|
||||
assertEqual(restrictions.allowedTools.length, 2, 'Should merge allowed tools');
|
||||
assert(restrictions.allowedTools.includes('Read'), 'Should have shared Read');
|
||||
assert(restrictions.allowedTools.includes('Bash(git:*)'), 'Should have local git');
|
||||
assertEqual(restrictions.disallowedTools.length, 1, 'Should have local deny');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 4: Handle malformed JSON
|
||||
*/
|
||||
runner.test('Handle malformed JSON gracefully', () => {
|
||||
setupTestDir();
|
||||
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, '{ invalid json }');
|
||||
|
||||
// Should not throw
|
||||
const restrictions = SettingsParser.parseToolRestrictions(testDir);
|
||||
|
||||
assertEqual(restrictions.allowedTools.length, 0, 'Should return empty arrays on parse error');
|
||||
assertEqual(restrictions.disallowedTools.length, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 5: Handle missing permissions key
|
||||
*/
|
||||
runner.test('Handle settings without permissions key', () => {
|
||||
setupTestDir();
|
||||
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({
|
||||
someOtherKey: 'value'
|
||||
}));
|
||||
|
||||
const restrictions = SettingsParser.parseToolRestrictions(testDir);
|
||||
|
||||
assertEqual(restrictions.allowedTools.length, 0, 'Should handle missing permissions');
|
||||
assertEqual(restrictions.disallowedTools.length, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 6: Handle empty permissions arrays
|
||||
*/
|
||||
runner.test('Handle empty permissions arrays', () => {
|
||||
setupTestDir();
|
||||
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({
|
||||
permissions: {
|
||||
allow: [],
|
||||
deny: []
|
||||
}
|
||||
}));
|
||||
|
||||
const restrictions = SettingsParser.parseToolRestrictions(testDir);
|
||||
|
||||
assertEqual(restrictions.allowedTools.length, 0);
|
||||
assertEqual(restrictions.disallowedTools.length, 0);
|
||||
});
|
||||
|
||||
// Run tests and cleanup
|
||||
runner.run().finally(() => {
|
||||
cleanupTestDir();
|
||||
});
|
||||
Reference in new issue
Block a user