diff --git a/src/commands/cliproxy/routing-subcommand.ts b/src/commands/cliproxy/routing-subcommand.ts index 326a9249..c2272597 100644 --- a/src/commands/cliproxy/routing-subcommand.ts +++ b/src/commands/cliproxy/routing-subcommand.ts @@ -173,12 +173,18 @@ export async function handleRoutingAffinityStatus(): Promise { export async function handleRoutingAffinitySet(args: string[]): Promise { const requested = normalizeCliproxySessionAffinityEnabled(args[0]); const extractedTtl = extractOption(args.slice(1), ['--ttl']); + const remainingArgs = extractedTtl.remainingArgs.filter((token) => token.trim().length > 0); const ttl: string | undefined = extractedTtl.found && !extractedTtl.missingValue ? (normalizeCliproxySessionAffinityTtl(extractedTtl.value) ?? undefined) : undefined; - if (requested === null || extractedTtl.missingValue || (extractedTtl.found && !ttl)) { + if ( + requested === null || + extractedTtl.missingValue || + (extractedTtl.found && !ttl) || + remainingArgs.length > 0 + ) { await initUI(); console.log(''); console.log( diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index a18ff712..660503af 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -168,11 +168,25 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg '-h', ]); if (subcommand === 'routing') { + const routingSubcommand = tokensBeforeCurrent[2]; + const routingAffinityMode = tokensBeforeCurrent[3]; if (lastToken === 'set') { return completeSubcommands(['round-robin', 'fill-first']); } - if (lastToken === 'affinity') { - return completeSubcommands(['on', 'off', '--ttl']); + if (routingSubcommand === 'affinity') { + if (!routingAffinityMode || lastToken === 'affinity') { + return completeSubcommands(['on', 'off']); + } + if ( + (routingAffinityMode === 'on' || routingAffinityMode === 'off') && + !tokensBeforeCurrent.includes('--ttl') + ) { + return completeSubcommands([], ['--ttl']); + } + if (lastToken === '--ttl') { + return []; + } + return completeSubcommands([]); } return completeSubcommands(['set', 'explain', 'affinity']); } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 12e348c8..9c4dc5b7 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -59,6 +59,8 @@ const CONFIG_YAML = 'config.yaml'; const CONFIG_JSON = 'config.json'; const CONFIG_LOCK = 'config.yaml.lock'; const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds +const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`; +const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`); function normalizeBrowserDevtoolsPort(value: number | undefined): number { if (!Number.isFinite(value)) { @@ -112,6 +114,19 @@ function canonicalizeBrowserConfig( }; } +function normalizeSessionAffinityTtl(value: unknown, fallback: string): string { + if (typeof value !== 'string') { + return fallback; + } + + const trimmed = value.trim(); + if (!trimmed || !GO_DURATION_PATTERN.test(trimmed)) { + return fallback; + } + + return trimmed; +} + /** * Get path to unified config.yaml */ @@ -444,11 +459,10 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { typeof partial.cliproxy?.routing?.session_affinity === 'boolean' ? partial.cliproxy.routing.session_affinity : defaults.cliproxy.routing?.session_affinity, - session_affinity_ttl: - typeof partial.cliproxy?.routing?.session_affinity_ttl === 'string' && - partial.cliproxy.routing.session_affinity_ttl.trim() - ? partial.cliproxy.routing.session_affinity_ttl.trim() - : defaults.cliproxy.routing?.session_affinity_ttl, + session_affinity_ttl: normalizeSessionAffinityTtl( + partial.cliproxy?.routing?.session_affinity_ttl, + defaults.cliproxy.routing?.session_affinity_ttl ?? '1h' + ), }, }, proxy: { diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index b683043e..4667dc76 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -128,6 +128,22 @@ describe('completion backend', () => { expect(values).toContain('my-codex'); }); + test('suggests the correct routing affinity completion shape', () => { + expect(suggestionValues(['cliproxy', 'routing'])).toEqual( + expect.arrayContaining(['set', 'explain', 'affinity']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity'])).toEqual( + expect.arrayContaining(['on', 'off']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity', 'on'])).toEqual( + expect.arrayContaining(['--ttl']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity', 'off'])).toEqual( + expect.arrayContaining(['--ttl']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity', '--ttl'])).not.toContain('--ttl'); + }); + test('suggests env format values after the format flag', () => { const values = suggestionValues(['env', '--format']); expect(values).toEqual( diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 1fc660c5..8113dae5 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -285,6 +285,41 @@ describe('continuity-inheritance-config', () => { }); }); +describe('cliproxy session-affinity ttl normalization', () => { + it('normalizes invalid stored ttl values back to the safe default', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-affinity-ttl-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'cliproxy:', + ' routing:', + ' strategy: round-robin', + ' session_affinity: true', + ' session_affinity_ttl: forever', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.cliproxy.routing.session_affinity_ttl).toBe('1h'); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); +}); + describe('official-channels-config', () => { it('keeps explicit channels.selected empty even when legacy discord_channels.enabled is true', () => { const originalCcsHome = process.env.CCS_HOME; diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index c7a58dd0..f8e12a5c 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -90,6 +90,7 @@ export function useUpdateCliproxySessionAffinity() { mutationFn: (data: { enabled: boolean; ttl?: string }) => api.cliproxy.updateSessionAffinity(data), onSuccess: (result: CliproxySessionAffinityApplyResult) => { + queryClient.setQueryData(['cliproxy-session-affinity'], result); queryClient.invalidateQueries({ queryKey: ['cliproxy-session-affinity'] }); const label = result.enabled ? 'enabled' : 'disabled'; toast.success(result.message || `Session affinity ${label}.`);