d67ebb0c77
Memind CI / Test, build, and release guards (pull_request) Failing after 17s
Gate stacks inherited developer MCP env and routed sandbox-fs writes to the 8082 workspace root, so PAGE/DATA scenarios failed with ENOENT despite a healthy goosed. Sanitize isolated portal env, grant gate skills, and align CHAT search assertions with the tkmind_search tool name. Co-authored-by: Cursor <cursoragent@cursor.com>
229 lines
7.6 KiB
JavaScript
229 lines
7.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Direct goosed page smoke on TKMIND_API_TARGET (default https://127.0.0.1:18006).
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { Readable } from 'node:stream';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { Agent, fetch } from 'undici';
|
|
|
|
import { buildChatSkillPrompt } from '../chat-skills.mjs';
|
|
|
|
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
function loadEnvFile(filePath) {
|
|
if (!fs.existsSync(filePath)) return;
|
|
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const eq = trimmed.indexOf('=');
|
|
if (eq < 0) continue;
|
|
const key = trimmed.slice(0, eq).trim();
|
|
const value = trimmed.slice(eq + 1).trim();
|
|
if (!process.env[key]) process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
loadEnvFile(path.join(root, '.env'));
|
|
|
|
const secret = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
|
const base = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006';
|
|
const workingDir = path.resolve(
|
|
process.argv[2] ?? path.join(root, '.release-gate/local/goosed-direct-page'),
|
|
);
|
|
const targetHtml = process.argv[3] ?? 'public/suzhou-goosed-direct.html';
|
|
const provider = process.argv[4] ?? process.env.GOOSED_PAGE_TEST_PROVIDER ?? 'custom_tkmind_relay_deepseek';
|
|
const model = process.argv[5] ?? process.env.GOOSED_PAGE_TEST_MODEL ?? 'deepseek-chat';
|
|
const timeoutMs = Number(process.env.GOOSED_PAGE_TEST_TIMEOUT_MS ?? 600_000);
|
|
|
|
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
|
|
|
async function apiFetch(pathname, init = {}) {
|
|
const headers = {
|
|
...(init.headers ?? {}),
|
|
'X-Secret-Key': secret,
|
|
};
|
|
if (init.body && !headers['Content-Type']) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
return fetch(`${base}${pathname}`, {
|
|
...init,
|
|
headers,
|
|
dispatcher,
|
|
});
|
|
}
|
|
|
|
async function apiJson(pathname, body) {
|
|
const response = await apiFetch(pathname, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) throw new Error(`${pathname} ${response.status}: ${text.slice(0, 800)}`);
|
|
if (!text.trim()) return {};
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
function messageVisibleText(message) {
|
|
return (message?.content ?? [])
|
|
.filter((item) => item?.type === 'text')
|
|
.map((item) => String(item.text ?? ''))
|
|
.join('\n')
|
|
.trim();
|
|
}
|
|
|
|
async function executeSessionReply(sessionId, requestId, prompt) {
|
|
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
|
method: 'GET',
|
|
headers: { Accept: 'text/event-stream' },
|
|
});
|
|
if (!eventsResponse.ok || !eventsResponse.body) {
|
|
const text = await eventsResponse.text().catch(() => '');
|
|
throw new Error(text || '无法建立 goosed 事件流');
|
|
}
|
|
|
|
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
request_id: requestId,
|
|
user_message: {
|
|
role: 'user',
|
|
created: Date.now(),
|
|
content: [{ type: 'text', text: prompt }],
|
|
metadata: { userVisible: true, agentVisible: true, displayText: prompt },
|
|
},
|
|
}),
|
|
});
|
|
if (!replyResponse.ok) {
|
|
const text = await replyResponse.text().catch(() => '');
|
|
throw new Error(text || 'reply 失败');
|
|
}
|
|
replyResponse.body?.cancel?.();
|
|
|
|
const reader = Readable.fromWeb(eventsResponse.body);
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let messages = [];
|
|
let finishSeen = false;
|
|
let errorText = '';
|
|
|
|
const pushMessage = (list, message) => {
|
|
const index = list.findIndex((item) => item.id === message.id);
|
|
if (index >= 0) {
|
|
const next = [...list];
|
|
next[index] = message;
|
|
return next;
|
|
}
|
|
return [...list, message];
|
|
};
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
for await (const chunk of reader) {
|
|
if (Date.now() > deadline) throw new Error(`goosed 事件流超时 ${timeoutMs}ms`);
|
|
buffer += decoder.decode(chunk, { stream: true });
|
|
const frames = buffer.split('\n\n');
|
|
buffer = frames.pop() ?? '';
|
|
for (const frame of frames) {
|
|
let data = '';
|
|
for (const line of frame.split('\n')) {
|
|
if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
}
|
|
if (!data) continue;
|
|
let event;
|
|
try {
|
|
event = JSON.parse(data);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const routingId = event.chat_request_id ?? event.request_id;
|
|
if (routingId && routingId !== requestId) continue;
|
|
|
|
if (event.type === 'Message' && event.message?.metadata?.userVisible !== false) {
|
|
messages = pushMessage(messages, event.message);
|
|
} else if (event.type === 'UpdateConversation') {
|
|
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible !== false);
|
|
} else if (event.type === 'Error') {
|
|
errorText = String(event.error ?? event.message ?? 'goose 执行失败');
|
|
throw new Error(errorText);
|
|
} else if (event.type === 'Finish') {
|
|
finishSeen = true;
|
|
break;
|
|
}
|
|
}
|
|
if (finishSeen) break;
|
|
}
|
|
|
|
const assistantTexts = messages
|
|
.filter((item) => item.role === 'assistant')
|
|
.map((item) => messageVisibleText(item))
|
|
.filter(Boolean);
|
|
return {
|
|
finishSeen,
|
|
combined: assistantTexts.join('\n\n').trim(),
|
|
toolCalls: messages.flatMap((item) => (item.content ?? [])
|
|
.filter((part) => part?.type === 'toolRequest' || part?.type === 'toolResponse')
|
|
.map((part) => ({
|
|
role: item.role,
|
|
type: part.type,
|
|
name: part.toolCall?.value?.name ?? part.toolResponse?.value?.name ?? part.name ?? null,
|
|
}))),
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
fs.mkdirSync(path.join(workingDir, 'public'), { recursive: true });
|
|
console.log(`goosed base: ${base}`);
|
|
console.log(`working_dir: ${workingDir}`);
|
|
console.log(`target: ${targetHtml}`);
|
|
console.log(`provider: ${provider} / ${model}`);
|
|
|
|
const start = await apiJson('/agent/start', { working_dir: workingDir });
|
|
const sessionId = start.id;
|
|
console.log(`session: ${sessionId}`);
|
|
|
|
await apiJson('/agent/update_provider', {
|
|
session_id: sessionId,
|
|
provider,
|
|
model,
|
|
});
|
|
|
|
const skillPrefix = buildChatSkillPrompt('generate-page', 'static-page-publish');
|
|
const userText = `${skillPrefix}请帮我做一个全新的苏州一日游攻略页面,保存为 ${targetHtml},不要修改或复用已有页面,做完直接给我链接。`;
|
|
const requestId = crypto.randomUUID();
|
|
|
|
const reply = await executeSessionReply(sessionId, requestId, userText);
|
|
const htmlPath = path.join(workingDir, targetHtml);
|
|
const htmlExists = fs.existsSync(htmlPath);
|
|
const htmlSize = htmlExists ? fs.statSync(htmlPath).size : 0;
|
|
|
|
console.log('\n=== result ===');
|
|
console.log(`finish_seen: ${reply.finishSeen}`);
|
|
console.log(`html_exists: ${htmlExists}`);
|
|
console.log(`html_bytes: ${htmlSize}`);
|
|
console.log(`tool_calls: ${reply.toolCalls.length}`);
|
|
for (const call of reply.toolCalls.slice(-12)) {
|
|
console.log(` - ${call.role} ${call.type} ${call.name ?? ''}`);
|
|
}
|
|
if (reply.combined) {
|
|
console.log('\n--- assistant excerpt ---');
|
|
console.log(reply.combined.slice(-1500));
|
|
}
|
|
|
|
if (!htmlExists || htmlSize <= 0) {
|
|
console.error('\nFAIL: goosed did not materialize target HTML in working_dir');
|
|
process.exit(1);
|
|
}
|
|
const html = fs.readFileSync(htmlPath, 'utf8');
|
|
if (!/苏州/u.test(html)) {
|
|
console.error('\nFAIL: generated HTML missing expected keyword 苏州');
|
|
process.exit(1);
|
|
}
|
|
console.log('\nPASS: goosed wrote deliverable HTML on 18006');
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
|
process.exit(1);
|
|
});
|