[codex] Add Cursor SDK agent support (#1399)
* feat(ai): add Cursor SDK agent support * fix(ai): harden Cursor SDK support * fix(ai): address Cursor SDK review findings * fix(ai): refresh Cursor environment handling * fix(ai): refresh Cursor discovery scans * fix(ai): enable Cursor recheck without path * Use official Cursor agent icon * Clarify Cursor SDK setup requirements * Split Cursor SDK setup status * Simplify Cursor settings copy * Improve Cursor API key error * Add safe Cursor auth diagnostics * Disable Cursor local sandbox by default * Show Cursor MCP tool names in tool cards * Add spacing inside tool call groups
This commit is contained in:
@@ -41,6 +41,17 @@ test('codex managed config no longer matches legacy adapter backend values', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('cursor managed config matches by sdk backend and discovered id', () => {
|
||||
assert.equal(
|
||||
matchesManagedAgentConfig({ id: 'discovered_cursor', command: 'cursor', sdkBackend: 'cursor' }, 'cursor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesManagedAgentConfig({ id: 'x', command: 'other', sdkBackend: 'cursor' }, 'cursor'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('claude managed config matches by sdk backend value', () => {
|
||||
assert.equal(
|
||||
matchesManagedAgentConfig({ id: 'discovered_claude', command: 'claude', sdkBackend: 'claude' }, 'claude'),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
|
||||
|
||||
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot' | 'codebuddy';
|
||||
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot' | 'cursor' | 'codebuddy';
|
||||
|
||||
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; sdkBackend: string }> = {
|
||||
codex: { commandNames: ['codex'], sdkBackend: 'codex' },
|
||||
claude: { commandNames: ['claude'], sdkBackend: 'claude' },
|
||||
copilot: { commandNames: ['copilot'], sdkBackend: 'copilot' },
|
||||
cursor: { commandNames: ['cursor'], sdkBackend: 'cursor' },
|
||||
codebuddy: { commandNames: ['codebuddy'], sdkBackend: 'codebuddy' },
|
||||
};
|
||||
|
||||
@@ -29,7 +30,11 @@ function matchesPrimaryCliBasename(command: string | undefined, agentKey: Manage
|
||||
export function isSettingsManagedDiscoveredAgent(
|
||||
agent: Pick<DiscoveredAgent, 'command'>,
|
||||
): agent is Pick<DiscoveredAgent, 'command'> & { command: ManagedAgentKey } {
|
||||
return agent.command === 'codex' || agent.command === 'claude' || agent.command === 'copilot' || agent.command === 'codebuddy';
|
||||
return agent.command === 'codex'
|
||||
|| agent.command === 'claude'
|
||||
|| agent.command === 'copilot'
|
||||
|| agent.command === 'cursor'
|
||||
|| agent.command === 'codebuddy';
|
||||
}
|
||||
|
||||
export function matchesManagedAgentConfig(
|
||||
|
||||
@@ -120,6 +120,47 @@ test('runSdkAgentTurn forwards configured SDK agent environment', async () => {
|
||||
assert.equal(streamArgs[2], 'codex');
|
||||
});
|
||||
|
||||
test('runSdkAgentTurn forwards Cursor API key as agent environment', async () => {
|
||||
let streamArgs: unknown[] = [];
|
||||
let done: (() => void) | null = null;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
aiSdkAgentStream: async (...args: unknown[]) => {
|
||||
streamArgs = args;
|
||||
queueMicrotask(() => done?.());
|
||||
return { ok: true };
|
||||
},
|
||||
aiSdkAgentCancel: async () => ({ ok: true }),
|
||||
onAiSdkAgentEvent: () => () => {},
|
||||
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
|
||||
done = cb as () => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiSdkAgentError: () => () => {},
|
||||
};
|
||||
|
||||
await runSdkAgentTurn(
|
||||
bridge,
|
||||
'request-cursor-key',
|
||||
'chat-cursor-key',
|
||||
{
|
||||
id: 'cursor',
|
||||
name: 'Cursor',
|
||||
command: 'cursor',
|
||||
enabled: true,
|
||||
sdkBackend: 'cursor',
|
||||
apiKey: 'cur-test-key',
|
||||
},
|
||||
'hello',
|
||||
createCallbacks([]),
|
||||
);
|
||||
|
||||
assert.deepEqual(streamArgs.at(-1), {
|
||||
CURSOR_API_KEY: 'cur-test-key',
|
||||
});
|
||||
assert.equal(streamArgs[2], 'cursor');
|
||||
});
|
||||
|
||||
|
||||
test('runSdkAgentTurn formats structured async error events', async () => {
|
||||
const errors: string[] = [];
|
||||
let onError: ((error: unknown) => void) | null = null;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import type { AIToolIntegrationMode, ExternalAgentConfig } from './types';
|
||||
import { getExternalAgentSdkBackend } from './managedAgents';
|
||||
import { decryptField } from '../persistence/secureFieldAdapter';
|
||||
|
||||
export interface DefaultTargetSessionHint {
|
||||
sessionId: string;
|
||||
@@ -73,6 +74,21 @@ export interface FileAttachment {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
async function buildAgentEnvWithStoredApiKey(
|
||||
sdkBackend: string,
|
||||
config: ExternalAgentConfig,
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
const env = { ...(config.env ?? {}) };
|
||||
if (sdkBackend === 'cursor' && config.apiKey) {
|
||||
const decrypted = await decryptField(config.apiKey).catch(() => config.apiKey);
|
||||
const apiKey = String(decrypted || '').trim();
|
||||
if (apiKey) {
|
||||
env.CURSOR_API_KEY = apiKey;
|
||||
}
|
||||
}
|
||||
return Object.keys(env).length > 0 ? env : undefined;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string | null {
|
||||
const seen = new WeakSet<object>();
|
||||
try {
|
||||
@@ -167,6 +183,8 @@ export async function runSdkAgentTurn(
|
||||
return true;
|
||||
};
|
||||
|
||||
const agentEnv = await buildAgentEnvWithStoredApiKey(sdkBackend, config);
|
||||
|
||||
// Set up event listeners before starting stream
|
||||
const unsubEvent = sdkBridge.onAiSdkAgentEvent(requestId, (event: StreamEvent) => {
|
||||
const streamFailed = handleStreamEvent(event, callbacks);
|
||||
@@ -221,7 +239,7 @@ export async function runSdkAgentTurn(
|
||||
toolIntegrationMode,
|
||||
defaultTargetSession,
|
||||
userSkillsContext,
|
||||
config.env,
|
||||
agentEnv,
|
||||
).then((result) => {
|
||||
if (result?.ok === false) {
|
||||
settle(() => {
|
||||
|
||||
@@ -229,9 +229,11 @@ export interface ExternalAgentConfig {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
apiKey?: string; // encrypted via credentialBridge (enc:v1: prefix)
|
||||
icon?: string;
|
||||
enabled: boolean;
|
||||
/** SDK backend key for managed agents (claude|codex|copilot|codebuddy). */
|
||||
available?: boolean;
|
||||
/** SDK backend key for managed agents (claude|codex|copilot|cursor|codebuddy). */
|
||||
sdkBackend?: string;
|
||||
/** @deprecated Legacy persisted field from the pre-SDK migration. Read only for compatibility. */
|
||||
acpCommand?: string;
|
||||
@@ -254,8 +256,8 @@ export interface DiscoveredAgent {
|
||||
/** @deprecated Legacy discovery field from the pre-SDK migration. */
|
||||
acpCommand?: string;
|
||||
acpArgs?: string[];
|
||||
/** SDK backend key (claude|codex|copilot|codebuddy) — the routing value. */
|
||||
sdkBackend?: 'claude' | 'codex' | 'copilot' | 'codebuddy';
|
||||
/** SDK backend key (claude|codex|copilot|cursor|codebuddy) — the routing value. */
|
||||
sdkBackend?: 'claude' | 'codex' | 'copilot' | 'cursor' | 'codebuddy';
|
||||
/** Absolute resolved CLI path (preferred over `path`). */
|
||||
binPath?: string;
|
||||
installed?: boolean;
|
||||
@@ -447,6 +449,15 @@ export const CODEX_MODEL_PRESETS: AgentModelPreset[] = [
|
||||
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||||
];
|
||||
|
||||
export const CURSOR_MODEL_PRESETS: AgentModelPreset[] = [
|
||||
{ id: 'composer-2.5', name: 'Composer 2.5', description: 'Recommended' },
|
||||
{ id: 'gpt-5.5', name: 'GPT-5.5' },
|
||||
{ id: 'gpt-5.2', name: 'GPT-5.2' },
|
||||
{ id: 'gpt-5.1', name: 'GPT-5.1' },
|
||||
{ id: 'claude-opus-4.6', name: 'Claude Opus 4.6' },
|
||||
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
|
||||
];
|
||||
|
||||
// CodeBuddy's SDK model enumeration can be empty depending on CLI/account
|
||||
// state; keep a CLI-supported fallback list so users can still pass --model.
|
||||
export const CODEBUDDY_MODEL_PRESETS: AgentModelPreset[] = [
|
||||
@@ -469,6 +480,7 @@ export function getAgentModelPresets(agentCommand?: string): AgentModelPreset[]
|
||||
const basename = agentCommand.split('/').pop()?.toLowerCase() ?? '';
|
||||
if (basename.startsWith('claude')) return CLAUDE_MODEL_PRESETS;
|
||||
if (basename.startsWith('codex')) return CODEX_MODEL_PRESETS;
|
||||
if (basename.startsWith('cursor')) return CURSOR_MODEL_PRESETS;
|
||||
if (basename.startsWith('codebuddy')) return CODEBUDDY_MODEL_PRESETS;
|
||||
return [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user