diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index bf89946..cbfaccd 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -230,6 +230,15 @@ function summarizeText(value, limit = TOOL_GATEWAY_SUMMARY_LIMIT) { return text.slice(text.length - limit); } +export function buildCodeRunCompletionReply(result) { + const executor = String(result?.executor ?? 'code executor').trim() || 'code executor'; + const output = summarizeText(result?.stdout, 2400).trim(); + return [ + `已由 ${executor} 完成执行,并通过平台文件验收。`, + output ? `\n${output}` : '', + ].join('').trim(); +} + function normalizeExpectedFileCheck(value) { if (typeof value === 'string') { const expectedPath = value.trim(); @@ -379,6 +388,12 @@ function getRunOptionsFromMessage(userMessage) { toolMode, taskType: normalizeTaskType(runMetadata?.taskType ?? metadata?.taskType), requiredExecutor: resolveRequiredCodeExecutor(userMessage), + reviewExecutor: ['aider', 'openhands'].includes( + String(runMetadata?.reviewExecutor ?? '').trim().toLowerCase(), + ) + ? String(runMetadata.reviewExecutor).trim().toLowerCase() + : null, + pageDataAiderWorkflow: runMetadata?.pageDataAiderWorkflow === true, forceDeepReasoning: runMetadata?.forceDeepReasoning === true || metadata?.forceDeepReasoning === true, validation: normalizeToolGatewayValidation(runMetadata?.validation ?? metadata?.toolGatewayValidation), sessionMessageCount: normalizeSessionMessageCount( @@ -387,6 +402,48 @@ function getRunOptionsFromMessage(userMessage) { }; } +export async function collectAiderReviewFiles(cwd, sinceMs = 0) { + if (!cwd) return []; + const root = path.resolve(String(cwd)); + const candidates = [ + { relativeDir: 'public', extensions: new Set(['.html', '.css', '.js']) }, + { relativeDir: '.mindspace/page-data-policies', extensions: new Set(['.json']) }, + ]; + const files = []; + async function walk(baseDir, relativeDir, extensions) { + let entries = []; + try { + entries = await fs.readdir(baseDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (files.length >= 40) return; + const absolute = path.join(baseDir, entry.name); + const relative = path.posix.join(relativeDir.split(path.sep).join('/'), entry.name); + if (entry.isDirectory()) { + await walk(absolute, relative, extensions); + continue; + } + if (!entry.isFile() || !extensions.has(path.extname(entry.name).toLowerCase())) continue; + try { + const stat = await fs.stat(absolute); + if (Number(stat.mtimeMs) + 5_000 >= Number(sinceMs || 0)) files.push(relative); + } catch { + // File disappeared between directory listing and stat. + } + } + } + for (const candidate of candidates) { + await walk( + path.join(root, candidate.relativeDir), + candidate.relativeDir, + candidate.extensions, + ); + } + return files; +} + function projectRun(row) { if (!row) return null; return { @@ -858,6 +915,89 @@ export function createAgentRunGateway({ } } + async function runRequiredCodeReview({ + row, + runId, + userMessage, + runOptions, + sessionId, + }) { + if (!runOptions.reviewExecutor) return; + const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null; + assertRequiredCodeExecutorAvailable(runOptions.reviewExecutor, toolGatewayStatus); + const workingDir = userAuth?.resolveWorkingDir + ? await userAuth.resolveWorkingDir(row.user_id) + : undefined; + const claimedRun = await getRunById(runId); + const contextFiles = await collectAiderReviewFiles( + workingDir, + claimedRun?.started_at ?? row.started_at ?? 0, + ); + const reviewMessage = { + ...userMessage, + content: [{ + type: 'text', + text: [ + '[Mandatory Aider Page Data review]', + `Original user request: ${extractRunDisplayText(row)}`, + `Session: ${sessionId}`, + 'Review the provided workspace files, fix concrete HTML/client-policy defects if needed,', + 'do not recreate PostgreSQL tables or datasets, and update the required validation receipt.', + 'Do not commit, push, publish, or modify files outside the current user workspace.', + contextFiles.length + ? `Review files:\n${contextFiles.map((item) => `- ${item}`).join('\n')}` + : 'No recent page files were detected; record that fact in the receipt so platform delivery validation can fail closed.', + `Receipt requestId: ${row.request_id}`, + ].join('\n'), + }], + metadata: { + ...(userMessage?.metadata ?? {}), + memindRun: { + ...(userMessage?.metadata?.memindRun ?? {}), + executor: runOptions.reviewExecutor, + aiderContextFiles: contextFiles, + }, + }, + }; + await appendEvent(runId, 'required_code_review_dispatch', { + executor: runOptions.reviewExecutor, + contextFiles, + }); + const result = await toolGateway.executeJob({ + runId, + userId: row.user_id, + requestId: row.request_id, + userMessage: reviewMessage, + taskType: 'page_data_dev', + cwd: workingDir, + timeoutMs: runTimeoutMs, + }); + if ( + String(result?.executor ?? '').trim().toLowerCase() !== runOptions.reviewExecutor + ) { + const error = new Error( + `审查执行器不匹配:要求 ${runOptions.reviewExecutor},实际 ${result?.executor ?? 'unknown'}`, + ); + error.code = 'REQUIRED_REVIEW_EXECUTOR_MISMATCH'; + error.retryable = false; + throw error; + } + await appendEvent(runId, 'required_code_review_result', { + executor: result.executor ?? null, + exitCode: result.exitCode ?? null, + stdoutTail: summarizeText(result.stdout), + stderrTail: summarizeText(result.stderr), + }); + const validation = await validateToolGatewayResult({ + result, + validation: runOptions.validation, + cwd: workingDir, + }); + if (validation) { + await appendEvent(runId, 'required_code_review_validation', validation); + } + } + async function resolveRunRouting(row, userMessage, runOptions) { if (!chatIntentRouter?.classify) return null; const enabled = chatIntentRouter.isEnabled @@ -880,7 +1020,13 @@ export function createAgentRunGateway({ let userMessage = safeJsonParse(row.user_message_json, {}); const runOptions = getRunOptionsFromMessage(userMessage); const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null; - assertRequiredCodeExecutorAvailable(runOptions.requiredExecutor, toolGatewayStatus); + assertRequiredCodeExecutorAvailable( + runOptions.requiredExecutor ?? runOptions.reviewExecutor, + toolGatewayStatus, + ); + const effectiveToolMode = runOptions.pageDataAiderWorkflow + ? 'chat' + : runOptions.toolMode; let disclosureDecision = null; try { disclosureDecision = systemDisclosurePolicyService?.evaluate?.({ @@ -1040,7 +1186,11 @@ export function createAgentRunGateway({ } } } - if (runOptions.toolMode === 'code' && toolGatewayStatus?.enabled) { + if ( + runOptions.toolMode === 'code' && + !runOptions.pageDataAiderWorkflow && + toolGatewayStatus?.enabled + ) { const workingDir = userAuth?.resolveWorkingDir ? await userAuth.resolveWorkingDir(row.user_id) : undefined; @@ -1094,6 +1244,38 @@ export function createAgentRunGateway({ }); throw err; } + if (runOptions.requiredExecutor) { + if (!directChatService?.respondDeterministically) { + const error = new Error('代码任务已执行,但结果回传服务不可用'); + error.code = 'CODE_RUN_RESULT_DELIVERY_UNAVAILABLE'; + error.retryable = false; + throw error; + } + const delivery = await directChatService.respondDeterministically({ + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + requestId: row.request_id, + userMessage, + reply: buildCodeRunCompletionReply(result), + metadata: { + source: 'tool-gateway-code-run', + executor: result.executor ?? null, + validated: true, + }, + onSessionReady: async (activeSessionId) => { + await pool.query( + `UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`, + [activeSessionId, nowMs(), runId], + ); + await appendRunSnapshot(runId); + }, + }); + await appendEvent(runId, 'tool_gateway_result_delivered', { + sessionId: delivery.sessionId, + executor: result.executor ?? null, + }); + return { sessionId: delivery.sessionId, routing }; + } return { sessionId: row.agent_session_id ?? null, routing }; } @@ -1112,7 +1294,7 @@ export function createAgentRunGateway({ } if (!sessionId) { const sessionOptions = {}; - if (runOptions.toolMode === 'code' && userAuth?.getCodeAgentSessionPolicy) { + if (effectiveToolMode === 'code' && userAuth?.getCodeAgentSessionPolicy) { sessionOptions.sessionPolicy = await userAuth.getCodeAgentSessionPolicy(row.user_id); } const session = await tkmindProxy.startSessionForUser(row.user_id, sessionOptions); @@ -1123,7 +1305,7 @@ export function createAgentRunGateway({ ); await appendEvent(runId, 'session_started', { sessionId, - toolMode: runOptions.toolMode, + toolMode: effectiveToolMode, taskType: runOptions.taskType, }); await appendRunSnapshot(runId); @@ -1165,8 +1347,17 @@ export function createAgentRunGateway({ await invalidatePortalDirectChatSnapshot(sessionId); let toolEvidence = null; const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true) - && runOptions.toolMode === 'chat' + && effectiveToolMode === 'chat' && typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function'; + if ( + runOptions.pageDataAiderWorkflow && + typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser !== 'function' + ) { + const error = new Error('Page Data + Aider 工作流需要等待 Page Data Agent 完成'); + error.code = 'PAGE_DATA_AIDER_FINISH_UNAVAILABLE'; + error.retryable = false; + throw error; + } if (awaitSessionFinish) { let submitMessage = ensureGooseUserMessageMetadata(userMessage); let replacedPoisonedSession = false; @@ -1178,7 +1369,7 @@ export function createAgentRunGateway({ row.request_id, submitMessage, { - toolMode: runOptions.toolMode, + toolMode: effectiveToolMode, forceDeepReasoning: runOptions.forceDeepReasoning, timeoutMs: runTimeoutMs, }, @@ -1244,11 +1435,18 @@ export function createAgentRunGateway({ row.request_id, ensureGooseUserMessageMetadata(userMessage), { - toolMode: runOptions.toolMode, + toolMode: effectiveToolMode, forceDeepReasoning: runOptions.forceDeepReasoning, }, ); } + await runRequiredCodeReview({ + row, + runId, + userMessage, + runOptions, + sessionId, + }); return { sessionId, routing, toolEvidence }; } diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 47d192a..3569e61 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -1088,6 +1088,114 @@ test('Page Data run succeeds only after a generated session page is detected', a await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); }); +test('Page Data plus Aider workflow builds with Agent and then performs mandatory Aider review', async () => { + const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-page-data-aider-')); + const pool = createFakePool({ + sessionDeliverables: { + 'user-1:session-order-system': [{ + page_id: 'page-order', + title: '下单系统', + publication_id: 'pub-order', + publication_status: 'online', + public_url: 'http://127.0.0.1:5173/u/john/pages/page-order', + workspace_relative_path: 'public/order.html', + }], + }, + }); + const reviewJobs = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: { + async resolveWorkingDir() { + return workdir; + }, + }, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-order-system' }; + }, + async submitSessionReplyAndAwaitFinishForUser(_userId, _sessionId, _requestId, _message, options) { + assert.equal(options.toolMode, 'chat'); + await fs.mkdir(path.join(workdir, 'public'), { recursive: true }); + await fs.writeFile( + path.join(workdir, 'public', 'order.html'), + '', + ); + return { + ok: true, + finishEvent: { type: 'Finish' }, + toolEvidence: { calls: ['private_data_execute', 'private_data_bind_workspace_page'] }, + }; + }, + }, + toolGateway: { + getStatus() { + return { + enabled: true, + protocol: 'agent-run-v1', + executors: ['aider', 'openhands'], + }; + }, + async executeJob(job) { + reviewJobs.push(job); + await fs.mkdir(path.join(workdir, '.memind', 'agent-runs'), { recursive: true }); + await fs.writeFile( + path.join(workdir, '.memind', 'agent-runs', 'req-page-data-aider.json'), + JSON.stringify({ requestId: 'req-page-data-aider', review: 'passed' }), + ); + return { + ok: true, + executor: 'aider', + exitCode: 0, + cwd: workdir, + stdout: 'Reviewed public/order.html and the Page Data client usage.', + }; + }, + }, + syncUserPagesOnSuccess: async () => ({ + pageDataBind: { errors: [] }, + pageDataRelativePaths: ['public/order.html'], + }), + validateRunDeliverables: async () => ({ errors: [] }), + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-page-data-aider', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '创建下单系统并在后台管理订单' }], + metadata: { + displayText: '创建下单系统并在后台管理订单', + memindRun: { + reviewExecutor: 'aider', + pageDataAiderWorkflow: true, + validation: { + expectedFile: { + path: '.memind/agent-runs/req-page-data-aider.json', + contains: 'req-page-data-aider', + }, + }, + }, + }, + }, + toolMode: 'chat', + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(reviewJobs.length, 1); + assert.equal(reviewJobs[0].taskType, 'page_data_dev'); + assert.deepEqual( + reviewJobs[0].userMessage.metadata.memindRun.aiderContextFiles, + ['public/order.html'], + ); + assert.ok( + pool.events.some( + (event) => event.runId === run.id && event.eventType === 'required_code_review_validation', + ), + ); +}); + test('agent run fails closed when a generated page violates browser storage policy', async () => { const pool = createFakePool({ sessionDeliverables: { @@ -2074,6 +2182,88 @@ test('agent run validates expected tool gateway artifacts before succeeding', as assert.equal(JSON.parse(validationEvent.dataJson).expectedFiles[0].path, 'RESULT.md'); }); +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-')); + const deliveries = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: { + async resolveWorkingDir() { + return workdir; + }, + }, + tkmindProxy: {}, + directChatService: { + async respondDeterministically(options) { + deliveries.push(options); + await options.onSessionReady('h5direct_aider_result'); + return { sessionId: 'h5direct_aider_result' }; + }, + }, + toolGateway: { + getStatus() { + return { + enabled: true, + protocol: 'agent-run-v1', + executors: ['aider', 'openhands'], + }; + }, + async executeJob() { + await fs.mkdir(path.join(workdir, '.memind', 'agent-runs'), { recursive: true }); + await fs.writeFile( + path.join(workdir, '.memind', 'agent-runs', 'req-aider-delivery.json'), + JSON.stringify({ requestId: 'req-aider-delivery', tests: 'passed' }), + ); + return { + ok: true, + dryRun: false, + executor: 'aider', + exitCode: 0, + cwd: workdir, + stdout: 'Implemented the requested page and ran its checks.', + stderr: '', + }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-aider-delivery', + userMessage: { + role: 'user', + content: [{ type: 'text', text: 'build the page' }], + metadata: { + displayText: 'build the page', + memindRun: { + executor: 'aider', + validation: { + expectedFile: { + path: '.memind/agent-runs/req-aider-delivery.json', + contains: 'req-aider-delivery', + }, + }, + }, + }, + }, + toolMode: 'code', + taskType: 'h5_chat_code_task', + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(pool.runs.get(run.id).agent_session_id, 'h5direct_aider_result'); + assert.equal(deliveries.length, 1); + assert.match(deliveries[0].reply, /Aider/i); + assert.match(deliveries[0].reply, /通过平台文件验收/); + assert.equal( + pool.events.some( + (event) => event.runId === run.id && event.eventType === 'tool_gateway_result_delivered', + ), + true, + ); +}); + test('agent run fails non-retryably when tool gateway artifact validation fails', async () => { const pool = createFakePool(); const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-tool-validation-missing-')); diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 6d98b60..509860d 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -1,5 +1,10 @@ import { normalizeAgentRunToolMode } from './agent-run-gateway.mjs'; -import { AIDER_DEVELOPMENT_SKILL_NAME } from './chat-skills.mjs'; +import { + AIDER_DEVELOPMENT_SKILL_NAME, + buildChatSkillPrompt, + extractAiderDevelopmentTask, + isPageDataIntent, +} from './chat-skills.mjs'; import { createSessionAccess } from './session-broker.mjs'; import { extractRunFromStreamEvent, @@ -58,6 +63,39 @@ function selectedChatSkill(userMessage) { return String(runMetadata.selectedChatSkill ?? '').trim(); } +function rewriteAiderPageDataInstruction(userMessage, taskText) { + const aiderPrompt = buildChatSkillPrompt( + AIDER_DEVELOPMENT_SKILL_NAME, + AIDER_DEVELOPMENT_SKILL_NAME, + ); + const pageDataPrompt = buildChatSkillPrompt('page-data-collect', 'page-data-collect'); + const compositePrompt = [ + pageDataPrompt, + taskText, + '\n\n[强制 Aider 审查]', + '先由当前 Agent 使用 private_data_* 和 Page Data 工具完成建表、dataset、页面与绑定。', + '完成后平台会强制调用 Aider 审查当前工作区产物;禁止省略该审查或声称 Aider 已执行。', + ].join(''); + const content = Array.isArray(userMessage?.content) + ? userMessage.content + .filter((item) => ( + item?.type !== 'text' || + !String(item.text ?? '').trim().startsWith('[Memind code-run validation]') + )) + .map((item) => { + if (item?.type !== 'text') return item; + const text = String(item.text ?? ''); + return { + ...item, + text: text.includes(aiderPrompt) + ? text.replace(`${aiderPrompt}${taskText}`, compositePrompt) + : text, + }; + }) + : userMessage?.content; + return { ...userMessage, content }; +} + export function enforceSelectedSkillRuntime(userMessage, { rawToolMode = 'chat', taskType = null, @@ -74,16 +112,24 @@ export function enforceSelectedSkillRuntime(userMessage, { const runMetadata = metadata.memindRun && typeof metadata.memindRun === 'object' && !Array.isArray(metadata.memindRun) ? { ...metadata.memindRun } : {}; + const taskText = extractAiderDevelopmentTask(userMessage); + const requiresPageDataBuild = isPageDataIntent(taskText); metadata.memindRun = { ...runMetadata, selectedChatSkill: AIDER_DEVELOPMENT_SKILL_NAME, - executor: 'aider', + ...(requiresPageDataBuild + ? { reviewExecutor: 'aider', pageDataAiderWorkflow: true } + : { executor: 'aider' }), }; + if (requiresPageDataBuild) delete metadata.memindRun.executor; return { - userMessage: { ...message, metadata }, - rawToolMode: 'code', - taskType: 'h5_chat_code_task', - requiredExecutor: 'aider', + userMessage: requiresPageDataBuild + ? rewriteAiderPageDataInstruction({ ...message, metadata }, taskText) + : { ...message, metadata }, + rawToolMode: requiresPageDataBuild ? 'chat' : 'code', + taskType: requiresPageDataBuild ? null : 'h5_chat_code_task', + requiredExecutor: requiresPageDataBuild ? null : 'aider', + requiredReviewExecutor: requiresPageDataBuild ? 'aider' : null, }; } @@ -142,7 +188,14 @@ export function createPostAgentRunsHandler({ rawToolMode = selectedSkillRuntime.rawToolMode; taskType = selectedSkillRuntime.taskType; if ( - selectedSkillRuntime.requiredExecutor && + (selectedSkillRuntime.requiredExecutor || selectedSkillRuntime.requiredReviewExecutor) && + !extractAiderDevelopmentTask(userMessage) + ) { + response.status(400).json({ message: '请输入需要 Aider 执行的具体开发任务' }); + return; + } + if ( + (selectedSkillRuntime.requiredExecutor || selectedSkillRuntime.requiredReviewExecutor) && userAuth?.getUserSkills ) { const skillState = await userAuth.getUserSkills(request.currentUser.id); @@ -160,8 +213,11 @@ export function createPostAgentRunsHandler({ }); return; } - if (toolMode === 'code') { + if (toolMode === 'code' || selectedSkillRuntime.requiredReviewExecutor) { const codeRunPolicy = await resolveCodeRunPolicy(request.currentUser.id); + const policyTaskType = selectedSkillRuntime.requiredReviewExecutor + ? 'h5_chat_code_task' + : taskType; if (!codeRunPolicy.enabled) { response.status(403).json({ message: '代码任务灰度未开启' }); return; @@ -173,7 +229,12 @@ export function createPostAgentRunsHandler({ const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? []; if ( taskTypeAllowlist.length > 0 && - (!taskType || !taskTypeAllowlist.map((item) => String(item).toLowerCase()).includes(taskType.toLowerCase())) + ( + !policyTaskType || + !taskTypeAllowlist + .map((item) => String(item).toLowerCase()) + .includes(policyTaskType.toLowerCase()) + ) ) { response.status(403).json({ message: '当前代码任务类型未开启灰度' }); return; diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index bf8f6d6..d82b164 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -158,6 +158,83 @@ test('selected Aider development skill forces code mode, task type, and Aider ex assert.equal(created[0].payload.userMessage.metadata.memindRun.executor, 'aider'); }); +test('selected Aider development skill rejects a template-only task before creating a run', async () => { + let created = false; + const handler = createPostAgentRunsHandler({ + userAuth: { + async getUserSkills() { + return { skills: { 'aider-development': true } }; + }, + }, + agentRunGateway: { + async createRun() { + created = true; + return { id: 'must-not-run' }; + }, + }, + codeRunsEnabled: true, + }); + const res = createResponseRecorder(); + const template = + '请使用 aider-development 技能:本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:'; + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + request_id: 'req-aider-empty', + user_message: { + role: 'user', + content: [{ type: 'text', text: template }], + metadata: { + displayText: template, + memindRun: { selectedChatSkill: 'aider-development' }, + }, + }, + }, + }, + res, + ); + + assert.equal(res.statusCode, 400); + assert.match(res.body.message, /具体开发任务/); + assert.equal(created, false); +}); + +test('Aider development routes Page Data creation through Agent then requires Aider review', () => { + const template = + '请使用 aider-development 技能:本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:'; + const task = '帮我设计一个简单下单系统,不要支付,可以有简单后台管理订单'; + const enforced = enforceSelectedSkillRuntime( + { + role: 'user', + content: [ + { type: 'text', text: `${template}${task}` }, + { + type: 'text', + text: '[Memind code-run validation]\nBefore finishing, create the receipt.', + }, + ], + metadata: { + displayText: `${template}${task}`, + memindRun: { selectedChatSkill: 'aider-development', executor: 'openhands' }, + }, + }, + { rawToolMode: 'code', taskType: 'h5_chat_code_task' }, + ); + assert.equal(enforced.rawToolMode, 'chat'); + assert.equal(enforced.taskType, null); + assert.equal(enforced.requiredExecutor, null); + assert.equal(enforced.requiredReviewExecutor, 'aider'); + assert.equal(enforced.userMessage.metadata.memindRun.executor, undefined); + assert.equal(enforced.userMessage.metadata.memindRun.reviewExecutor, 'aider'); + assert.equal(enforced.userMessage.metadata.memindRun.pageDataAiderWorkflow, true); + assert.match(enforced.userMessage.content[0].text, /private_data_execute/); + assert.match(enforced.userMessage.content[0].text, /强制 Aider 审查/); + assert.match(enforced.userMessage.content[0].text, /简单下单系统/); + assert.equal(enforced.userMessage.content.length, 1); +}); + test('selected Aider development skill fails closed when code runs are disabled', async () => { const handler = createPostAgentRunsHandler({ userAuth: { @@ -197,6 +274,50 @@ test('selected Aider development skill fails closed when code runs are disabled' assert.match(res.body.message, /代码任务灰度未开启/); }); +test('Page Data plus Aider review also respects the code-run policy gate', async () => { + const template = + '请使用 aider-development 技能:本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:'; + const task = '创建下单系统并在后台管理订单'; + const handler = createPostAgentRunsHandler({ + userAuth: { + async getUserSkills() { + return { skills: { 'aider-development': true } }; + }, + }, + agentRunGateway: { + async createRun() { + assert.fail('disabled review policy must not create a run'); + }, + }, + codeRunsEnabled: false, + }); + const res = createResponseRecorder(); + await handler( + { + currentUser: { id: 'user-1' }, + body: { + request_id: 'req-page-data-aider-disabled', + user_message: { + role: 'user', + content: [{ type: 'text', text: `${template}${task}` }], + metadata: { + displayText: `${template}${task}`, + memindRun: { + selectedChatSkill: 'aider-development', + validation: { + expectedFile: '.memind/agent-runs/req-page-data-aider-disabled.json', + }, + }, + }, + }, + }, + }, + res, + ); + assert.equal(res.statusCode, 403); + assert.match(res.body.message, /代码任务灰度未开启/); +}); + test('POST /agent/runs forwards deep reasoning flag to the run gateway', async () => { const created = []; const handler = createPostAgentRunsHandler({ diff --git a/capabilities.mjs b/capabilities.mjs index 8b0cbe3..1ecd6a4 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -162,6 +162,11 @@ export function resolveExcelMcpServerPath(overridePath, runtimeRoot) { ); } +function resolveBundledMcpRuntimeRoot(sandboxMcp) { + if (!sandboxMcp?.containerized || !sandboxMcp?.serverPath) return undefined; + return path.dirname(sandboxMcp.serverPath); +} + export const CAPABILITY_CATALOG = [ { key: 'shell', @@ -535,6 +540,7 @@ export function buildAgentExtensionPolicy( } const extensions = []; + const bundledMcpRuntimeRoot = resolveBundledMcpRuntimeRoot(sandboxMcp); if (capabilities.static_publish || (capabilities.private_data_space && sandboxMcp)) { const localRoot = resolveSandboxMcpLocalRoot(sandboxMcp); const compatRoot = resolveSandboxMcpCompatRoot(sandboxMcp); @@ -645,7 +651,12 @@ export function buildAgentExtensionPolicy( display_name: 'tkmind-search', bundled: false, cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH), - args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)], + args: [ + resolveMindSearchMcpServerPath( + process.env.TKMIND_SEARCH_MCP_SERVER_PATH, + bundledMcpRuntimeRoot, + ), + ], envs: { TKMIND_SEARCH_ENABLED: '1', TKMIND_SEARCH_MODE: mindSearchConfig.mode, @@ -687,7 +698,13 @@ export function buildAgentExtensionPolicy( display_name: 'Excel Analyst', bundled: false, cmd: resolveSandboxMcpNodeExecPath(sandboxMcp?.nodeExecPath), - args: [resolveExcelMcpServerPath(process.env.GOOSED_EXCEL_MCP_SERVER_PATH), excelWorkspaceRoot], + args: [ + resolveExcelMcpServerPath( + process.env.GOOSED_EXCEL_MCP_SERVER_PATH, + bundledMcpRuntimeRoot, + ), + excelWorkspaceRoot, + ], envs: { EXCEL_ANALYST_ENABLED: '1', MINDSPACE_WORKSPACE_ROOT: excelWorkspaceRoot, diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 94ba67d..d9e65e6 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -503,3 +503,21 @@ test('sandboxMcp can use workspaceRoot as the local runtime root compatibility f assert.equal(sandboxExt.envs.MINDSPACE_WORKSPACE_ROOT, '/opt/h5/MindSpace/abc123'); assert.equal(sandboxExt.envs.MINDSPACE_WORKSPACE_REF, 'mindspace://users/abc123/workspace'); }); + +test('Excel analyst uses the container-visible bundled MCP directory', () => { + const policy = buildAgentExtensionPolicy( + { + ...DEFAULT_USER_CAPABILITIES, + excel_analysis: true, + }, + { + sandboxMcp: { + containerized: true, + serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs', + sandboxRoot: '/opt/portal/MindSpace/user-1', + }, + }, + ); + const extension = policy.extensionOverrides.find((ext) => ext.name === 'tkmind-excel'); + assert.equal(extension.args[0], '/opt/portal/tkmind-excel-mcp.mjs'); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index cb303d9..1b2198f 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -52,6 +52,8 @@ const PAGE_DATA_INTENT_PATTERNS = [ /(?:页面|网页|H5|h5).{0,80}(?:每天|每日|新增|添加|填写|记录).{0,80}(?:所有记录|历史记录|管理|汇总|统计)/u, /(?:页面|网页|商城|店铺).{0,80}(?:下单|订单|购物车).{0,80}(?:后台|管理|上架|商品|产品|库存)/u, /(?:后台|管理).{0,80}(?:上架|下架|商品|产品|库存).{0,80}(?:下单|订单|购物车|页面|网页|商城|店铺)/u, + /(?:下单|订单).{0,40}(?:后台|管理|记录|保存|查询|状态)/u, + /(?:后台|管理).{0,40}(?:下单|订单)/u, ]; const INTERACTIVE_PAGE_DATA_SUBJECT_PATTERN = /(?:便签|便利贴|备忘录|待办|清单)/u; @@ -180,6 +182,7 @@ export const CHAT_SKILL_DEFINITIONS = [ icon: 'spark', skillName: AIDER_DEVELOPMENT_SKILL_NAME, requiresSkill: AIDER_DEVELOPMENT_SKILL_NAME, + prefillOnly: true, promptKey: AIDER_DEVELOPMENT_SKILL_NAME, }, { @@ -298,6 +301,33 @@ export function buildChatSkillPrompt(promptKey, skillName) { } } +export function mergeChatSkillPromptWithInput(prompt, currentInput) { + const normalizedPrompt = String(prompt ?? ''); + const existing = String(currentInput ?? '').trim(); + if (!existing) return normalizedPrompt; + if (existing.startsWith(normalizedPrompt)) return existing; + return `${normalizedPrompt}${existing}`; +} + +export function extractAiderDevelopmentTask(userMessage) { + const metadata = userMessage?.metadata; + const displayText = String(metadata?.displayText ?? '').trim(); + const contentText = Array.isArray(userMessage?.content) + ? userMessage.content + .filter((item) => item?.type === 'text') + .map((item) => String(item.text ?? '').trim()) + .filter(Boolean) + .join('\n') + : String(userMessage?.content ?? userMessage?.text ?? userMessage?.value ?? '').trim(); + const source = displayText || contentText; + const prompt = buildChatSkillPrompt( + AIDER_DEVELOPMENT_SKILL_NAME, + AIDER_DEVELOPMENT_SKILL_NAME, + ); + if (!source.startsWith(prompt)) return source; + return source.slice(prompt.length).trim(); +} + function buildWebNewsSkillPrompt(skillName) { return `请使用 ${skillName ?? 'web'} 技能:先搜索今天/最新相关的新闻与热点,优先一手来源和权威媒体,整理 3-5 条最相关结果,按时间或热度排序;然后给出中文摘要、关键信息、事件背景和来源链接。我的问题是:`; } diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index d545403..7368487 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -5,11 +5,13 @@ import { buildChatSkillPrompt, CHAT_SKILL_DEFINITIONS, filterChatSkills, + extractAiderDevelopmentTask, isExcelAnalysisIntent, isPageDataDevIntent, isPageDataIntent, isPageGenerationIntent, isGenericPageGenerationRequest, + mergeChatSkillPromptWithInput, } from './chat-skills.mjs'; test('filterChatSkills shows summarize and analyze without granted skills', () => { @@ -47,6 +49,36 @@ test('filterChatSkills shows service integration smoke when granted', () => { assert.ok(visible.some((item) => item.id === 'service-integration-smoke')); }); +test('Aider development skill prefills instead of submitting an empty template', () => { + const aider = CHAT_SKILL_DEFINITIONS.find((item) => item.id === 'aider-development'); + assert.equal(aider?.prefillOnly, true); +}); + +test('mergeChatSkillPromptWithInput preserves an existing user task', () => { + const prompt = buildChatSkillPrompt('aider-development', 'aider-development'); + const task = '帮我设计一个简单下单系统,不要支付,可以有简单后台'; + assert.equal(mergeChatSkillPromptWithInput(prompt, task), `${prompt}${task}`); + assert.equal(mergeChatSkillPromptWithInput(prompt, `${prompt}${task}`), `${prompt}${task}`); +}); + +test('extractAiderDevelopmentTask rejects the template-only submission', () => { + const prompt = buildChatSkillPrompt('aider-development', 'aider-development'); + assert.equal( + extractAiderDevelopmentTask({ + metadata: { displayText: prompt }, + content: [{ type: 'text', text: prompt }], + }), + '', + ); + assert.equal( + extractAiderDevelopmentTask({ + metadata: { displayText: `${prompt}修复页面` }, + content: [{ type: 'text', text: `${prompt}修复页面` }], + }), + '修复页面', + ); +}); + test('filterChatSkills only shows Aider development when granted', () => { const hidden = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: false, @@ -176,6 +208,13 @@ test('isPageDataIntent treats storefront ordering plus product administration as ); }); +test('isPageDataIntent recognizes order systems with an admin backend', () => { + assert.equal( + isPageDataIntent('帮我设计一个简单下单系统,不要支付,可以有简单后台管理订单'), + true, + ); +}); + test('isPageDataIntent matches implicit interactive sticky-note app requests', () => { const text = '帮我设计一个便签提醒,可以写便签提交,时间轴来显示'; assert.equal(isPageDataIntent(text), true); diff --git a/mindsearch.test.mjs b/mindsearch.test.mjs index 3f20559..daa1328 100644 --- a/mindsearch.test.mjs +++ b/mindsearch.test.mjs @@ -144,6 +144,28 @@ test('MindSearch never changes legacy web extension and is gated by capability/c assert.ok(extension.available_tools.includes('tkmind_research_cancel')); }); +test('MindSearch uses the container-visible bundled MCP directory', () => { + const policy = buildAgentExtensionPolicy( + { + ...DEFAULT_USER_CAPABILITIES, + search_external: true, + }, + { + sandboxMcp: { + containerized: true, + serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs', + }, + mindSearchConfig: { + enabled: true, + mode: 'assist', + providers: {}, + }, + }, + ); + const extension = policy.extensionOverrides.find((ext) => ext.name === 'tkmind-search'); + assert.equal(extension.args[0], '/opt/portal/tkmind-search-mcp.mjs'); +}); + test('SearXNG adapter normalizes provider results without requiring a live network', async () => { const result = await searchSearxng('goose', { endpoint: 'http://search.local', fetchImpl: async () => ({ ok: true, json: async () => ({ results: [{ title: 'Goose', url: 'https://example.com', content: 'snippet' }] }) }) }); assert.deepEqual(result[0], { title: 'Goose', url: 'https://example.com', snippet: 'snippet', source: 'searxng', rank: 1 }); diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index a348d15..2f3ec63 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -2,7 +2,11 @@ import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react'; import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { openAvatarPicker } from '../utils/userAvatar'; -import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; +import { + CHAT_SKILL_OPTIONS, + filterChatSkills, + mergeChatSkillPromptWithInput, +} from '../utils/chatSkills'; import { getMessageSaveActions } from '../utils/messageSave'; import { getDisplayText } from '../utils/message'; import { @@ -1242,7 +1246,7 @@ export function ChatPanel({ onSelect={submitText} onPrefill={(prompt, skillId) => { pendingSkillRef.current = skillId ?? null; - setInput(prompt); + setInput((current) => mergeChatSkillPromptWithInput(prompt, current)); }} /> )} diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 8dbab7e..926a1f1 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -117,6 +117,7 @@ async function waitForAgentRun(runId: string): Promise { } const DIRECT_CHAT_SESSION_POLL_MS = 600; +const AGENT_RUN_STATUS_POLL_MS = 1_500; const AGENT_RUN_WAIT_TIMEOUT_MS = 16 * 60 * 1000; async function waitForAgentRunWithDirectChatPreview( @@ -129,6 +130,8 @@ async function waitForAgentRunWithDirectChatPreview( ): Promise { return await new Promise((resolve, reject) => { let pollTimer: number | null = null; + let runStatusPollTimer: number | null = null; + let runStatusPollInFlight = false; let waitTimer: number | null = null; let pollingSessionId: string | null = null; let settled = false; @@ -138,6 +141,7 @@ async function waitForAgentRunWithDirectChatPreview( if (settled) return; settled = true; stopPoll(); + stopRunStatusPoll(); if (waitTimer != null) { window.clearTimeout(waitTimer); waitTimer = null; @@ -153,6 +157,13 @@ async function waitForAgentRunWithDirectChatPreview( } }; + const stopRunStatusPoll = () => { + if (runStatusPollTimer != null) { + window.clearInterval(runStatusPollTimer); + runStatusPollTimer = null; + } + }; + const startPolling = (sessionId: string) => { if (pollingSessionId === sessionId && pollTimer != null) return; pollingSessionId = sessionId; @@ -175,9 +186,7 @@ async function waitForAgentRunWithDirectChatPreview( }, DIRECT_CHAT_SESSION_POLL_MS); }; - unsubscribe = subscribeAgentRunEvents( - runId, - (run) => { + const handleRunStatus = (run: AgentRun) => { if (run.sessionId) { handlers.onSessionId?.(run.sessionId); if (isDirectChatSessionId(run.sessionId)) { @@ -191,11 +200,32 @@ async function waitForAgentRunWithDirectChatPreview( if (run.status === 'failed') { settle(() => reject(new Error(run.error || '后台任务失败,请稍后重试'))); } - }, + }; + + const pollRunStatus = () => { + if (settled || runStatusPollInFlight || handlers.isCancelled?.()) return; + runStatusPollInFlight = true; + void getAgentRun(runId) + .then(handleRunStatus) + .catch(() => { + // SSE remains primary; polling only closes terminal-state gaps. + }) + .finally(() => { + runStatusPollInFlight = false; + }); + }; + + unsubscribe = subscribeAgentRunEvents( + runId, + handleRunStatus, (error) => { - settle(() => reject(error)); + void getAgentRun(runId) + .then(handleRunStatus) + .catch(() => settle(() => reject(error))); }, ); + runStatusPollTimer = window.setInterval(pollRunStatus, AGENT_RUN_STATUS_POLL_MS); + pollRunStatus(); waitTimer = window.setTimeout(() => { void getAgentRun(runId) diff --git a/src/utils/agentRunMode.ts b/src/utils/agentRunMode.ts index ac431a5..cb719e1 100644 --- a/src/utils/agentRunMode.ts +++ b/src/utils/agentRunMode.ts @@ -291,6 +291,7 @@ export function resolveAgentRunOptions( { taskType = 'code_task', forceCode = false, + requiredExecutor, allowAutodetect = clientCodeRunsAutodetectEnabled(), allowPageDataDevAutodetect = clientPageDataDevAutodetectEnabled(), userId = null, @@ -310,8 +311,9 @@ export function resolveAgentRunOptions( } = {}, ): AgentRunCreateOptions { const normalizedText = String(text ?? '').trim(); - const pageDataDevTaskType = - allowPageDataDevAutodetect && resolvePageDataDevTaskType(normalizedText); + const pageDataDevTaskType = allowPageDataDevAutodetect + ? resolvePageDataDevTaskType(normalizedText) + : null; const effectiveTaskType = pageDataDevTaskType ?? taskType; const shouldUseDeepReasoning = forceCode || diff --git a/src/utils/chatSkills.ts b/src/utils/chatSkills.ts index 10ff7b0..dff5ac9 100644 --- a/src/utils/chatSkills.ts +++ b/src/utils/chatSkills.ts @@ -2,6 +2,7 @@ import { buildChatSkillPrompt, CHAT_SKILL_DEFINITIONS, filterChatSkills as filterChatSkillDefinitions, + mergeChatSkillPromptWithInput, } from '../../chat-skills.mjs'; import { buildPublishSkillPrompt, PUBLISH_SKILL_NAME } from './publishSkill'; @@ -45,3 +46,4 @@ export function filterChatSkills( } export { buildPublishSkillPrompt, PUBLISH_SKILL_NAME }; +export { mergeChatSkillPromptWithInput }; diff --git a/tool-gateway.mjs b/tool-gateway.mjs index 38bca68..74a66f4 100644 --- a/tool-gateway.mjs +++ b/tool-gateway.mjs @@ -1,5 +1,7 @@ import { spawn as nodeSpawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; +import fs from 'node:fs/promises'; +import path from 'node:path'; const CODE_EXECUTORS = new Set(['aider', 'openhands']); const DEFAULT_STDIO_LIMIT = 64 * 1024; @@ -55,6 +57,92 @@ export function extractToolInstruction(userMessage) { .trim(); } +function runValidation(userMessage) { + const metadata = userMessage?.metadata ?? {}; + const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {}; + const validation = runMetadata.validation ?? metadata.toolGatewayValidation; + return validation && typeof validation === 'object' && !Array.isArray(validation) + ? validation + : null; +} + +function validationFilePaths(userMessage) { + const validation = runValidation(userMessage); + if (!validation) return []; + const candidates = [ + validation.expectedFile ?? validation.expectedPath, + ...(Array.isArray(validation.expectedFiles) ? validation.expectedFiles : []), + ]; + return candidates + .map((item) => { + if (typeof item === 'string') return item.trim(); + if (!item || typeof item !== 'object' || Array.isArray(item)) return ''; + return String(item.path ?? item.file ?? item.relativePath ?? '').trim(); + }) + .filter(Boolean); +} + +export function resolveAiderReceiptPath(userMessage, requestId) { + const expected = `.memind/agent-runs/${String(requestId ?? '').trim()}.json`; + return validationFilePaths(userMessage).find((item) => item === expected) ?? null; +} + +export async function prepareAiderReceiptFile(cwd, relativePath) { + if (!cwd || !relativePath) return null; + const root = path.resolve(String(cwd)); + const target = path.resolve(root, relativePath); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error(`Aider receipt path escapes working directory: ${relativePath}`); + } + await fs.mkdir(path.dirname(target), { recursive: true }); + try { + await fs.access(target); + } catch { + await fs.writeFile( + target, + `${JSON.stringify({ status: 'pending', executor: 'aider' }, null, 2)}\n`, + { flag: 'wx' }, + ); + } + return target; +} + +async function resolveAiderContextFiles(userMessage, cwd) { + const metadata = userMessage?.metadata ?? {}; + const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {}; + const candidates = Array.isArray(runMetadata.aiderContextFiles) + ? runMetadata.aiderContextFiles + : []; + if (!cwd || candidates.length === 0) return []; + const root = path.resolve(String(cwd)); + const resolved = []; + for (const relativePath of candidates.slice(0, 40)) { + const target = path.resolve(root, String(relativePath ?? '')); + if (target === root || !target.startsWith(`${root}${path.sep}`)) continue; + try { + const stat = await fs.stat(target); + if (stat.isFile()) resolved.push(target); + } catch { + // Review context is best-effort; delivery validation remains authoritative. + } + } + return resolved; +} + +export function hardenAiderLaunchPlan(plan, receiptPath = null, contextFiles = []) { + const args = [...(plan?.args ?? [])]; + for (const flag of ['--no-git', '--no-auto-commits', '--no-dirty-commits']) { + if (!args.includes(flag)) args.push(flag); + } + for (const contextFile of contextFiles) { + if (!args.includes(contextFile)) args.push('--file', contextFile); + } + if (receiptPath && !args.includes(receiptPath)) { + args.push('--file', receiptPath); + } + return { ...plan, args }; +} + export function createToolGateway({ llmProviderService, env = process.env, @@ -110,16 +198,29 @@ export function createToolGateway({ throw new Error('Tool Gateway job missing instruction'); } const executor = selectExecutor({ userMessage, taskType }); - const plan = await llmProviderService.getExecutorLaunchPlan(executor, { + const receiptPath = executor === 'aider' + ? resolveAiderReceiptPath(userMessage, requestId) + : null; + 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, + instruction: executorInstruction, purpose: 'default', includeSecret: true, }); if (!plan?.ok) { throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`); } + if (executor === 'aider') { + const preparedReceipt = dryRun + ? (receiptPath ? path.resolve(String(cwd), receiptPath) : null) + : await prepareAiderReceiptFile(cwd, receiptPath); + const contextFiles = await resolveAiderContextFiles(userMessage, cwd); + plan = hardenAiderLaunchPlan(plan, preparedReceipt, contextFiles); + } if (dryRun) { return { ok: true, diff --git a/tool-gateway.test.mjs b/tool-gateway.test.mjs index 8766afd..69ae44d 100644 --- a/tool-gateway.test.mjs +++ b/tool-gateway.test.mjs @@ -1,6 +1,15 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; -import { createToolGateway, extractToolInstruction } from './tool-gateway.mjs'; +import { + createToolGateway, + extractToolInstruction, + hardenAiderLaunchPlan, + prepareAiderReceiptFile, + resolveAiderReceiptPath, +} from './tool-gateway.mjs'; test('tool gateway extracts text instructions from H5 message content', () => { assert.equal( @@ -58,6 +67,46 @@ test('tool gateway honors an explicitly required Aider executor over task defaul }), 'aider'); }); +test('tool gateway resolves and prepares the required Aider receipt without pre-validating it', async () => { + const requestId = 'req-receipt'; + const relativePath = `.memind/agent-runs/${requestId}.json`; + const userMessage = { + metadata: { + memindRun: { + validation: { + expectedFile: { path: relativePath, contains: requestId }, + }, + }, + }, + }; + assert.equal(resolveAiderReceiptPath(userMessage, requestId), relativePath); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-aider-receipt-')); + try { + const target = await prepareAiderReceiptFile(cwd, relativePath); + const pending = JSON.parse(await fs.readFile(target, 'utf8')); + assert.deepEqual(pending, { status: 'pending', executor: 'aider' }); + assert.equal((await fs.readFile(target, 'utf8')).includes(requestId), false); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } +}); + +test('tool gateway hardens Aider against repository commits and includes the receipt', () => { + const plan = hardenAiderLaunchPlan( + { ok: true, args: ['--model', 'test-model'] }, + '/tmp/work/.memind/agent-runs/req.json', + ['/tmp/work/public/order.html'], + ); + assert.ok(plan.args.includes('--no-git')); + assert.ok(plan.args.includes('--no-auto-commits')); + assert.ok(plan.args.includes('--no-dirty-commits')); + assert.deepEqual( + plan.args.slice(-2), + ['--file', '/tmp/work/.memind/agent-runs/req.json'], + ); + assert.ok(plan.args.includes('/tmp/work/public/order.html')); +}); + test('tool gateway dry run builds executor launch plan without spawning', async () => { const plans = []; const gateway = createToolGateway({