mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
fix(cursor): support ACP parameterized model picker (#969)
* test: reproduce issue #968 * fix: support Cursor parameterized model picker (closes #968)
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const transportState = vi.hoisted(() => ({
|
||||||
|
calls: [] as Array<{ method: string; params?: unknown }>
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./AcpStdioTransport', () => ({
|
||||||
|
AcpStdioTransport: class {
|
||||||
|
constructor(_options: unknown) {}
|
||||||
|
onNotification = vi.fn();
|
||||||
|
onStderrError = vi.fn();
|
||||||
|
registerRequestHandler = vi.fn();
|
||||||
|
sendRequest = vi.fn(async (method: string, params?: unknown) => {
|
||||||
|
transportState.calls.push({ method, params });
|
||||||
|
if (method === 'initialize') {
|
||||||
|
return { protocolVersion: 1, authMethods: [] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
close = vi.fn(async () => {});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AcpSdkBackend } from './AcpSdkBackend';
|
||||||
|
|
||||||
|
describe('AcpSdkBackend.initialize', () => {
|
||||||
|
it('advertises Cursor-compatible parameterized model picker support', async () => {
|
||||||
|
const backend = new AcpSdkBackend({ command: 'agent', args: ['acp'] });
|
||||||
|
|
||||||
|
await backend.initialize();
|
||||||
|
|
||||||
|
expect(transportState.calls).toContainEqual({
|
||||||
|
method: 'initialize',
|
||||||
|
params: expect.objectContaining({
|
||||||
|
clientCapabilities: expect.objectContaining({
|
||||||
|
_meta: expect.objectContaining({
|
||||||
|
parameterizedModelPicker: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,7 +131,13 @@ export class AcpSdkBackend implements AgentBackend {
|
|||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
clientCapabilities: {
|
clientCapabilities: {
|
||||||
fs: { readTextFile: false, writeTextFile: false },
|
fs: { readTextFile: false, writeTextFile: false },
|
||||||
terminal: false
|
terminal: false,
|
||||||
|
_meta: {
|
||||||
|
// Cursor ACP exposes Composer's non-fast/fast choice as separate
|
||||||
|
// `model` + `fast` config options only when the client advertises
|
||||||
|
// this capability. Agents that do not know this metadata ignore it.
|
||||||
|
parameterizedModelPicker: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
name: 'hapi',
|
name: 'hapi',
|
||||||
@@ -839,7 +845,7 @@ export class AcpSdkBackend implements AgentBackend {
|
|||||||
|
|
||||||
for (const entry of response.configOptions) {
|
for (const entry of response.configOptions) {
|
||||||
if (!isObject(entry)) continue;
|
if (!isObject(entry)) continue;
|
||||||
if (asString(entry.category) !== 'model') continue;
|
if (asString(entry.category) !== 'model' && asString(entry.id) !== 'model') continue;
|
||||||
return {
|
return {
|
||||||
currentValue: asString(entry.currentValue),
|
currentValue: asString(entry.currentValue),
|
||||||
options: Array.isArray(entry.options) ? entry.options : []
|
options: Array.isArray(entry.options) ? entry.options : []
|
||||||
|
|||||||
@@ -47,4 +47,50 @@ describe('buildCursorModelsSnapshotFromAcp', () => {
|
|||||||
|
|
||||||
expect(snapshot?.availableModels).toHaveLength(2);
|
expect(snapshot?.availableModels).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('synthesizes Composer fast variants from parameterized model + fast config options', () => {
|
||||||
|
const backend = {
|
||||||
|
getSessionModelsMetadata: () => ({
|
||||||
|
availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }],
|
||||||
|
currentModelId: 'composer-2.5'
|
||||||
|
}),
|
||||||
|
getConfigOptionByCategory: (_sessionId: string, category: string) => {
|
||||||
|
if (category === 'model') {
|
||||||
|
return {
|
||||||
|
id: 'model',
|
||||||
|
currentValue: 'composer-2.5',
|
||||||
|
options: [{ value: 'composer-2.5', name: 'Composer 2.5' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (category === 'fast') {
|
||||||
|
return {
|
||||||
|
id: 'fast',
|
||||||
|
currentValue: 'false',
|
||||||
|
options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
getSessionConfigOptions: () => [
|
||||||
|
{
|
||||||
|
id: 'model',
|
||||||
|
currentValue: 'composer-2.5',
|
||||||
|
options: [{ value: 'composer-2.5', name: 'Composer 2.5' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fast',
|
||||||
|
currentValue: 'false',
|
||||||
|
options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = buildCursorModelsSnapshotFromAcp(backend, 's1');
|
||||||
|
|
||||||
|
expect(snapshot?.availableModels.map((entry) => entry.modelId).sort()).toEqual([
|
||||||
|
'composer-2.5[fast=false]',
|
||||||
|
'composer-2.5[fast=true]'
|
||||||
|
]);
|
||||||
|
expect(snapshot?.currentModelId).toBe('composer-2.5[fast=false]');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ export type CursorModelsSnapshot = {
|
|||||||
currentModelId: string | null;
|
currentModelId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CursorAcpModelSnapshotBackend = Pick<AcpSdkBackend, 'getSessionModelsMetadata' | 'getConfigOptionByCategory'>
|
||||||
|
& Partial<Pick<AcpSdkBackend, 'getSessionConfigOptions'>>;
|
||||||
|
|
||||||
|
function findConfigOption(
|
||||||
|
backend: CursorAcpModelSnapshotBackend,
|
||||||
|
sessionId: string,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
return backend.getConfigOptionByCategory?.(sessionId, key)
|
||||||
|
?? backend.getSessionConfigOptions?.(sessionId)?.find((option) => option.id === key || option.category === key);
|
||||||
|
}
|
||||||
|
|
||||||
function mergeModelEntries(
|
function mergeModelEntries(
|
||||||
target: Map<string, CursorModelSummary>,
|
target: Map<string, CursorModelSummary>,
|
||||||
entries: Iterable<{ modelId: string; name?: string | null }>
|
entries: Iterable<{ modelId: string; name?: string | null }>
|
||||||
@@ -31,19 +43,43 @@ function mergeModelEntries(
|
|||||||
* `availableModels` alone is often one variant per base family.
|
* `availableModels` alone is often one variant per base family.
|
||||||
*/
|
*/
|
||||||
export function buildCursorModelsSnapshotFromAcp(
|
export function buildCursorModelsSnapshotFromAcp(
|
||||||
backend: Pick<AcpSdkBackend, 'getSessionModelsMetadata' | 'getConfigOptionByCategory'>,
|
backend: CursorAcpModelSnapshotBackend,
|
||||||
sessionId: string
|
sessionId: string
|
||||||
): CursorModelsSnapshot | null {
|
): CursorModelsSnapshot | null {
|
||||||
const metadata = backend.getSessionModelsMetadata(sessionId);
|
const metadata = backend.getSessionModelsMetadata(sessionId);
|
||||||
const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model');
|
const modelOption = findConfigOption(backend, sessionId, 'model');
|
||||||
|
const fastOption = findConfigOption(backend, sessionId, 'fast');
|
||||||
|
|
||||||
if (!metadata && !modelOption) {
|
if (!metadata && !modelOption) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = new Map<string, CursorModelSummary>();
|
const merged = new Map<string, CursorModelSummary>();
|
||||||
|
const parameterizedFastModels: CursorModelSummary[] = [];
|
||||||
|
|
||||||
if (modelOption?.options?.length) {
|
if (modelOption?.options?.length && fastOption?.options?.length) {
|
||||||
|
const fastValues = fastOption.options
|
||||||
|
.map((option) => option.value.trim())
|
||||||
|
.filter((value) => value === 'false' || value === 'true');
|
||||||
|
if (fastValues.length > 0) {
|
||||||
|
for (const option of modelOption.options) {
|
||||||
|
const modelId = option.value.trim();
|
||||||
|
if (!modelId || modelId.includes('[')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const fast of fastValues) {
|
||||||
|
parameterizedFastModels.push({
|
||||||
|
modelId: `${modelId}[fast=${fast}]`,
|
||||||
|
name: option.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameterizedFastModels.length > 0) {
|
||||||
|
mergeModelEntries(merged, parameterizedFastModels);
|
||||||
|
} else if (modelOption?.options?.length) {
|
||||||
mergeModelEntries(merged, modelOption.options.map((option) => ({
|
mergeModelEntries(merged, modelOption.options.map((option) => ({
|
||||||
modelId: option.value,
|
modelId: option.value,
|
||||||
name: option.name
|
name: option.name
|
||||||
@@ -51,16 +87,23 @@ export function buildCursorModelsSnapshotFromAcp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (metadata?.availableModels?.length) {
|
if (metadata?.availableModels?.length) {
|
||||||
mergeModelEntries(merged, metadata.availableModels);
|
mergeModelEntries(
|
||||||
|
merged,
|
||||||
|
parameterizedFastModels.length > 0
|
||||||
|
? metadata.availableModels.filter((entry) => entry.modelId.includes('['))
|
||||||
|
: metadata.availableModels
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (merged.size === 0) {
|
if (merged.size === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentModelId = metadata?.currentModelId
|
const currentModelId = parameterizedFastModels.length > 0 && modelOption?.currentValue && fastOption?.currentValue
|
||||||
?? modelOption?.currentValue
|
? `${modelOption.currentValue}[fast=${fastOption.currentValue}]`
|
||||||
?? null;
|
: metadata?.currentModelId
|
||||||
|
?? modelOption?.currentValue
|
||||||
|
?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
availableModels: [...merged.values()],
|
availableModels: [...merged.values()],
|
||||||
|
|||||||
@@ -194,6 +194,109 @@ describe('applyCursorAcpModel', () => {
|
|||||||
expect(setConfigOption).toHaveBeenCalledWith('s1', 'model-opt', 'composer-2.5[fast=false]');
|
expect(setConfigOption).toHaveBeenCalledWith('s1', 'model-opt', 'composer-2.5[fast=false]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies parameterized Cursor Composer fast=false for base CLI sku requests', async () => {
|
||||||
|
const setConfigOption = vi.fn(async () => {});
|
||||||
|
const backend = mockModelBackend({
|
||||||
|
setConfigOption,
|
||||||
|
getSessionModelsMetadata: vi.fn(() => ({
|
||||||
|
availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }],
|
||||||
|
currentModelId: 'composer-2.5'
|
||||||
|
})),
|
||||||
|
getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => {
|
||||||
|
if (category === 'model') {
|
||||||
|
return {
|
||||||
|
id: 'model',
|
||||||
|
category: 'model',
|
||||||
|
currentValue: 'composer-2.5',
|
||||||
|
options: [{ value: 'composer-2.5', name: 'Composer 2.5' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (category === 'fast') {
|
||||||
|
return {
|
||||||
|
id: 'fast',
|
||||||
|
category: 'fast',
|
||||||
|
currentValue: 'true',
|
||||||
|
options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({
|
||||||
|
applied: true,
|
||||||
|
resolvedWireId: 'composer-2.5[fast=false]',
|
||||||
|
requestedWireId: 'composer-2.5'
|
||||||
|
});
|
||||||
|
expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'composer-2.5');
|
||||||
|
expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'false');
|
||||||
|
expect(backend.pinSessionModelWireId).toHaveBeenCalledWith('s1', 'composer-2.5[fast=false]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies parameterized Cursor Composer fast=true for -fast CLI sku requests', async () => {
|
||||||
|
const setConfigOption = vi.fn(async () => {});
|
||||||
|
const backend = mockModelBackend({
|
||||||
|
setConfigOption,
|
||||||
|
getSessionModelsMetadata: vi.fn(() => ({
|
||||||
|
availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }],
|
||||||
|
currentModelId: 'composer-2.5'
|
||||||
|
})),
|
||||||
|
getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => {
|
||||||
|
if (category === 'model') {
|
||||||
|
return { id: 'model', category: 'model', options: [{ value: 'composer-2.5' }] };
|
||||||
|
}
|
||||||
|
if (category === 'fast') {
|
||||||
|
return { id: 'fast', category: 'fast', options: [{ value: 'false' }, { value: 'true' }] };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5-fast')).resolves.toMatchObject({
|
||||||
|
applied: true,
|
||||||
|
resolvedWireId: 'composer-2.5[fast=true]',
|
||||||
|
requestedWireId: 'composer-2.5-fast'
|
||||||
|
});
|
||||||
|
expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'composer-2.5');
|
||||||
|
expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fall back to base-only model apply when parameterized fast update fails', async () => {
|
||||||
|
const setConfigOption = vi.fn()
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error('fast update failed'));
|
||||||
|
const backend = mockModelBackend({
|
||||||
|
setConfigOption,
|
||||||
|
getSessionModelsMetadata: vi.fn(() => ({
|
||||||
|
availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }],
|
||||||
|
currentModelId: 'composer-2.5'
|
||||||
|
})),
|
||||||
|
getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => {
|
||||||
|
if (category === 'model') {
|
||||||
|
return {
|
||||||
|
id: 'model',
|
||||||
|
category: 'model',
|
||||||
|
options: [{ value: 'composer-2.5', name: 'Composer 2.5' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (category === 'fast') {
|
||||||
|
return {
|
||||||
|
id: 'fast',
|
||||||
|
category: 'fast',
|
||||||
|
options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({
|
||||||
|
applied: false
|
||||||
|
});
|
||||||
|
expect(setConfigOption).toHaveBeenCalledTimes(2);
|
||||||
|
expect(backend.pinSessionModelWireId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('retries set_config_option once before failing apply', async () => {
|
it('retries set_config_option once before failing apply', async () => {
|
||||||
const setConfigOption = vi.fn()
|
const setConfigOption = vi.fn()
|
||||||
.mockRejectedValueOnce(new Error('transient'))
|
.mockRejectedValueOnce(new Error('transient'))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CursorPermissionMode } from '@hapi/protocol/types';
|
import type { CursorPermissionMode } from '@hapi/protocol/types';
|
||||||
import { matchCliSkuToAcpWireId } from '@hapi/protocol';
|
import { cursorCliSkuBaseId, cursorModelBaseId, matchCliSkuToAcpWireId } from '@hapi/protocol';
|
||||||
import type { AcpSdkBackend } from '@/agent/backends/acp';
|
import type { AcpSdkBackend } from '@/agent/backends/acp';
|
||||||
import { logger } from '@/ui/logger';
|
import { logger } from '@/ui/logger';
|
||||||
|
|
||||||
@@ -71,6 +71,9 @@ export type ApplyCursorAcpModelResult = {
|
|||||||
requestedWireId?: string;
|
requestedWireId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ConfigOption = NonNullable<ReturnType<AcpSdkBackend['getConfigOptionByCategory']>>;
|
||||||
|
type ParameterizedCursorModelResult = ApplyCursorAcpModelResult | 'unsupported' | 'failed';
|
||||||
|
|
||||||
/** Wire id stored on session + keepalive (preserve explicit variant picks). */
|
/** Wire id stored on session + keepalive (preserve explicit variant picks). */
|
||||||
export function wireIdForCursorSessionState(requested: string, resolved: string): string {
|
export function wireIdForCursorSessionState(requested: string, resolved: string): string {
|
||||||
const trimmed = requested.trim();
|
const trimmed = requested.trim();
|
||||||
@@ -101,6 +104,61 @@ export function resolveCursorAcpWireId(
|
|||||||
return matchCliSkuToAcpWireId(trimmed, available);
|
return matchCliSkuToAcpWireId(trimmed, available);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findConfigOption(backend: AcpSdkBackend, sessionId: string, key: string): ConfigOption | undefined {
|
||||||
|
return backend.getConfigOptionByCategory?.(sessionId, key)
|
||||||
|
?? backend.getSessionConfigOptions?.(sessionId)?.find((option) => option.id === key || option.category === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionHasValue(option: ConfigOption | undefined, value: string): boolean {
|
||||||
|
return Boolean(option?.options?.some((entry) => entry.value === value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fastHintForCursorSkuOrWire(modelId: string): 'false' | 'true' {
|
||||||
|
const lower = modelId.trim().toLowerCase();
|
||||||
|
const fastMatch = lower.match(/[\[,](?:\s*)fast=(true|false)(?:\s*)[\],]/)
|
||||||
|
?? lower.match(/\[\s*fast=(true|false)\s*\]/);
|
||||||
|
if (fastMatch?.[1] === 'true' || fastMatch?.[1] === 'false') {
|
||||||
|
return fastMatch[1];
|
||||||
|
}
|
||||||
|
return lower.includes('-fast') ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cursorRequestBaseId(modelId: string): string {
|
||||||
|
return modelId.includes('[')
|
||||||
|
? cursorModelBaseId(modelId)
|
||||||
|
: cursorCliSkuBaseId(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyParameterizedCursorModel(
|
||||||
|
backend: AcpSdkBackend,
|
||||||
|
sessionId: string,
|
||||||
|
requested: string
|
||||||
|
): Promise<ParameterizedCursorModelResult> {
|
||||||
|
const modelOption = findConfigOption(backend, sessionId, 'model');
|
||||||
|
const fastOption = findConfigOption(backend, sessionId, 'fast');
|
||||||
|
if (!modelOption || !fastOption || !backend.setConfigOption) {
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseModel = cursorRequestBaseId(requested);
|
||||||
|
const fast = fastHintForCursorSkuOrWire(requested);
|
||||||
|
if (!optionHasValue(modelOption, baseModel) || !optionHasValue(fastOption, fast)) {
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await backend.setConfigOption(sessionId, modelOption.id, baseModel);
|
||||||
|
await backend.setConfigOption(sessionId, fastOption.id, fast);
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('[cursor-acp] parameterized model config failed', error);
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = `${baseModel}[fast=${fast}]`;
|
||||||
|
backend.pinSessionModelWireId(sessionId, resolved);
|
||||||
|
return { applied: true, resolvedWireId: resolved, requestedWireId: requested };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a model from the live ACP configOptions list (Zed-style).
|
* Apply a model from the live ACP configOptions list (Zed-style).
|
||||||
* Only wire ids present in `availableModels` are accepted.
|
* Only wire ids present in `availableModels` are accepted.
|
||||||
@@ -118,6 +176,15 @@ export async function applyCursorAcpModel(
|
|||||||
const metadata = backend.getSessionModelsMetadata(sessionId);
|
const metadata = backend.getSessionModelsMetadata(sessionId);
|
||||||
const available = metadata?.availableModels ?? [];
|
const available = metadata?.availableModels ?? [];
|
||||||
const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model');
|
const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model');
|
||||||
|
|
||||||
|
const parameterized = await applyParameterizedCursorModel(backend, sessionId, trimmed);
|
||||||
|
if (parameterized === 'failed') {
|
||||||
|
return { applied: false };
|
||||||
|
}
|
||||||
|
if (parameterized !== 'unsupported') {
|
||||||
|
return parameterized;
|
||||||
|
}
|
||||||
|
|
||||||
const optionWireIds = modelOption?.options?.map((option) => ({ modelId: option.value })) ?? [];
|
const optionWireIds = modelOption?.options?.map((option) => ({ modelId: option.value })) ?? [];
|
||||||
const catalog = [...available, ...optionWireIds];
|
const catalog = [...available, ...optionWireIds];
|
||||||
const resolved = resolveCursorAcpWireId(trimmed, catalog);
|
const resolved = resolveCursorAcpWireId(trimmed, catalog);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ModelEffortSettingsSection } from './HappyComposer';
|
||||||
|
|
||||||
|
vi.mock('@/lib/use-translation', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key === 'misc.variant' ? 'Variant' : key
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ModelEffortSettingsSection', () => {
|
||||||
|
it('renders Cursor variant choices and marks the selected variant', () => {
|
||||||
|
render(
|
||||||
|
<ModelEffortSettingsSection
|
||||||
|
agentFlavor="cursor"
|
||||||
|
options={[
|
||||||
|
{ value: 'composer-2.5', label: 'Composer 2.5' },
|
||||||
|
{ value: 'composer-2.5-fast', label: 'Composer 2.5 Fast' }
|
||||||
|
]}
|
||||||
|
selectedValue="composer-2.5"
|
||||||
|
controlsDisabled={false}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Variant')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^Composer 2.5$/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Composer 2.5 Fast/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^Composer 2.5$/ }).innerHTML).toContain('bg-[var(--app-link)]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,57 @@ export type ComposerSendError = {
|
|||||||
|
|
||||||
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
|
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
|
||||||
|
|
||||||
|
export function ModelEffortSettingsSection(props: {
|
||||||
|
agentFlavor?: string | null
|
||||||
|
options: Array<{ value: string; label: string }>
|
||||||
|
selectedValue: string | null | undefined
|
||||||
|
controlsDisabled: boolean
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { agentFlavor, options, selectedValue, controlsDisabled, onChange } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
|
||||||
|
{agentFlavor === 'cursor' ? t('misc.variant') : t('misc.effort')}
|
||||||
|
</div>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValue === option.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
controlsDisabled
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
|
||||||
|
isSelected
|
||||||
|
? 'border-[var(--app-link)]'
|
||||||
|
: 'border-[var(--app-hint)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={isSelected ? 'text-[var(--app-link)]' : ''}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function HappyComposer(props: {
|
export function HappyComposer(props: {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@@ -984,6 +1035,24 @@ export function HappyComposer(props: {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showModelSettings && showModelEffortSettings ? (
|
||||||
|
<div className="mx-3 h-px bg-[var(--app-divider)]" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showModelEffortSettings ? (
|
||||||
|
<ModelEffortSettingsSection
|
||||||
|
agentFlavor={agentFlavor}
|
||||||
|
options={modelEffortOptions!}
|
||||||
|
selectedValue={selectedModelVariant ?? model}
|
||||||
|
controlsDisabled={controlsDisabled}
|
||||||
|
onChange={handleModelEffortChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(showModelSettings || showModelEffortSettings) && showModelReasoningEffortSettings ? (
|
||||||
|
<div className="mx-3 h-px bg-[var(--app-divider)]" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{(showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings) && showEffortSettings ? (
|
{(showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings) && showEffortSettings ? (
|
||||||
<div className="mx-3 h-px bg-[var(--app-divider)]" />
|
<div className="mx-3 h-px bg-[var(--app-divider)]" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user