fix(droid): avoid interactive model arg prompt pollution

- persist selector in ~/.factory/settings.json as settings.model

- stop injecting '-m custom:...' for interactive droid launches

- improve provider inference for localhost /v1 and qwen/deepseek/kimi

- add regression tests for adapter, provider, and config manager
This commit is contained in:
Tam Nhu Tran
2026-02-25 22:14:00 +07:00
parent 60b1c043c7
commit f1567c0090
8 changed files with 65 additions and 13 deletions
+4
View File
@@ -162,6 +162,10 @@ For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hin
`anthropic`, `openai`, or `generic-chat-completion-api`.
If the hint is missing, CCS resolves provider from base URL/model at runtime.
CCS also persists Droid's active model selector in `~/.factory/settings.json`
(`model: custom:<alias>`). This avoids passing `-m` argv in interactive mode,
which Droid treats as queued prompt text.
### Per-Profile Target Defaults
You can pin a default target (`claude` or `droid`) per profile:
+7 -5
View File
@@ -2,7 +2,7 @@
* Droid Adapter
*
* TargetAdapter implementation for Factory Droid CLI.
* Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:<alias>`.
* Writes credentials + active model to ~/.factory/settings.json and spawns `droid`.
*/
import { spawn, ChildProcess } from 'child_process';
@@ -19,7 +19,6 @@ import { runCleanup } from '../errors';
export class DroidAdapter implements TargetAdapter {
readonly type: TargetType = 'droid';
readonly displayName = 'Factory Droid';
private readonly modelSelectorsByProfile = new Map<string, string>();
private validateCredentials(creds: TargetCredentials): void {
if (!creds.baseUrl?.trim()) {
@@ -57,7 +56,9 @@ export class DroidAdapter implements TargetAdapter {
apiKey: creds.apiKey,
provider,
});
this.modelSelectorsByProfile.set(creds.profile, modelRef.selector);
if (!modelRef.selector) {
throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`);
}
}
buildArgs(profile: string, userArgs: string[]): string[] {
@@ -66,8 +67,9 @@ export class DroidAdapter implements TargetAdapter {
`Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed`
);
}
const selector = this.modelSelectorsByProfile.get(profile) || `custom:ccs-${profile}`;
return ['-m', selector, ...userArgs];
// Droid interactive mode treats unknown argv as queued prompt text.
// Model selection must be persisted in settings.json (`model`) instead of `-m`.
return [...userArgs];
}
/**
+6 -3
View File
@@ -52,6 +52,7 @@ export interface DroidManagedModelRef {
}
interface DroidSettings {
model?: string;
customModels?: DroidCustomModelEntry[];
[key: string]: unknown;
}
@@ -346,19 +347,21 @@ export async function upsertCcsModel(
settings.customModels.push(entry);
}
writeDroidSettings(settings);
const index = settings.customModels.findIndex(
(entry) => parseManagedProfile(entry.displayName) === profile
);
const safeIndex = index >= 0 ? index : 0;
const selectorAlias = buildSelectorAlias(entry.displayName, safeIndex);
const selector = `custom:${selectorAlias}`;
// Droid interactive mode uses settings.model for default model selection.
settings.model = selector;
writeDroidSettings(settings);
ref = {
profile,
displayName: entry.displayName,
index: safeIndex,
selectorAlias,
selector: `custom:${selectorAlias}`,
selector,
};
} finally {
if (release) await release();
+14
View File
@@ -65,6 +65,8 @@ export function inferDroidProviderFromBaseUrl(
const host = parsed.host.toLowerCase();
const pathname = parsed.pathname.toLowerCase();
const isLocalHost =
host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]');
if (
host.includes('api.openai.com') ||
@@ -92,6 +94,11 @@ export function inferDroidProviderFromBaseUrl(
return 'generic-chat-completion-api';
}
// Local OpenAI-compatible proxies are commonly exposed at /v1.
if (isLocalHost && (pathname === '/v1' || pathname.startsWith('/v1/'))) {
return 'generic-chat-completion-api';
}
return null;
}
@@ -116,6 +123,13 @@ export function inferDroidProviderFromModel(
) {
return 'openai';
}
if (
normalized.startsWith('qwen') ||
normalized.startsWith('deepseek') ||
normalized.startsWith('kimi')
) {
return 'generic-chat-completion-api';
}
return null;
}
+2 -2
View File
@@ -5,9 +5,9 @@ import { describe, it, expect } from 'bun:test';
import { DroidAdapter } from '../../../src/targets/droid-adapter';
describe('DroidAdapter.buildArgs', () => {
it('builds droid model args for valid profile names', () => {
it('passes user args without model injection for valid profile names', () => {
const adapter = new DroidAdapter();
expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['-m', 'custom:ccs-gemini_01', '--help']);
expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['--help']);
});
it('rejects unsafe profile names', () => {
@@ -45,6 +45,10 @@ describe('droid-config-manager', () => {
expect(ref.selectorAlias).toBe('CCS-gemini-0');
expect(ref.selector).toBe('custom:CCS-gemini-0');
expect(ref.index).toBe(0);
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.model).toBe('custom:CCS-gemini-0');
});
it('should create settings.json with customModels', async () => {
@@ -63,6 +67,7 @@ describe('droid-config-manager', () => {
expect(settings.customModels).toHaveLength(1);
expect(settings.customModels[0].displayName).toBe('CCS gemini');
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8317');
expect(settings.model).toBe('custom:CCS-gemini-0');
});
it('should update existing entry on second upsert', async () => {
+18
View File
@@ -46,6 +46,18 @@ describe('droid-provider', () => {
'generic-chat-completion-api'
);
});
it('detects localhost openai-compatible /v1 endpoints', () => {
expect(inferDroidProviderFromBaseUrl('http://127.0.0.1:1234/v1')).toBe(
'generic-chat-completion-api'
);
expect(inferDroidProviderFromBaseUrl('http://localhost:8317/v1/chat/completions')).toBe(
'generic-chat-completion-api'
);
expect(inferDroidProviderFromBaseUrl('http://[::1]:8317/v1')).toBe(
'generic-chat-completion-api'
);
});
});
describe('inferDroidProviderFromModel', () => {
@@ -56,6 +68,12 @@ describe('droid-provider', () => {
it('detects openai model naming', () => {
expect(inferDroidProviderFromModel('gpt-5-codex')).toBe('openai');
});
it('detects generic openai-compatible model families', () => {
expect(inferDroidProviderFromModel('qwen3-coder-plus')).toBe('generic-chat-completion-api');
expect(inferDroidProviderFromModel('deepseek-v3.1')).toBe('generic-chat-completion-api');
expect(inferDroidProviderFromModel('kimi-k2')).toBe('generic-chat-completion-api');
});
});
describe('resolveDroidProvider', () => {
+9 -3
View File
@@ -125,10 +125,16 @@ describe('DroidAdapter', () => {
expect(adapter.supportsProfileType('copilot')).toBe(false);
});
it('should build args with -m custom:ccs- prefix', () => {
it('should keep interactive args clean (no model argv injection)', () => {
const isolatedAdapter = new DroidAdapter();
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']);
expect(args).toEqual(['--verbose']);
});
it('should not queue model selector as prompt when no user args', () => {
const isolatedAdapter = new DroidAdapter();
const args = isolatedAdapter.buildArgs('codex', []);
expect(args).toEqual([]);
});
it('should build minimal env (no ANTHROPIC_ vars)', () => {
@@ -200,7 +206,7 @@ describe('DroidAdapter', () => {
});
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
expect(args).toEqual(['-m', 'custom:CCS-gemini-0', '--verbose']);
expect(args).toEqual(['--verbose']);
} finally {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;