diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index d9fdabf..d681fd5 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -37,6 +37,13 @@ import { recordMemoryV2ProductEvent, } from './memory-v2-product-events.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 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) { if (value == null || value === '') return fallback; if (typeof value === 'object') return value; @@ -337,8 +368,8 @@ function summarizeText(value, limit = TOOL_GATEWAY_SUMMARY_LIMIT) { } export function buildCodeRunCompletionReply(result) { - const executor = String(result?.executor ?? 'code executor').trim() || 'code executor'; - const output = summarizeText(result?.stdout, 2400).trim(); + const executor = resolveExecutorDisplayLabel(result?.executor, result?.executorLabel); + const output = sanitizeUserFacingBrandText(summarizeText(result?.stdout, 2400).trim()); return [ `已由 ${executor} 完成执行,并通过平台文件验收。`, output ? `\n${output}` : '', @@ -464,8 +495,10 @@ function normalizeSessionMessageCount(value) { export function resolveRequiredCodeExecutor(userMessage) { const metadata = userMessage?.metadata; const runMetadata = metadata?.[RUN_METADATA_KEY] ?? metadata?.agentRun ?? {}; - const executor = String(runMetadata?.executor ?? '').trim().toLowerCase(); - return ['aider', 'openhands'].includes(executor) ? executor : null; + const executor = String( + runMetadata?.executor ?? runMetadata?.requiredExecutor ?? '', + ).trim().toLowerCase(); + return ['aider', 'openhands', 'cursor'].includes(executor) ? executor : null; } 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) { if (!cwd) return []; const root = path.resolve(String(cwd)); @@ -1024,6 +1100,26 @@ export function createAgentRunGateway({ 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, { sessionId = null, 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 createdAt = nowMs(); const runMessage = withRunMetadata(userMessage, { @@ -1213,9 +1318,11 @@ export function createAgentRunGateway({ function startRunHeartbeat(runId, { attempt }) { let stopped = false; + let heartbeatCount = 0; const writeHeartbeat = async () => { if (stopped) return; try { + heartbeatCount += 1; await appendEvent(runId, 'worker_heartbeat', { attempt, pid: process.pid, @@ -1224,6 +1331,14 @@ export function createAgentRunGateway({ runtimeRoot: worker.runtimeRoot, 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) { console.error('[AgentRun] worker heartbeat failed:', err instanceof Error ? err.message : err); } @@ -1381,15 +1496,9 @@ export function createAgentRunGateway({ async function executeRun(row, runId) { let userMessage = safeJsonParse(row.user_message_json, {}); - const runOptions = getRunOptionsFromMessage(userMessage); + let runOptions = getRunOptionsFromMessage(userMessage); const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null; - assertRequiredCodeExecutorAvailable( - runOptions.requiredExecutor ?? runOptions.reviewExecutor, - toolGatewayStatus, - ); - const effectiveToolMode = runOptions.pageDataAiderWorkflow - ? 'chat' - : runOptions.toolMode; + let effectiveToolMode = resolveEffectiveToolMode(runOptions); let disclosureDecision = null; try { disclosureDecision = systemDisclosurePolicyService?.evaluate?.({ @@ -1446,6 +1555,40 @@ export function createAgentRunGateway({ } const routing = await resolveRunRouting(row, userMessage, runOptions); 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; if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.resolveAgentMemoryContext) { const displayText = userMessage?.metadata?.displayText @@ -1540,8 +1683,9 @@ export function createAgentRunGateway({ } } const preferDirectChat = - routingDecision === CHAT_INTENT_ROUTE.DIRECT_CHAT || - (isDirectChatSessionId(row.agent_session_id ?? null) && !runOptions.forceDeepReasoning); + !cursorFirstAgent && + (routingDecision === CHAT_INTENT_ROUTE.DIRECT_CHAT || + (isDirectChatSessionId(row.agent_session_id ?? null) && !runOptions.forceDeepReasoning)); const directChatInput = { sessionId: row.agent_session_id ?? null, toolMode: runOptions.toolMode, @@ -1602,11 +1746,13 @@ export function createAgentRunGateway({ const workingDir = userAuth?.resolveWorkingDir ? await userAuth.resolveWorkingDir(row.user_id) : undefined; + try { await invalidatePortalDirectChatSnapshot(row.agent_session_id ?? null); await appendEvent(runId, 'tool_gateway_dispatch', { protocol: toolGatewayStatus.protocol ?? 'agent-run-v1', taskType: runOptions.taskType, workingDir: workingDir ?? null, + executor: runOptions.requiredExecutor ?? null, }); const result = await toolGateway.executeJob({ runId, @@ -1685,6 +1831,9 @@ export function createAgentRunGateway({ return { sessionId: delivery.sessionId, routing }; } return { sessionId: row.agent_session_id ?? null, routing }; + } catch (err) { + if (!(await fallbackCursorExecutorToDeepseek(err))) throw err; + } } let sessionId = resolveGatewayAgentSessionId({ diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 823effa..b73c484 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -21,6 +21,15 @@ test('required code executor is read from run metadata', () => { }), 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', () => { assert.doesNotThrow(() => assertRequiredCodeExecutorAvailable('aider', { enabled: true, @@ -124,6 +133,21 @@ function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} } const [userId, requestId] = params; 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')")) { const [sessionId] = params; 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'); }); +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 () => { const pool = createFakePool(); 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'); }); +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 () => { const pool = createFakePool(); const gateway = createAgentRunGateway({ diff --git a/executor-display-label.mjs b/executor-display-label.mjs new file mode 100644 index 0000000..720f867 --- /dev/null +++ b/executor-display-label.mjs @@ -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 智趣'); +} diff --git a/executor-display-label.test.mjs b/executor-display-label.test.mjs new file mode 100644 index 0000000..b642bb9 --- /dev/null +++ b/executor-display-label.test.mjs @@ -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', + ); +}); diff --git a/tkmind-loading-tips.mjs b/tkmind-loading-tips.mjs new file mode 100644 index 0000000..d8a77a4 --- /dev/null +++ b/tkmind-loading-tips.mjs @@ -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]; +} diff --git a/tkmind-loading-tips.test.mjs b/tkmind-loading-tips.test.mjs new file mode 100644 index 0000000..340199a --- /dev/null +++ b/tkmind-loading-tips.test.mjs @@ -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); +}); diff --git a/tool-gateway.mjs b/tool-gateway.mjs index 74a66f4..364cc85 100644 --- a/tool-gateway.mjs +++ b/tool-gateway.mjs @@ -2,8 +2,22 @@ import { spawn as nodeSpawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; import fs from 'node:fs/promises'; 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; function envFlag(value, fallback = false) { @@ -18,9 +32,10 @@ function positiveInteger(value, fallback) { return Math.floor(n); } -function normalizeExecutor(value, fallback = 'aider') { +function normalizeExecutor(value, fallback = 'aider', env = process.env) { 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; } @@ -150,7 +165,12 @@ export function createToolGateway({ } = {}) { const enabled = envFlag(env.MEMIND_TOOL_GATEWAY_ENABLED, 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( env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES ?? 'repo_refactor,multi_file,complex_repo,page_data_dev_complex', @@ -162,8 +182,10 @@ export function createToolGateway({ enabled, dryRun, protocol: 'agent-run-v1', - executors: ['aider', 'openhands'], + executors: codeExecutorsForEnv(env), defaultExecutor, + cursorEnabled, + cursorTaskTypes: [...cursorTaskTypes], openhandsTaskTypes: [...openhandsTaskTypes], }; } @@ -171,9 +193,14 @@ export function createToolGateway({ function selectExecutor({ userMessage, taskType } = {}) { const metadata = userMessage?.metadata ?? {}; const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {}; - const requested = normalizeExecutor(runMetadata.executor, ''); + const requested = normalizeExecutor( + runMetadata.executor || runMetadata.requiredExecutor, + '', + env, + ); if (requested) return requested; const normalizedTaskType = String(taskType ?? runMetadata.taskType ?? '').trim().toLowerCase(); + if (cursorEnabled && cursorTaskTypes.has(normalizedTaskType)) return 'cursor'; if (openhandsTaskTypes.has(normalizedTaskType)) return 'openhands'; return defaultExecutor; } @@ -190,9 +217,6 @@ export function createToolGateway({ if (!enabled) { throw new Error('Tool Gateway is disabled'); } - if (!llmProviderService?.getExecutorLaunchPlan) { - throw new Error('Tool Gateway missing llm provider service'); - } const instruction = extractToolInstruction(userMessage); if (!instruction) { throw new Error('Tool Gateway job missing instruction'); @@ -204,13 +228,25 @@ export function createToolGateway({ 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; - let plan = await llmProviderService.getExecutorLaunchPlan(executor, { - cwd, - mode: 'headless', - instruction: executorInstruction, - purpose: 'default', - includeSecret: true, - }); + let plan; + if (executor === 'cursor') { + plan = buildCursorExecutorLaunchPlan({ + cwd, + instruction: executorInstruction, + 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) { throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`); } @@ -226,6 +262,7 @@ export function createToolGateway({ ok: true, dryRun: true, executor, + executorLabel: resolveExecutorDisplayLabel(executor, plan.executorLabel), protocol: 'agent-run-v1', cwd: plan.cwd, command: plan.command, @@ -274,6 +311,7 @@ export function createToolGateway({ resolve({ ok: true, executor, + executorLabel: resolveExecutorDisplayLabel(executor, plan.executorLabel), protocol: 'agent-run-v1', cwd: plan.cwd, command: plan.command, diff --git a/tool-gateway.test.mjs b/tool-gateway.test.mjs index 69ae44d..b94c4c8 100644 --- a/tool-gateway.test.mjs +++ b/tool-gateway.test.mjs @@ -32,10 +32,59 @@ test('tool gateway is disabled by default and reports protocol', () => { protocol: 'agent-run-v1', executors: ['aider', 'openhands'], 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'], }); }); +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', () => { const gateway = createToolGateway({ 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[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$/); +});