[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:
陈大猫
2026-06-11 16:43:34 +08:00
committed by GitHub
parent 5e00e998a8
commit 3408bba303
35 changed files with 3479 additions and 93 deletions

View File

@@ -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'),

View File

@@ -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(

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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 [];
}