#!/usr/bin/env node "use strict"; const http = require("node:http"); const { spawn } = require("node:child_process"); const OPENHUB_BACKEND_DIR = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend"; const OPENCODE_PORT = Number(process.env.TASK793_OPENCODE_PORT || 19096); const OPENHUB_PORT = Number(process.env.TASK793_OPENHUB_PORT || 18181); const SESSION_ID = "ses_mnote_context_fullchain"; function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function readBody(req) { return new Promise((resolve) => { let data = ""; req.on("data", (chunk) => { data += chunk; }); req.on("end", () => resolve(data)); }); } function sseEvent(type, properties) { return `data: ${JSON.stringify({ payload: { type, properties } })}\n\n`; } function startFakeOpencode() { const capturedPrompts = []; let eventResponse = null; function sendEvents() { if (!eventResponse) return false; eventResponse.write(sseEvent("message.updated", { sessionID: SESSION_ID, info: { id: "msg_assistant", role: "assistant", sessionID: SESSION_ID }, })); eventResponse.write(sseEvent("message.part.updated", { sessionID: SESSION_ID, part: { id: "prt_text", messageID: "msg_assistant", sessionID: SESSION_ID, type: "text", text: "OK" }, })); eventResponse.write(sseEvent("session.status", { sessionID: SESSION_ID, status: { type: "idle" }, })); eventResponse.end(); eventResponse = null; return true; } const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://127.0.0.1:${OPENCODE_PORT}`); if (req.method === "GET" && url.pathname === "/global/health") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); return; } if (req.method === "POST" && url.pathname === "/session") { await readBody(req); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ id: SESSION_ID })); return; } if (req.method === "GET" && url.pathname === "/global/event") { res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive", }); eventResponse = res; eventResponse.write(`data: ${JSON.stringify({ payload: { type: "ready", properties: { sessionID: SESSION_ID } } })}\n\n`); if (capturedPrompts.length) { setTimeout(sendEvents, 50); } return; } if (req.method === "POST" && url.pathname === `/session/${SESSION_ID}/prompt_async`) { const body = JSON.parse(await readBody(req) || "{}"); capturedPrompts.push(body); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); setTimeout(sendEvents, 50); return; } res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "not_found", path: url.pathname })); }); return { capturedPrompts, listen: () => new Promise((resolve) => server.listen(OPENCODE_PORT, "127.0.0.1", resolve)), close: () => new Promise((resolve) => server.close(resolve)), }; } async function waitForHealth(url, timeoutMs = 20_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const response = await fetch(url, { signal: AbortSignal.timeout(2_000) }); if (response.status < 500) return; } catch {} await wait(250); } throw new Error(`等待服务超时: ${url}`); } async function sendOpenHub(payload, extraHeaders = {}) { const body = JSON.stringify(payload); return new Promise((resolve, reject) => { const req = http.request({ hostname: "127.0.0.1", port: OPENHUB_PORT, path: "/api/query/stream", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body), "x-mnote-user-key": "openhub_user_test", "x-mnote-workspace-key": "workspace_test", "x-mnote-session-scope": `session_${Date.now()}_${Math.random().toString(16).slice(2)}`, "x-mnote-root-uri": "file:///tmp/mnote-fullchain-workspace", "x-mnote-page-resource-id": "local-md:Inbox~2FPage.md", ...extraHeaders, }, }, (res) => { let text = ""; const timeout = setTimeout(() => { req.destroy(); resolve(text); }, 10_000); res.setEncoding("utf8"); res.on("data", (chunk) => { text += chunk; if (text.includes("message_complete")) { clearTimeout(timeout); req.destroy(); resolve(text); } }); res.on("end", () => { clearTimeout(timeout); if (res.statusCode < 200 || res.statusCode >= 300) { reject(new Error(`OpenHub HTTP ${res.statusCode}: ${text.slice(0, 500)}`)); } else { resolve(text); } }); }); req.on("error", (error) => { if (error.code === "ECONNRESET") return; reject(error); }); req.write(body); req.end(); }); } async function main() { const fakeOpencode = startFakeOpencode(); let uvicorn = null; let stderr = ""; await fakeOpencode.listen(); try { uvicorn = spawn(".venv/bin/uvicorn", ["app.main:app", "--host", "127.0.0.1", "--port", String(OPENHUB_PORT)], { cwd: OPENHUB_BACKEND_DIR, env: { ...process.env, OPENCODE_BASE_URL: `http://localhost:${OPENCODE_PORT}`, MNOTE_OPENHUB_TOOL_BRIDGE_AUTO: "0", SQLITE_DB_PATH: "/tmp/openhub-fullchain-smoke.db", NO_PROXY: "127.0.0.1,localhost", no_proxy: "127.0.0.1,localhost", }, stdio: ["ignore", "ignore", "pipe"], }); uvicorn.stderr.on("data", (chunk) => { stderr += String(chunk); }); await waitForHealth(`http://127.0.0.1:${OPENHUB_PORT}/api/health`); const tabUrl = "http://127.0.0.1:3000/documents/local-md:Inbox~2FPage.md?sourceKind=local_folder&rootUri=file:///tmp/mnote-fullchain-workspace"; await sendOpenHub({ question: "只回答 OK", conversation_id: "", agent: "build", mnote_context: { kind: "tab", value: tabUrl, tabUrl, rootUri: "file:///tmp/mnote-fullchain-workspace", documentId: "local-md:Inbox~2FPage.md", relativePath: "Inbox/Page.md", }, }); await wait(200); await sendOpenHub({ question: "只回答 OK fallback", conversation_id: "", agent: "build" }); await wait(200); const explicitPrompt = fakeOpencode.capturedPrompts[0]?.parts?.[0]?.text || ""; const fallbackPrompt = fakeOpencode.capturedPrompts[1]?.parts?.[0]?.text || ""; const result = { ok: false, promptCount: fakeOpencode.capturedPrompts.length, explicitContextOk: explicitPrompt.includes("") && explicitPrompt.includes("当前页面: http://127.0.0.1:3000/documents/") && explicitPrompt.includes("relativePath: Inbox/Page.md"), fallbackContextOk: fallbackPrompt.includes("") && fallbackPrompt.includes("source: mnote_scope_fallback") && fallbackPrompt.includes("file:///tmp/mnote-fullchain-workspace"), explicitPromptPreview: explicitPrompt.slice(0, 700), fallbackPromptPreview: fallbackPrompt.slice(0, 700), }; result.ok = result.promptCount >= 2 && result.explicitContextOk && result.fallbackContextOk; console.log(JSON.stringify(result, null, 2)); if (!result.ok) process.exitCode = 1; } finally { if (uvicorn) { uvicorn.kill("SIGTERM"); await wait(400); } await fakeOpencode.close(); if (process.exitCode) process.stderr.write(stderr.slice(-3000)); } } main().catch((error) => { console.error(error.stack || error.message); process.exit(1); });