fix(agent-run): align cursor executor path with 103 production hotfixes
Recover requiredExecutor fallback, direct cursor launch in tool gateway, and user-facing brand sanitization in source so the next portal release can replace manual bundled edits on 103. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+163
-14
@@ -37,6 +37,13 @@ import {
|
|||||||
recordMemoryV2ProductEvent,
|
recordMemoryV2ProductEvent,
|
||||||
} from './memory-v2-product-events.mjs';
|
} from './memory-v2-product-events.mjs';
|
||||||
import { buildMemoryRecallPreviews } from './memory-v2-user-feedback.mjs';
|
import { buildMemoryRecallPreviews } from './memory-v2-user-feedback.mjs';
|
||||||
|
import { pickTkmindLoadingTip } from './tkmind-loading-tips.mjs';
|
||||||
|
import {
|
||||||
|
resolveExecutorDisplayLabel,
|
||||||
|
sanitizeUserFacingBrandText,
|
||||||
|
} from './executor-display-label.mjs';
|
||||||
|
import { applyCursorFirstAgentExecution } from './cursor-page-routing.mjs';
|
||||||
|
import { cursorDeepseekFallbackEnabled } from './cursor-agent-launch.mjs';
|
||||||
|
|
||||||
const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000];
|
const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000];
|
||||||
const TERMINAL_STATUSES = new Set(['succeeded', 'failed']);
|
const TERMINAL_STATUSES = new Set(['succeeded', 'failed']);
|
||||||
@@ -65,6 +72,30 @@ function safeJsonParse(value, fallback = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function agentRunUserMessageFingerprint(userMessage) {
|
||||||
|
const metadata = userMessage?.metadata ?? {};
|
||||||
|
const displayText = String(metadata.displayText ?? '').trim();
|
||||||
|
if (displayText) {
|
||||||
|
return displayText.replace(/\s+/g, ' ').toLowerCase();
|
||||||
|
}
|
||||||
|
const content = userMessage?.content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
const text = content.trim();
|
||||||
|
return text ? text.replace(/\s+/g, ' ').toLowerCase() : '';
|
||||||
|
}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const text = content
|
||||||
|
.filter((item) => item?.type === 'text')
|
||||||
|
.map((item) => String(item.text ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
return text ? text.replace(/\s+/g, ' ').toLowerCase() : '';
|
||||||
|
}
|
||||||
|
const fallback = String(userMessage?.text ?? userMessage?.value ?? '').trim();
|
||||||
|
return fallback ? fallback.replace(/\s+/g, ' ').toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
function parseDbJsonColumn(value, fallback = null) {
|
function parseDbJsonColumn(value, fallback = null) {
|
||||||
if (value == null || value === '') return fallback;
|
if (value == null || value === '') return fallback;
|
||||||
if (typeof value === 'object') return value;
|
if (typeof value === 'object') return value;
|
||||||
@@ -337,8 +368,8 @@ function summarizeText(value, limit = TOOL_GATEWAY_SUMMARY_LIMIT) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildCodeRunCompletionReply(result) {
|
export function buildCodeRunCompletionReply(result) {
|
||||||
const executor = String(result?.executor ?? 'code executor').trim() || 'code executor';
|
const executor = resolveExecutorDisplayLabel(result?.executor, result?.executorLabel);
|
||||||
const output = summarizeText(result?.stdout, 2400).trim();
|
const output = sanitizeUserFacingBrandText(summarizeText(result?.stdout, 2400).trim());
|
||||||
return [
|
return [
|
||||||
`已由 ${executor} 完成执行,并通过平台文件验收。`,
|
`已由 ${executor} 完成执行,并通过平台文件验收。`,
|
||||||
output ? `\n${output}` : '',
|
output ? `\n${output}` : '',
|
||||||
@@ -464,8 +495,10 @@ function normalizeSessionMessageCount(value) {
|
|||||||
export function resolveRequiredCodeExecutor(userMessage) {
|
export function resolveRequiredCodeExecutor(userMessage) {
|
||||||
const metadata = userMessage?.metadata;
|
const metadata = userMessage?.metadata;
|
||||||
const runMetadata = metadata?.[RUN_METADATA_KEY] ?? metadata?.agentRun ?? {};
|
const runMetadata = metadata?.[RUN_METADATA_KEY] ?? metadata?.agentRun ?? {};
|
||||||
const executor = String(runMetadata?.executor ?? '').trim().toLowerCase();
|
const executor = String(
|
||||||
return ['aider', 'openhands'].includes(executor) ? executor : null;
|
runMetadata?.executor ?? runMetadata?.requiredExecutor ?? '',
|
||||||
|
).trim().toLowerCase();
|
||||||
|
return ['aider', 'openhands', 'cursor'].includes(executor) ? executor : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertRequiredCodeExecutorAvailable(requiredExecutor, toolGatewayStatus) {
|
export function assertRequiredCodeExecutorAvailable(requiredExecutor, toolGatewayStatus) {
|
||||||
@@ -509,6 +542,49 @@ function getRunOptionsFromMessage(userMessage) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEffectiveToolMode(runOptions) {
|
||||||
|
return runOptions?.pageDataAiderWorkflow ? 'chat' : (runOptions?.toolMode ?? 'chat');
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCursorFallbackUserMessage(userMessage) {
|
||||||
|
const message = (userMessage && typeof userMessage === 'object' && !Array.isArray(userMessage))
|
||||||
|
? { ...userMessage }
|
||||||
|
: { value: userMessage };
|
||||||
|
const metadata = (message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata))
|
||||||
|
? { ...message.metadata }
|
||||||
|
: {};
|
||||||
|
const runMetadata = (metadata[RUN_METADATA_KEY] && typeof metadata[RUN_METADATA_KEY] === 'object' && !Array.isArray(metadata[RUN_METADATA_KEY]))
|
||||||
|
? { ...metadata[RUN_METADATA_KEY] }
|
||||||
|
: {};
|
||||||
|
const wasCursorPageRewrite = runMetadata.pageCursorDefault === true;
|
||||||
|
if (String(runMetadata.executor ?? '').trim().toLowerCase() === 'cursor') {
|
||||||
|
delete runMetadata.executor;
|
||||||
|
}
|
||||||
|
delete runMetadata.pageCursorDefault;
|
||||||
|
delete runMetadata.cursorAgentDefault;
|
||||||
|
delete runMetadata.cursorTaskKind;
|
||||||
|
delete runMetadata.suggestedDelivery;
|
||||||
|
runMetadata.toolMode = 'chat';
|
||||||
|
if (runMetadata.taskType === 'h5_chat_code_task') delete runMetadata.taskType;
|
||||||
|
metadata[RUN_METADATA_KEY] = runMetadata;
|
||||||
|
|
||||||
|
if (wasCursorPageRewrite) {
|
||||||
|
const displayText = String(metadata.displayText ?? '').trim();
|
||||||
|
if (displayText) {
|
||||||
|
const content = Array.isArray(message.content) ? [...message.content] : [];
|
||||||
|
const textIndex = content.findIndex((item) => item?.type === 'text');
|
||||||
|
if (textIndex >= 0) {
|
||||||
|
content[textIndex] = { ...content[textIndex], text: displayText };
|
||||||
|
} else {
|
||||||
|
content.unshift({ type: 'text', text: displayText });
|
||||||
|
}
|
||||||
|
return { ...message, metadata, content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...message, metadata };
|
||||||
|
}
|
||||||
|
|
||||||
export async function collectAiderReviewFiles(cwd, sinceMs = 0) {
|
export async function collectAiderReviewFiles(cwd, sinceMs = 0) {
|
||||||
if (!cwd) return [];
|
if (!cwd) return [];
|
||||||
const root = path.resolve(String(cwd));
|
const root = path.resolve(String(cwd));
|
||||||
@@ -1024,6 +1100,26 @@ export function createAgentRunGateway({
|
|||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findActiveDuplicateRun(userId, userMessage) {
|
||||||
|
const fingerprint = agentRunUserMessageFingerprint(userMessage);
|
||||||
|
if (!fingerprint) return null;
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT id, user_message_json, status
|
||||||
|
FROM h5_agent_runs
|
||||||
|
WHERE user_id = ? AND status NOT IN ('succeeded', 'failed')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
for (const row of rows) {
|
||||||
|
const parsed = parseDbJsonColumn(row.user_message_json, {});
|
||||||
|
if (agentRunUserMessageFingerprint(parsed) === fingerprint) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function createRun(userId, {
|
async function createRun(userId, {
|
||||||
sessionId = null,
|
sessionId = null,
|
||||||
requestId,
|
requestId,
|
||||||
@@ -1087,6 +1183,15 @@ export function createAgentRunGateway({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateActive = await findActiveDuplicateRun(userId, userMessage);
|
||||||
|
if (duplicateActive) {
|
||||||
|
const conflict = new Error('相同内容已有任务正在处理,请稍候');
|
||||||
|
conflict.code = 'SESSION_RUN_CONFLICT';
|
||||||
|
conflict.status = 409;
|
||||||
|
conflict.existingRunId = duplicateActive.id;
|
||||||
|
throw conflict;
|
||||||
|
}
|
||||||
|
|
||||||
const runId = crypto.randomUUID();
|
const runId = crypto.randomUUID();
|
||||||
const createdAt = nowMs();
|
const createdAt = nowMs();
|
||||||
const runMessage = withRunMetadata(userMessage, {
|
const runMessage = withRunMetadata(userMessage, {
|
||||||
@@ -1213,9 +1318,11 @@ export function createAgentRunGateway({
|
|||||||
|
|
||||||
function startRunHeartbeat(runId, { attempt }) {
|
function startRunHeartbeat(runId, { attempt }) {
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
let heartbeatCount = 0;
|
||||||
const writeHeartbeat = async () => {
|
const writeHeartbeat = async () => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
try {
|
try {
|
||||||
|
heartbeatCount += 1;
|
||||||
await appendEvent(runId, 'worker_heartbeat', {
|
await appendEvent(runId, 'worker_heartbeat', {
|
||||||
attempt,
|
attempt,
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
@@ -1224,6 +1331,14 @@ export function createAgentRunGateway({
|
|||||||
runtimeRoot: worker.runtimeRoot,
|
runtimeRoot: worker.runtimeRoot,
|
||||||
buildId: worker.buildId,
|
buildId: worker.buildId,
|
||||||
});
|
});
|
||||||
|
const tip = pickTkmindLoadingTip({
|
||||||
|
seed: `${runId}:${heartbeatCount}:${Date.now()}`,
|
||||||
|
});
|
||||||
|
await appendEvent(runId, 'loading_tip', {
|
||||||
|
text: tip.text,
|
||||||
|
category: tip.category,
|
||||||
|
source: 'tkmind_executor',
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AgentRun] worker heartbeat failed:', err instanceof Error ? err.message : err);
|
console.error('[AgentRun] worker heartbeat failed:', err instanceof Error ? err.message : err);
|
||||||
}
|
}
|
||||||
@@ -1381,15 +1496,9 @@ export function createAgentRunGateway({
|
|||||||
|
|
||||||
async function executeRun(row, runId) {
|
async function executeRun(row, runId) {
|
||||||
let userMessage = safeJsonParse(row.user_message_json, {});
|
let userMessage = safeJsonParse(row.user_message_json, {});
|
||||||
const runOptions = getRunOptionsFromMessage(userMessage);
|
let runOptions = getRunOptionsFromMessage(userMessage);
|
||||||
const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null;
|
const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null;
|
||||||
assertRequiredCodeExecutorAvailable(
|
let effectiveToolMode = resolveEffectiveToolMode(runOptions);
|
||||||
runOptions.requiredExecutor ?? runOptions.reviewExecutor,
|
|
||||||
toolGatewayStatus,
|
|
||||||
);
|
|
||||||
const effectiveToolMode = runOptions.pageDataAiderWorkflow
|
|
||||||
? 'chat'
|
|
||||||
: runOptions.toolMode;
|
|
||||||
let disclosureDecision = null;
|
let disclosureDecision = null;
|
||||||
try {
|
try {
|
||||||
disclosureDecision = systemDisclosurePolicyService?.evaluate?.({
|
disclosureDecision = systemDisclosurePolicyService?.evaluate?.({
|
||||||
@@ -1446,6 +1555,40 @@ export function createAgentRunGateway({
|
|||||||
}
|
}
|
||||||
const routing = await resolveRunRouting(row, userMessage, runOptions);
|
const routing = await resolveRunRouting(row, userMessage, runOptions);
|
||||||
const routingDecision = resolveLegacyRouteFromClassification(routing) ?? routing?.route ?? null;
|
const routingDecision = resolveLegacyRouteFromClassification(routing) ?? routing?.route ?? null;
|
||||||
|
const cursorFirstApplied = applyCursorFirstAgentExecution(userMessage, runOptions, {
|
||||||
|
routingDecision,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
userMessage = cursorFirstApplied.userMessage;
|
||||||
|
runOptions = cursorFirstApplied.runOptions;
|
||||||
|
effectiveToolMode = resolveEffectiveToolMode(runOptions);
|
||||||
|
const cursorFirstAgent = cursorFirstApplied.cursorFirst === true;
|
||||||
|
const fallbackCursorExecutorToDeepseek = async (err) => {
|
||||||
|
const canFallback = cursorDeepseekFallbackEnabled(process.env)
|
||||||
|
&& (cursorFirstAgent || runOptions.requiredExecutor === 'cursor');
|
||||||
|
if (!canFallback) return false;
|
||||||
|
await appendEvent(runId, 'cursor_executor_fallback_to_deepseek', {
|
||||||
|
code: err?.code ?? null,
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
userMessage = restoreCursorFallbackUserMessage(userMessage);
|
||||||
|
runOptions = {
|
||||||
|
...runOptions,
|
||||||
|
toolMode: 'chat',
|
||||||
|
requiredExecutor: null,
|
||||||
|
taskType: null,
|
||||||
|
};
|
||||||
|
effectiveToolMode = resolveEffectiveToolMode(runOptions);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
assertRequiredCodeExecutorAvailable(
|
||||||
|
runOptions.requiredExecutor ?? runOptions.reviewExecutor,
|
||||||
|
toolGatewayStatus,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(await fallbackCursorExecutorToDeepseek(err))) throw err;
|
||||||
|
}
|
||||||
let agentMemoryContext = null;
|
let agentMemoryContext = null;
|
||||||
if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.resolveAgentMemoryContext) {
|
if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.resolveAgentMemoryContext) {
|
||||||
const displayText = userMessage?.metadata?.displayText
|
const displayText = userMessage?.metadata?.displayText
|
||||||
@@ -1540,8 +1683,9 @@ export function createAgentRunGateway({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const preferDirectChat =
|
const preferDirectChat =
|
||||||
routingDecision === CHAT_INTENT_ROUTE.DIRECT_CHAT ||
|
!cursorFirstAgent &&
|
||||||
(isDirectChatSessionId(row.agent_session_id ?? null) && !runOptions.forceDeepReasoning);
|
(routingDecision === CHAT_INTENT_ROUTE.DIRECT_CHAT ||
|
||||||
|
(isDirectChatSessionId(row.agent_session_id ?? null) && !runOptions.forceDeepReasoning));
|
||||||
const directChatInput = {
|
const directChatInput = {
|
||||||
sessionId: row.agent_session_id ?? null,
|
sessionId: row.agent_session_id ?? null,
|
||||||
toolMode: runOptions.toolMode,
|
toolMode: runOptions.toolMode,
|
||||||
@@ -1602,11 +1746,13 @@ export function createAgentRunGateway({
|
|||||||
const workingDir = userAuth?.resolveWorkingDir
|
const workingDir = userAuth?.resolveWorkingDir
|
||||||
? await userAuth.resolveWorkingDir(row.user_id)
|
? await userAuth.resolveWorkingDir(row.user_id)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
try {
|
||||||
await invalidatePortalDirectChatSnapshot(row.agent_session_id ?? null);
|
await invalidatePortalDirectChatSnapshot(row.agent_session_id ?? null);
|
||||||
await appendEvent(runId, 'tool_gateway_dispatch', {
|
await appendEvent(runId, 'tool_gateway_dispatch', {
|
||||||
protocol: toolGatewayStatus.protocol ?? 'agent-run-v1',
|
protocol: toolGatewayStatus.protocol ?? 'agent-run-v1',
|
||||||
taskType: runOptions.taskType,
|
taskType: runOptions.taskType,
|
||||||
workingDir: workingDir ?? null,
|
workingDir: workingDir ?? null,
|
||||||
|
executor: runOptions.requiredExecutor ?? null,
|
||||||
});
|
});
|
||||||
const result = await toolGateway.executeJob({
|
const result = await toolGateway.executeJob({
|
||||||
runId,
|
runId,
|
||||||
@@ -1685,6 +1831,9 @@ export function createAgentRunGateway({
|
|||||||
return { sessionId: delivery.sessionId, routing };
|
return { sessionId: delivery.sessionId, routing };
|
||||||
}
|
}
|
||||||
return { sessionId: row.agent_session_id ?? null, routing };
|
return { sessionId: row.agent_session_id ?? null, routing };
|
||||||
|
} catch (err) {
|
||||||
|
if (!(await fallbackCursorExecutorToDeepseek(err))) throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let sessionId = resolveGatewayAgentSessionId({
|
let sessionId = resolveGatewayAgentSessionId({
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ test('required code executor is read from run metadata', () => {
|
|||||||
}), null);
|
}), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('required code executor falls back to requiredExecutor metadata', () => {
|
||||||
|
assert.equal(resolveRequiredCodeExecutor({
|
||||||
|
metadata: { memindRun: { requiredExecutor: 'cursor' } },
|
||||||
|
}), 'cursor');
|
||||||
|
assert.equal(resolveRequiredCodeExecutor({
|
||||||
|
metadata: { memindRun: { executor: 'aider', requiredExecutor: 'cursor' } },
|
||||||
|
}), 'aider');
|
||||||
|
});
|
||||||
|
|
||||||
test('required Aider executor fails closed when Tool Gateway is unavailable', () => {
|
test('required Aider executor fails closed when Tool Gateway is unavailable', () => {
|
||||||
assert.doesNotThrow(() => assertRequiredCodeExecutorAvailable('aider', {
|
assert.doesNotThrow(() => assertRequiredCodeExecutorAvailable('aider', {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -124,6 +133,21 @@ function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} }
|
|||||||
const [userId, requestId] = params;
|
const [userId, requestId] = params;
|
||||||
return [[...runs.values()].filter((row) => row.user_id === userId && row.request_id === requestId)];
|
return [[...runs.values()].filter((row) => row.user_id === userId && row.request_id === requestId)];
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
sql.includes('SELECT id, user_message_json, status')
|
||||||
|
&& sql.includes('WHERE user_id = ? AND status NOT IN')
|
||||||
|
) {
|
||||||
|
const [userId] = params;
|
||||||
|
return [[...runs.values()]
|
||||||
|
.filter((row) => row.user_id === userId && !['succeeded', 'failed'].includes(row.status))
|
||||||
|
.sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0))
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
user_message_json: row.user_message_json,
|
||||||
|
status: row.status,
|
||||||
|
}))];
|
||||||
|
}
|
||||||
if (sql.includes('agent_session_id = ?') && sql.includes("status NOT IN ('succeeded', 'failed')")) {
|
if (sql.includes('agent_session_id = ?') && sql.includes("status NOT IN ('succeeded', 'failed')")) {
|
||||||
const [sessionId] = params;
|
const [sessionId] = params;
|
||||||
const active = [...runs.values()].filter(
|
const active = [...runs.values()].filter(
|
||||||
@@ -2943,6 +2967,88 @@ test('agent run validates expected tool gateway artifacts before succeeding', as
|
|||||||
assert.equal(JSON.parse(validationEvent.dataJson).expectedFiles[0].path, 'RESULT.md');
|
assert.equal(JSON.parse(validationEvent.dataJson).expectedFiles[0].path, 'RESULT.md');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cursor executor fallback submits a clean chat message to Goose', async () => {
|
||||||
|
const previousFallback = process.env.MEMIND_CURSOR_DEEPSEEK_FALLBACK;
|
||||||
|
process.env.MEMIND_CURSOR_DEEPSEEK_FALLBACK = '1';
|
||||||
|
try {
|
||||||
|
const pool = createFakePool();
|
||||||
|
const submitted = [];
|
||||||
|
const gateway = createAgentRunGateway({
|
||||||
|
pool,
|
||||||
|
userAuth: {
|
||||||
|
async resolveWorkingDir() {
|
||||||
|
return '/tmp/memind-user-1';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tkmindProxy: {
|
||||||
|
async startSessionForUser() {
|
||||||
|
return { id: 'session-cursor-fallback' };
|
||||||
|
},
|
||||||
|
async submitSessionReplyAndAwaitFinishForUser(userId, sessionId, requestId, userMessage, options) {
|
||||||
|
submitted.push({ userId, sessionId, requestId, userMessage, options });
|
||||||
|
return { tokenState: null, toolEvidence: null };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toolGateway: {
|
||||||
|
getStatus() {
|
||||||
|
return { enabled: true, protocol: 'agent-run-v1', executors: ['cursor'] };
|
||||||
|
},
|
||||||
|
async executeJob() {
|
||||||
|
const error = new Error('cursor executor failed');
|
||||||
|
error.code = 'TOOL_GATEWAY_EXECUTOR_FAILED';
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retryDelaysMs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = await gateway.createRun('user-1', {
|
||||||
|
requestId: 'req-cursor-fallback',
|
||||||
|
userMessage: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: '写一段问候文字\n\n[Memind page task via TKMind 智趣 executor]\n不要只回复文字',
|
||||||
|
}],
|
||||||
|
metadata: {
|
||||||
|
displayText: '写一段问候文字',
|
||||||
|
memindRun: {
|
||||||
|
executor: 'cursor',
|
||||||
|
pageCursorDefault: true,
|
||||||
|
cursorTaskKind: 'page_generation',
|
||||||
|
suggestedDelivery: 'mindspace_public_html',
|
||||||
|
toolMode: 'code',
|
||||||
|
taskType: 'h5_chat_code_task',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toolMode: 'code',
|
||||||
|
taskType: 'h5_chat_code_task',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
|
||||||
|
assert.equal(submitted.length, 1);
|
||||||
|
assert.equal(submitted[0].options.toolMode, 'chat');
|
||||||
|
assert.equal(submitted[0].userMessage.content[0].text, '写一段问候文字');
|
||||||
|
assert.equal(submitted[0].userMessage.metadata.memindRun.toolMode, 'chat');
|
||||||
|
assert.equal(submitted[0].userMessage.metadata.memindRun.executor, undefined);
|
||||||
|
assert.equal(submitted[0].userMessage.metadata.memindRun.pageCursorDefault, undefined);
|
||||||
|
assert.equal(submitted[0].userMessage.metadata.memindRun.taskType, undefined);
|
||||||
|
assert.equal(
|
||||||
|
pool.events.some(
|
||||||
|
(event) => event.runId === run.id && event.eventType === 'cursor_executor_fallback_to_deepseek',
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previousFallback == null) {
|
||||||
|
delete process.env.MEMIND_CURSOR_DEEPSEEK_FALLBACK;
|
||||||
|
} else {
|
||||||
|
process.env.MEMIND_CURSOR_DEEPSEEK_FALLBACK = previousFallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('required Aider run persists a validated result into a chat session', async () => {
|
test('required Aider run persists a validated result into a chat session', async () => {
|
||||||
const pool = createFakePool();
|
const pool = createFakePool();
|
||||||
const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-aider-delivery-'));
|
const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-aider-delivery-'));
|
||||||
@@ -3767,6 +3873,51 @@ test('createRun rejects with SESSION_RUN_CONFLICT when same session already has
|
|||||||
assert.equal(run3.requestId, 'req-conflict-3');
|
assert.equal(run3.requestId, 'req-conflict-3');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createRun rejects duplicate active runs with the same user-visible message fingerprint', async () => {
|
||||||
|
const pool = createFakePool();
|
||||||
|
const gateway = createAgentRunGateway({
|
||||||
|
pool,
|
||||||
|
userAuth: {},
|
||||||
|
tkmindProxy: {},
|
||||||
|
autoDispatch: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeRunId = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
const userMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '帮我写一首诗,做成页面123' }],
|
||||||
|
metadata: { displayText: '帮我写一首诗,做成页面123', userVisible: true },
|
||||||
|
};
|
||||||
|
pool.runs.set(activeRunId, {
|
||||||
|
id: activeRunId,
|
||||||
|
user_id: 'user-1',
|
||||||
|
agent_session_id: null,
|
||||||
|
request_id: 'req-page123',
|
||||||
|
status: 'running',
|
||||||
|
attempts: 1,
|
||||||
|
user_message_json: JSON.stringify(userMessage),
|
||||||
|
error_message: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
started_at: now,
|
||||||
|
completed_at: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
let conflictErr = null;
|
||||||
|
try {
|
||||||
|
await gateway.createRun('user-1', {
|
||||||
|
requestId: 'req-page123-dup',
|
||||||
|
userMessage,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
conflictErr = err;
|
||||||
|
}
|
||||||
|
assert.ok(conflictErr);
|
||||||
|
assert.equal(conflictErr.code, 'SESSION_RUN_CONFLICT');
|
||||||
|
assert.equal(conflictErr.existingRunId, activeRunId);
|
||||||
|
});
|
||||||
|
|
||||||
test('createRun recovers a stale active run for the same session before accepting a new run', async () => {
|
test('createRun recovers a stale active run for the same session before accepting a new run', async () => {
|
||||||
const pool = createFakePool();
|
const pool = createFakePool();
|
||||||
const gateway = createAgentRunGateway({
|
const gateway = createAgentRunGateway({
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const EXECUTOR_DISPLAY_LABELS = Object.freeze({
|
||||||
|
cursor: 'TKMind 智趣',
|
||||||
|
aider: 'Aider',
|
||||||
|
openhands: 'OpenHands',
|
||||||
|
goose: 'TKMind',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function resolveExecutorDisplayLabel(executor, executorLabel = '') {
|
||||||
|
const label = String(executorLabel ?? '').trim();
|
||||||
|
if (label && !/^cursor$/i.test(label)) return label;
|
||||||
|
|
||||||
|
const normalized = String(executor ?? '').trim().toLowerCase();
|
||||||
|
if (EXECUTOR_DISPLAY_LABELS[normalized]) return EXECUTOR_DISPLAY_LABELS[normalized];
|
||||||
|
if (label) return label.replace(/cursor/gi, 'TKMind 智趣');
|
||||||
|
return normalized || 'TKMind 智趣';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeUserFacingBrandText(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/\bCursor Chat Bridge\b/g, 'TKMind Chat Bridge')
|
||||||
|
.replace(/\bcursor\s+agent\b/gi, 'TKMind 智趣')
|
||||||
|
.replace(/\bcursor\s+cli\b/gi, 'TKMind 智趣')
|
||||||
|
.replace(/\bCursor agent\b/g, 'TKMind 智趣')
|
||||||
|
.replace(/\bCursor 执行器\b/g, 'TKMind 智趣执行器')
|
||||||
|
.replace(/\bCursor 启动\b/g, 'TKMind 智趣启动')
|
||||||
|
.replace(/\bCursor\b/g, 'TKMind 智趣')
|
||||||
|
.replace(/\bcursor\b/g, 'TKMind 智趣');
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { resolveExecutorDisplayLabel, sanitizeUserFacingBrandText } from './executor-display-label.mjs';
|
||||||
|
import { buildCodeRunCompletionReply } from './agent-run-gateway.mjs';
|
||||||
|
|
||||||
|
test('resolveExecutorDisplayLabel maps cursor id to TKMind 智趣', () => {
|
||||||
|
assert.equal(resolveExecutorDisplayLabel('cursor'), 'TKMind 智趣');
|
||||||
|
assert.equal(resolveExecutorDisplayLabel('cursor', 'Cursor'), 'TKMind 智趣');
|
||||||
|
assert.equal(resolveExecutorDisplayLabel('aider'), 'Aider');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCodeRunCompletionReply never exposes cursor brand to users', () => {
|
||||||
|
const reply = buildCodeRunCompletionReply({ executor: 'cursor', stdout: '' });
|
||||||
|
assert.match(reply, /已由 TKMind 智趣 完成执行,并通过平台文件验收。/);
|
||||||
|
assert.doesNotMatch(reply, /cursor/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCodeRunCompletionReply sanitizes cursor wording in stdout', () => {
|
||||||
|
const reply = buildCodeRunCompletionReply({
|
||||||
|
executor: 'cursor',
|
||||||
|
stdout: 'cursor agent finished editing public/page.html',
|
||||||
|
});
|
||||||
|
assert.match(reply, /TKMind 智趣 finished editing public\/page\.html/);
|
||||||
|
assert.doesNotMatch(reply, /\bcursor\b/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeUserFacingBrandText replaces cursor wording', () => {
|
||||||
|
assert.equal(
|
||||||
|
sanitizeUserFacingBrandText('已由 cursor 完成执行,并通过平台文件验收。'),
|
||||||
|
'已由 TKMind 智趣 完成执行,并通过平台文件验收。',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
sanitizeUserFacingBrandText('cursor agent finished editing public/page.html'),
|
||||||
|
'TKMind 智趣 finished editing public/page.html',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/** @typedef {'intro' | 'news' | 'joke' | 'poem'} TkmindLoadingTipCategory */
|
||||||
|
|
||||||
|
/** @type {Array<{ category: TkmindLoadingTipCategory, text: string }>} */
|
||||||
|
export const TKMIND_LOADING_TIPS = [
|
||||||
|
{ category: 'intro', text: 'TKMind 智趣:把聊天变成可交付的页面、问卷与分析。' },
|
||||||
|
{ category: 'intro', text: '智趣正在帮你落盘 MindSpace 页面,稍等片刻就好。' },
|
||||||
|
{ category: 'intro', text: 'TKMind 会把对话里的想法,变成能分享的链接。' },
|
||||||
|
{ category: 'intro', text: '页面、问卷、Excel 分析——智趣一条指令就能开工。' },
|
||||||
|
{ category: 'intro', text: 'MindSpace 是你的创作工作台,TKMind 是懂你的搭档。' },
|
||||||
|
{ category: 'news', text: '今日小贴士:先让智趣出草稿,再微调细节,效率翻倍。' },
|
||||||
|
{ category: 'news', text: '热点观察:越来越多团队用 AI 助手做「可点击」的交付物。' },
|
||||||
|
{ category: 'news', text: '趋势速览:问卷 + Page Data 可以直接沉淀到数据库。' },
|
||||||
|
{ category: 'news', text: '轻新闻:一杯咖啡的时间,页面可能就写好了。' },
|
||||||
|
{ category: 'joke', text: '程序员笑话:Bug 不是消失,只是换了个地方躲猫猫。' },
|
||||||
|
{ category: 'joke', text: '冷笑话:为什么页面加载慢?因为它在认真排版。' },
|
||||||
|
{ category: 'joke', text: '今日一笑:产品经理说「就改一行」,智趣默默打开了十个文件。' },
|
||||||
|
{ category: 'joke', text: '段子时间:代码写得好,头发剩多少?' },
|
||||||
|
{ category: 'poem', text: '「采菊东篱下,悠然见南山。」——陶渊明' },
|
||||||
|
{ category: 'poem', text: '「欲把西湖比西子,淡妆浓抹总相宜。」——苏轼' },
|
||||||
|
{ category: 'poem', text: '「海上生明月,天涯共此时。」——张九龄' },
|
||||||
|
{ category: 'poem', text: '「春风得意马蹄疾,一日看尽长安花。」——孟郊' },
|
||||||
|
{ category: 'poem', text: '「行到水穷处,坐看云起时。」——王维' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ seed?: string | number, exclude?: string[] }} [options]
|
||||||
|
* @returns {{ category: TkmindLoadingTipCategory, text: string }}
|
||||||
|
*/
|
||||||
|
export function pickTkmindLoadingTip(options = {}) {
|
||||||
|
const exclude = new Set((options.exclude ?? []).map((item) => String(item ?? '').trim()).filter(Boolean));
|
||||||
|
const pool = TKMIND_LOADING_TIPS.filter((item) => !exclude.has(item.text));
|
||||||
|
const candidates = pool.length > 0 ? pool : TKMIND_LOADING_TIPS;
|
||||||
|
const seed = String(options.seed ?? `${Date.now()}-${Math.random()}`);
|
||||||
|
let hash = 0;
|
||||||
|
for (let index = 0; index < seed.length; index += 1) {
|
||||||
|
hash = ((hash << 5) - hash + seed.charCodeAt(index)) | 0;
|
||||||
|
}
|
||||||
|
const picked = candidates[Math.abs(hash) % candidates.length];
|
||||||
|
return picked ?? candidates[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { TKMIND_LOADING_TIPS, pickTkmindLoadingTip } from './tkmind-loading-tips.mjs';
|
||||||
|
|
||||||
|
test('TKMIND_LOADING_TIPS has intro news joke poem entries', () => {
|
||||||
|
const categories = new Set(TKMIND_LOADING_TIPS.map((item) => item.category));
|
||||||
|
assert.ok(categories.has('intro'));
|
||||||
|
assert.ok(categories.has('news'));
|
||||||
|
assert.ok(categories.has('joke'));
|
||||||
|
assert.ok(categories.has('poem'));
|
||||||
|
for (const item of TKMIND_LOADING_TIPS) {
|
||||||
|
assert.ok(item.text.trim().length >= 8);
|
||||||
|
assert.doesNotMatch(item.text, /Cursor/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickTkmindLoadingTip is deterministic for the same seed', () => {
|
||||||
|
const first = pickTkmindLoadingTip({ seed: 'run-123' });
|
||||||
|
const second = pickTkmindLoadingTip({ seed: 'run-123' });
|
||||||
|
assert.equal(first.text, second.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickTkmindLoadingTip can exclude recent tips', () => {
|
||||||
|
const only = TKMIND_LOADING_TIPS[0];
|
||||||
|
const picked = pickTkmindLoadingTip({
|
||||||
|
seed: 'exclude-test',
|
||||||
|
exclude: TKMIND_LOADING_TIPS.filter((item) => item.text !== only.text).map((item) => item.text),
|
||||||
|
});
|
||||||
|
assert.equal(picked.text, only.text);
|
||||||
|
});
|
||||||
+54
-16
@@ -2,8 +2,22 @@ import { spawn as nodeSpawn } from 'node:child_process';
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { buildCursorExecutorLaunchPlan } from './cursor-agent-launch.mjs';
|
||||||
|
import { resolveExecutorDisplayLabel } from './executor-display-label.mjs';
|
||||||
|
|
||||||
const CODE_EXECUTORS = new Set(['aider', 'openhands']);
|
const BASE_CODE_EXECUTORS = ['aider', 'openhands'];
|
||||||
|
|
||||||
|
function cursorExecutorEnabled(env = process.env) {
|
||||||
|
const raw = String(env.MEMIND_CURSOR_EXECUTOR_ENABLED ?? '').trim().toLowerCase();
|
||||||
|
if (!raw) return false;
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeExecutorsForEnv(env = process.env) {
|
||||||
|
return cursorExecutorEnabled(env)
|
||||||
|
? ['cursor', ...BASE_CODE_EXECUTORS]
|
||||||
|
: [...BASE_CODE_EXECUTORS];
|
||||||
|
}
|
||||||
const DEFAULT_STDIO_LIMIT = 64 * 1024;
|
const DEFAULT_STDIO_LIMIT = 64 * 1024;
|
||||||
|
|
||||||
function envFlag(value, fallback = false) {
|
function envFlag(value, fallback = false) {
|
||||||
@@ -18,9 +32,10 @@ function positiveInteger(value, fallback) {
|
|||||||
return Math.floor(n);
|
return Math.floor(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeExecutor(value, fallback = 'aider') {
|
function normalizeExecutor(value, fallback = 'aider', env = process.env) {
|
||||||
const normalized = String(value ?? fallback).trim().toLowerCase();
|
const normalized = String(value ?? fallback).trim().toLowerCase();
|
||||||
if (CODE_EXECUTORS.has(normalized)) return normalized;
|
const allowed = new Set(codeExecutorsForEnv(env));
|
||||||
|
if (allowed.has(normalized)) return normalized;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +165,12 @@ export function createToolGateway({
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
const enabled = envFlag(env.MEMIND_TOOL_GATEWAY_ENABLED, false);
|
const enabled = envFlag(env.MEMIND_TOOL_GATEWAY_ENABLED, false);
|
||||||
const dryRun = envFlag(env.MEMIND_TOOL_GATEWAY_DRY_RUN, false);
|
const dryRun = envFlag(env.MEMIND_TOOL_GATEWAY_DRY_RUN, false);
|
||||||
const defaultExecutor = normalizeExecutor(env.MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR, 'aider');
|
const cursorEnabled = cursorExecutorEnabled(env);
|
||||||
|
const defaultExecutor = normalizeExecutor(env.MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR, 'aider', env);
|
||||||
|
const cursorTaskTypes = csvSet(
|
||||||
|
env.MEMIND_TOOL_GATEWAY_CURSOR_TASK_TYPES
|
||||||
|
?? 'h5_chat_code_task,mindspace_page,mindspace_html_page',
|
||||||
|
);
|
||||||
const openhandsTaskTypes = csvSet(
|
const openhandsTaskTypes = csvSet(
|
||||||
env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES
|
env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES
|
||||||
?? 'repo_refactor,multi_file,complex_repo,page_data_dev_complex',
|
?? 'repo_refactor,multi_file,complex_repo,page_data_dev_complex',
|
||||||
@@ -162,8 +182,10 @@ export function createToolGateway({
|
|||||||
enabled,
|
enabled,
|
||||||
dryRun,
|
dryRun,
|
||||||
protocol: 'agent-run-v1',
|
protocol: 'agent-run-v1',
|
||||||
executors: ['aider', 'openhands'],
|
executors: codeExecutorsForEnv(env),
|
||||||
defaultExecutor,
|
defaultExecutor,
|
||||||
|
cursorEnabled,
|
||||||
|
cursorTaskTypes: [...cursorTaskTypes],
|
||||||
openhandsTaskTypes: [...openhandsTaskTypes],
|
openhandsTaskTypes: [...openhandsTaskTypes],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -171,9 +193,14 @@ export function createToolGateway({
|
|||||||
function selectExecutor({ userMessage, taskType } = {}) {
|
function selectExecutor({ userMessage, taskType } = {}) {
|
||||||
const metadata = userMessage?.metadata ?? {};
|
const metadata = userMessage?.metadata ?? {};
|
||||||
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
|
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
|
||||||
const requested = normalizeExecutor(runMetadata.executor, '');
|
const requested = normalizeExecutor(
|
||||||
|
runMetadata.executor || runMetadata.requiredExecutor,
|
||||||
|
'',
|
||||||
|
env,
|
||||||
|
);
|
||||||
if (requested) return requested;
|
if (requested) return requested;
|
||||||
const normalizedTaskType = String(taskType ?? runMetadata.taskType ?? '').trim().toLowerCase();
|
const normalizedTaskType = String(taskType ?? runMetadata.taskType ?? '').trim().toLowerCase();
|
||||||
|
if (cursorEnabled && cursorTaskTypes.has(normalizedTaskType)) return 'cursor';
|
||||||
if (openhandsTaskTypes.has(normalizedTaskType)) return 'openhands';
|
if (openhandsTaskTypes.has(normalizedTaskType)) return 'openhands';
|
||||||
return defaultExecutor;
|
return defaultExecutor;
|
||||||
}
|
}
|
||||||
@@ -190,9 +217,6 @@ export function createToolGateway({
|
|||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
throw new Error('Tool Gateway is disabled');
|
throw new Error('Tool Gateway is disabled');
|
||||||
}
|
}
|
||||||
if (!llmProviderService?.getExecutorLaunchPlan) {
|
|
||||||
throw new Error('Tool Gateway missing llm provider service');
|
|
||||||
}
|
|
||||||
const instruction = extractToolInstruction(userMessage);
|
const instruction = extractToolInstruction(userMessage);
|
||||||
if (!instruction) {
|
if (!instruction) {
|
||||||
throw new Error('Tool Gateway job missing instruction');
|
throw new Error('Tool Gateway job missing instruction');
|
||||||
@@ -204,13 +228,25 @@ export function createToolGateway({
|
|||||||
const executorInstruction = receiptPath
|
const executorInstruction = receiptPath
|
||||||
? `${instruction}\n\nThe validation receipt is already included in the Aider chat. Edit it directly; do not ask the user to add it.`
|
? `${instruction}\n\nThe validation receipt is already included in the Aider chat. Edit it directly; do not ask the user to add it.`
|
||||||
: instruction;
|
: instruction;
|
||||||
let plan = await llmProviderService.getExecutorLaunchPlan(executor, {
|
let plan;
|
||||||
cwd,
|
if (executor === 'cursor') {
|
||||||
mode: 'headless',
|
plan = buildCursorExecutorLaunchPlan({
|
||||||
instruction: executorInstruction,
|
cwd,
|
||||||
purpose: 'default',
|
instruction: executorInstruction,
|
||||||
includeSecret: true,
|
env,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
if (!llmProviderService?.getExecutorLaunchPlan) {
|
||||||
|
throw new Error('Tool Gateway missing llm provider service');
|
||||||
|
}
|
||||||
|
plan = await llmProviderService.getExecutorLaunchPlan(executor, {
|
||||||
|
cwd,
|
||||||
|
mode: 'headless',
|
||||||
|
instruction: executorInstruction,
|
||||||
|
purpose: 'default',
|
||||||
|
includeSecret: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!plan?.ok) {
|
if (!plan?.ok) {
|
||||||
throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`);
|
throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`);
|
||||||
}
|
}
|
||||||
@@ -226,6 +262,7 @@ export function createToolGateway({
|
|||||||
ok: true,
|
ok: true,
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
executor,
|
executor,
|
||||||
|
executorLabel: resolveExecutorDisplayLabel(executor, plan.executorLabel),
|
||||||
protocol: 'agent-run-v1',
|
protocol: 'agent-run-v1',
|
||||||
cwd: plan.cwd,
|
cwd: plan.cwd,
|
||||||
command: plan.command,
|
command: plan.command,
|
||||||
@@ -274,6 +311,7 @@ export function createToolGateway({
|
|||||||
resolve({
|
resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
executor,
|
executor,
|
||||||
|
executorLabel: resolveExecutorDisplayLabel(executor, plan.executorLabel),
|
||||||
protocol: 'agent-run-v1',
|
protocol: 'agent-run-v1',
|
||||||
cwd: plan.cwd,
|
cwd: plan.cwd,
|
||||||
command: plan.command,
|
command: plan.command,
|
||||||
|
|||||||
@@ -32,10 +32,59 @@ test('tool gateway is disabled by default and reports protocol', () => {
|
|||||||
protocol: 'agent-run-v1',
|
protocol: 'agent-run-v1',
|
||||||
executors: ['aider', 'openhands'],
|
executors: ['aider', 'openhands'],
|
||||||
defaultExecutor: 'aider',
|
defaultExecutor: 'aider',
|
||||||
|
cursorEnabled: false,
|
||||||
|
cursorTaskTypes: ['h5_chat_code_task', 'mindspace_page', 'mindspace_html_page'],
|
||||||
openhandsTaskTypes: ['repo_refactor', 'multi_file', 'complex_repo', 'page_data_dev_complex'],
|
openhandsTaskTypes: ['repo_refactor', 'multi_file', 'complex_repo', 'page_data_dev_complex'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tool gateway exposes cursor executor when enabled', () => {
|
||||||
|
const gateway = createToolGateway({
|
||||||
|
env: {
|
||||||
|
MEMIND_CURSOR_EXECUTOR_ENABLED: '1',
|
||||||
|
MEMIND_TOOL_GATEWAY_CURSOR_TASK_TYPES: 'h5_chat_code_task',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(gateway.getStatus().executors, ['cursor', 'aider', 'openhands']);
|
||||||
|
assert.equal(
|
||||||
|
gateway.selectExecutor({ taskType: 'h5_chat_code_task' }),
|
||||||
|
'cursor',
|
||||||
|
);
|
||||||
|
assert.equal(gateway.selectExecutor({ taskType: 'small_patch' }), 'aider');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tool gateway honors explicit cursor executor in run metadata', () => {
|
||||||
|
const gateway = createToolGateway({
|
||||||
|
env: { MEMIND_CURSOR_EXECUTOR_ENABLED: '1' },
|
||||||
|
});
|
||||||
|
assert.equal(gateway.selectExecutor({
|
||||||
|
taskType: 'page_data_dev_complex',
|
||||||
|
userMessage: {
|
||||||
|
metadata: {
|
||||||
|
memindRun: {
|
||||||
|
executor: 'cursor',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), 'cursor');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tool gateway honors requiredExecutor when executor is absent', () => {
|
||||||
|
const gateway = createToolGateway({
|
||||||
|
env: { MEMIND_CURSOR_EXECUTOR_ENABLED: '1' },
|
||||||
|
});
|
||||||
|
assert.equal(gateway.selectExecutor({
|
||||||
|
userMessage: {
|
||||||
|
metadata: {
|
||||||
|
memindRun: {
|
||||||
|
requiredExecutor: 'cursor',
|
||||||
|
channel: 'wechat_mp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), 'cursor');
|
||||||
|
});
|
||||||
|
|
||||||
test('tool gateway selects openhands for configured task types', () => {
|
test('tool gateway selects openhands for configured task types', () => {
|
||||||
const gateway = createToolGateway({
|
const gateway = createToolGateway({
|
||||||
env: {
|
env: {
|
||||||
@@ -144,3 +193,33 @@ test('tool gateway dry run builds executor launch plan without spawning', async
|
|||||||
assert.equal(plans.length, 1);
|
assert.equal(plans.length, 1);
|
||||||
assert.equal(plans[0].options.instruction, 'fix it');
|
assert.equal(plans[0].options.instruction, 'fix it');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tool gateway cursor dry run does not require llm provider service', async () => {
|
||||||
|
const gateway = createToolGateway({
|
||||||
|
env: {
|
||||||
|
MEMIND_TOOL_GATEWAY_ENABLED: '1',
|
||||||
|
MEMIND_TOOL_GATEWAY_DRY_RUN: '1',
|
||||||
|
MEMIND_CURSOR_EXECUTOR_ENABLED: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await gateway.executeJob({
|
||||||
|
runId: 'run-cursor',
|
||||||
|
requestId: 'req-cursor',
|
||||||
|
userId: 'user-1',
|
||||||
|
cwd: '/tmp/work',
|
||||||
|
userMessage: {
|
||||||
|
content: [{ type: 'text', text: 'build page' }],
|
||||||
|
metadata: {
|
||||||
|
memindRun: {
|
||||||
|
requiredExecutor: 'cursor',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.dryRun, true);
|
||||||
|
assert.equal(result.executor, 'cursor');
|
||||||
|
assert.equal(result.executorLabel, 'TKMind 智趣');
|
||||||
|
assert.match(String(result.command), /agent$/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user