feat: Page AI Reasonix desktop session alignment, settings IA cleanup, knowledge RAG hardening
- ACP client/session manager: Reasonix desktop live session context - Hermes tools: knowledge_rag tool manifest and skill updates - Browser runtime: sidebar page AI permission/profile/render/session/tree modules - Routes: hermes_client, hermes_tools, knowledge_rag, web_shell - Scripts: reasonix ACP wrapper, LightRAG MCP, smoke tasks 159/558/559/561/562 - Skills: mnote-knowledge-rag and mnote-lightrag-bridge SKILL.md updates
This commit is contained in:
@@ -19,6 +19,9 @@ API_KEY_VALUE="${LIGHTRAG_API_KEY:-$(read_env_value LIGHTRAG_API_KEY "$ENV_FILE"
|
||||
|
||||
HOST_VALUE="${HOST_VALUE:-127.0.0.1}"
|
||||
PORT_VALUE="${PORT_VALUE:-9621}"
|
||||
if [[ "$HOST_VALUE" == "0.0.0.0" || "$HOST_VALUE" == "::" ]]; then
|
||||
HOST_VALUE="127.0.0.1"
|
||||
fi
|
||||
|
||||
export LIGHTRAG_SERVER_URL="${LIGHTRAG_SERVER_URL:-http://${HOST_VALUE}:${PORT_VALUE}}"
|
||||
export LIGHTRAG_API_KEY="$API_KEY_VALUE"
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
|
||||
DEFAULT_ENV_FILE = "/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env"
|
||||
@@ -21,6 +22,7 @@ DEFAULT_ROOT_URI = "file:///mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-
|
||||
DEFAULT_WORKSPACE_ID = "local-ws:mnote-e2e:my-space"
|
||||
|
||||
mcp = FastMCP("MNote-LightRAG-Server")
|
||||
READ_ONLY_TOOL = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)
|
||||
|
||||
|
||||
def _read_env_value(key: str, env_file: str) -> str:
|
||||
@@ -43,6 +45,8 @@ def _lightrag_base_url() -> str:
|
||||
env_file = os.environ.get("MNOTE_LIGHTRAG_ENV_FILE", DEFAULT_ENV_FILE)
|
||||
host = os.environ.get("LIGHTRAG_HOST") or _read_env_value("HOST", env_file) or "127.0.0.1"
|
||||
port = os.environ.get("LIGHTRAG_PORT") or _read_env_value("PORT", env_file) or "9621"
|
||||
if host in {"0.0.0.0", "::"}:
|
||||
host = "127.0.0.1"
|
||||
return f"http://{host}:{port}".rstrip("/")
|
||||
|
||||
|
||||
@@ -85,9 +89,48 @@ async def _request_lightrag(path: str, *, method: str = "GET", json_body: Any =
|
||||
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
|
||||
|
||||
|
||||
async def _request_mnote(path: str, *, method: str = "GET", json_body: Any = None, params: dict[str, Any] | None = None) -> Any:
|
||||
headers = _mnote_headers()
|
||||
if method.upper() == "GET":
|
||||
headers = {key: value for key, value in headers.items() if key != "content-type"}
|
||||
async with httpx.AsyncClient(timeout=180) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{_mnote_web_url()}{path}",
|
||||
headers=headers,
|
||||
json=json_body,
|
||||
params=params,
|
||||
)
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = {"text": response.text}
|
||||
if response.status_code >= 400:
|
||||
return {"status": "error", "response": None, "error": payload, "httpStatus": response.status_code}
|
||||
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="connect",
|
||||
description="No-op connection probe for agents that expect MCP servers to expose a connect tool.",
|
||||
annotations=READ_ONLY_TOOL,
|
||||
)
|
||||
async def connect() -> Any:
|
||||
return {
|
||||
"status": "success",
|
||||
"response": {
|
||||
"server": "mnote_lightrag_bridge",
|
||||
"connected": True,
|
||||
},
|
||||
"error": None,
|
||||
"httpStatus": 200,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="verify_server_health",
|
||||
description="Check whether the configured local LightRAG server is healthy.",
|
||||
annotations=READ_ONLY_TOOL,
|
||||
)
|
||||
async def verify_server_health() -> Any:
|
||||
return await _request_lightrag("/health")
|
||||
@@ -96,6 +139,7 @@ async def verify_server_health() -> Any:
|
||||
@mcp.tool(
|
||||
name="check_indexing_status",
|
||||
description="Check the LightRAG document processing pipeline status.",
|
||||
annotations=READ_ONLY_TOOL,
|
||||
)
|
||||
async def check_indexing_status() -> Any:
|
||||
return await _request_lightrag("/documents/pipeline_status")
|
||||
@@ -104,6 +148,7 @@ async def check_indexing_status() -> Any:
|
||||
@mcp.tool(
|
||||
name="list_all_docs",
|
||||
description="List documents currently known to LightRAG.",
|
||||
annotations=READ_ONLY_TOOL,
|
||||
)
|
||||
async def list_all_docs() -> Any:
|
||||
return await _request_lightrag("/documents")
|
||||
@@ -111,39 +156,135 @@ async def list_all_docs() -> Any:
|
||||
|
||||
@mcp.tool(
|
||||
name="query_knowledge_graph",
|
||||
description="Search the local LightRAG knowledge base. Use mix by default; references include provider file_path/chunk_id for MNote citation mapping.",
|
||||
description="Ask MNote knowledge_rag.query. This calls MNote's LightRAG facade so references/citations are already mapped to MNote source registry and clickable locators.",
|
||||
annotations=READ_ONLY_TOOL,
|
||||
)
|
||||
async def query_knowledge_graph(
|
||||
prompt: str,
|
||||
search_mode: str = "mix",
|
||||
limit: int = 60,
|
||||
context_only: bool = False,
|
||||
prompt_only: bool = False,
|
||||
include_references: bool = True,
|
||||
include_chunk_content: bool = True,
|
||||
include_document_structure_index: bool = False,
|
||||
source_paths: list[str] | None = None,
|
||||
workspace_id: str = DEFAULT_WORKSPACE_ID,
|
||||
root_uri: str = DEFAULT_ROOT_URI,
|
||||
) -> Any:
|
||||
return await mnote_knowledge_rag_query(
|
||||
query=prompt,
|
||||
mode=search_mode,
|
||||
top_k=limit,
|
||||
chunk_top_k=limit,
|
||||
include_chunk_content=include_chunk_content,
|
||||
include_document_structure_index=include_document_structure_index,
|
||||
source_paths=source_paths,
|
||||
workspace_id=workspace_id,
|
||||
root_uri=root_uri,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="mnote_knowledge_rag_status",
|
||||
description="Call MNote mnote.knowledge_rag.status for provider health, source registry and sync state.",
|
||||
annotations=READ_ONLY_TOOL,
|
||||
)
|
||||
async def mnote_knowledge_rag_status(
|
||||
workspace_id: str = DEFAULT_WORKSPACE_ID,
|
||||
root_uri: str = DEFAULT_ROOT_URI,
|
||||
) -> Any:
|
||||
return await _request_mnote(
|
||||
"/api/knowledge-rag/status",
|
||||
method="GET",
|
||||
params={"workspaceId": workspace_id, "rootUri": root_uri},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="mnote_knowledge_rag_query",
|
||||
description="Call MNote mnote.knowledge_rag.query. Use this for knowledge-library answers that need references/citations/clickable MNote locators. For book-like files pass source_paths and include_document_structure_index=true.",
|
||||
annotations=READ_ONLY_TOOL,
|
||||
)
|
||||
async def mnote_knowledge_rag_query(
|
||||
query: str,
|
||||
mode: str = "mix",
|
||||
top_k: int = 40,
|
||||
chunk_top_k: int = 20,
|
||||
include_chunk_content: bool = True,
|
||||
include_document_structure_index: bool = False,
|
||||
source_paths: list[str] | None = None,
|
||||
workspace_id: str = DEFAULT_WORKSPACE_ID,
|
||||
root_uri: str = DEFAULT_ROOT_URI,
|
||||
) -> Any:
|
||||
mode_aliases = {
|
||||
"keyword": "naive",
|
||||
"semantic": "hybrid",
|
||||
}
|
||||
mode = mode_aliases.get(search_mode, search_mode)
|
||||
resolved_mode = mode_aliases.get(mode, mode)
|
||||
body = {
|
||||
"query": prompt,
|
||||
"mode": mode,
|
||||
"top_k": limit,
|
||||
"chunk_top_k": limit,
|
||||
"only_need_context": context_only,
|
||||
"only_need_prompt": prompt_only,
|
||||
"include_references": include_references,
|
||||
"include_chunk_content": include_chunk_content,
|
||||
"response_type": "Multiple Paragraphs",
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"query": query,
|
||||
"mode": resolved_mode,
|
||||
"topK": top_k,
|
||||
"chunkTopK": chunk_top_k,
|
||||
"includeChunkContent": include_chunk_content,
|
||||
"includeDocumentStructureIndex": include_document_structure_index,
|
||||
}
|
||||
return await _request_lightrag("/query", method="POST", json_body=body)
|
||||
if source_paths:
|
||||
body["sourcePaths"] = source_paths
|
||||
return await _request_mnote("/api/knowledge-rag/query", method="POST", json_body=body)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="mnote_knowledge_rag_section_context",
|
||||
description="Call MNote mnote.knowledge_rag.section_context. Use documentStructureIndex section ranges to fetch bounded LightRAG sidecar blocks/chunks for second-pass reading of book-like/long documents.",
|
||||
annotations=READ_ONLY_TOOL,
|
||||
)
|
||||
async def mnote_knowledge_rag_section_context(
|
||||
source_path: str = "",
|
||||
source_id: str = "",
|
||||
light_rag_doc_id: str = "",
|
||||
file_path: str = "",
|
||||
section_id: str = "",
|
||||
start_block_ordinal: int | None = None,
|
||||
end_block_ordinal: int | None = None,
|
||||
start_paragraph_ordinal: int | None = None,
|
||||
end_paragraph_ordinal: int | None = None,
|
||||
context_before: int = 1,
|
||||
context_after: int = 1,
|
||||
max_blocks: int = 24,
|
||||
max_chars: int = 12000,
|
||||
workspace_id: str = DEFAULT_WORKSPACE_ID,
|
||||
root_uri: str = DEFAULT_ROOT_URI,
|
||||
) -> Any:
|
||||
body: dict[str, Any] = {
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"contextBefore": context_before,
|
||||
"contextAfter": context_after,
|
||||
"maxBlocks": max_blocks,
|
||||
"maxChars": max_chars,
|
||||
}
|
||||
optional_values = {
|
||||
"sourcePath": source_path,
|
||||
"sourceId": source_id,
|
||||
"lightRagDocId": light_rag_doc_id,
|
||||
"filePath": file_path,
|
||||
"sectionId": section_id,
|
||||
"startBlockOrdinal": start_block_ordinal,
|
||||
"endBlockOrdinal": end_block_ordinal,
|
||||
"startParagraphOrdinal": start_paragraph_ordinal,
|
||||
"endParagraphOrdinal": end_paragraph_ordinal,
|
||||
}
|
||||
for key, value in optional_values.items():
|
||||
if value is not None and value != "":
|
||||
body[key] = value
|
||||
return await _request_mnote("/api/knowledge-rag/section-context", method="POST", json_body=body)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="open_mnote_reference",
|
||||
description="Map a LightRAG reference/file_path/chunk_id to an MNote clickable citationUrl using MNote source registry.",
|
||||
annotations=READ_ONLY_TOOL,
|
||||
)
|
||||
async def open_mnote_reference(
|
||||
file_path: str,
|
||||
@@ -162,21 +303,36 @@ async def open_mnote_reference(
|
||||
body["chunkId"] = chunk_id
|
||||
if reference_id:
|
||||
body["referenceId"] = reference_id
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(
|
||||
f"{_mnote_web_url()}/api/knowledge-rag/open-reference",
|
||||
headers=_mnote_headers(),
|
||||
json=body,
|
||||
)
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = {"text": response.text}
|
||||
if response.status_code >= 400:
|
||||
return {"status": "error", "response": None, "error": payload, "httpStatus": response.status_code}
|
||||
result = await _request_mnote("/api/knowledge-rag/open-reference", method="POST", json_body=body)
|
||||
payload = result.get("response")
|
||||
if result.get("status") != "success":
|
||||
return result
|
||||
if not include_registry and isinstance(payload, dict):
|
||||
payload = {key: value for key, value in payload.items() if key != "registry"}
|
||||
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
|
||||
return {"status": "success", "response": payload, "error": None, "httpStatus": result.get("httpStatus")}
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="mnote_knowledge_rag_open_reference",
|
||||
description="Call MNote mnote.knowledge_rag.open_reference to convert a returned reference/file_path/chunk_id into a clickable MNote locator.",
|
||||
annotations=READ_ONLY_TOOL,
|
||||
)
|
||||
async def mnote_knowledge_rag_open_reference(
|
||||
file_path: str,
|
||||
chunk_id: str = "",
|
||||
reference_id: str = "",
|
||||
workspace_id: str = DEFAULT_WORKSPACE_ID,
|
||||
root_uri: str = DEFAULT_ROOT_URI,
|
||||
include_registry: bool = False,
|
||||
) -> Any:
|
||||
return await open_mnote_reference(
|
||||
file_path=file_path,
|
||||
chunk_id=chunk_id,
|
||||
reference_id=reference_id,
|
||||
workspace_id=workspace_id,
|
||||
root_uri=root_uri,
|
||||
include_registry=include_registry,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -20,6 +20,9 @@ API_KEY_VALUE="${LIGHTRAG_API_KEY:-$(read_env_value LIGHTRAG_API_KEY "$ENV_FILE"
|
||||
|
||||
HOST_VALUE="${HOST_VALUE:-127.0.0.1}"
|
||||
PORT_VALUE="${PORT_VALUE:-9621}"
|
||||
if [[ "$HOST_VALUE" == "0.0.0.0" || "$HOST_VALUE" == "::" ]]; then
|
||||
HOST_VALUE="127.0.0.1"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$PYTHON" ]]; then
|
||||
echo "python not found or not executable: $PYTHON" >&2
|
||||
|
||||
@@ -48,6 +48,7 @@ const MNOTE_TOOL_NAMES = [
|
||||
'mnote.context.read_current_page',
|
||||
'mnote.knowledge_rag.status',
|
||||
'mnote.knowledge_rag.query',
|
||||
'mnote.knowledge_rag.section_context',
|
||||
'mnote.knowledge_rag.open_reference',
|
||||
];
|
||||
|
||||
@@ -58,6 +59,7 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
||||
mnote_context_read_current_page: 'mnote.context.read_current_page',
|
||||
mnote_knowledge_rag_status: 'mnote.knowledge_rag.status',
|
||||
mnote_knowledge_rag_query: 'mnote.knowledge_rag.query',
|
||||
mnote_knowledge_rag_section_context: 'mnote.knowledge_rag.section_context',
|
||||
mnote_knowledge_rag_open_reference: 'mnote.knowledge_rag.open_reference',
|
||||
};
|
||||
|
||||
@@ -153,6 +155,7 @@ function isWriteMnoteTool(toolName) {
|
||||
'mnote.context.read_current_page',
|
||||
'mnote.knowledge_rag.status',
|
||||
'mnote.knowledge_rag.query',
|
||||
'mnote.knowledge_rag.section_context',
|
||||
'mnote.knowledge_rag.open_reference',
|
||||
'mnote.doc.fetch',
|
||||
'mnote.page.get',
|
||||
@@ -693,6 +696,10 @@ function compactMnoteToolResultForReasonix(toolName, payload) {
|
||||
answerGuidance: result.answerGuidance || '',
|
||||
references: Array.isArray(result.references) ? result.references.slice(0, 8) : [],
|
||||
citations: citationMarkdowns,
|
||||
documentStructureIndex: result.documentStructureIndex || null,
|
||||
requestedRetrievalMode: result.requestedRetrievalMode || null,
|
||||
effectiveRetrievalMode: result.effectiveRetrievalMode || result.retrievalMode || null,
|
||||
retrievalModeReason: result.retrievalModeReason || null,
|
||||
sourceScope: result.sourceScope || [],
|
||||
sourceScopeMode: result.sourceScopeMode || '',
|
||||
rawScopeFiltered: Boolean(result.rawScopeFiltered),
|
||||
@@ -875,6 +882,10 @@ function fallbackMnoteToolSpecs() {
|
||||
topK: { type: 'integer', description: 'LightRAG top_k' },
|
||||
chunkTopK: { type: 'integer', description: 'LightRAG chunk_top_k' },
|
||||
includeChunkContent: { type: 'boolean', description: '是否在 reference 中包含 chunk 内容' },
|
||||
includeDocumentStructureIndex: {
|
||||
type: 'boolean',
|
||||
description: '是否返回由 LightRAG sidecar headings 派生的 document_structure_index;适合大书/长文档章节导航,不替代 references 引用证据。',
|
||||
},
|
||||
sourcePaths: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
@@ -903,6 +914,32 @@ function fallbackMnoteToolSpecs() {
|
||||
},
|
||||
parallelSafe: true,
|
||||
},
|
||||
{
|
||||
mnoteToolName: 'mnote.knowledge_rag.section_context',
|
||||
name: 'mnote_knowledge_rag_section_context',
|
||||
description: '按 documentStructureIndex section 的 block/paragraph range,从 LightRAG native sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sourcePath: { type: 'string', description: 'MNote workspace 相对路径,推荐从 documentStructureIndex.documents[].sourceRootRelativePath 传入' },
|
||||
sourceId: { type: 'string' },
|
||||
lightRagDocId: { type: 'string' },
|
||||
filePath: { type: 'string', description: 'LightRAG provider file_path' },
|
||||
sectionId: { type: 'string' },
|
||||
startBlockOrdinal: { type: 'integer' },
|
||||
endBlockOrdinal: { type: 'integer' },
|
||||
startParagraphOrdinal: { type: 'integer' },
|
||||
endParagraphOrdinal: { type: 'integer' },
|
||||
contextBefore: { type: 'integer' },
|
||||
contextAfter: { type: 'integer' },
|
||||
maxBlocks: { type: 'integer' },
|
||||
maxChars: { type: 'integer' },
|
||||
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||
},
|
||||
},
|
||||
parallelSafe: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1001,7 +1038,7 @@ onRequest('session/new', async (params) => {
|
||||
'<available-skills>',
|
||||
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
|
||||
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
|
||||
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and answers that require links/sources/citations. Returned uiCitations/citationMarkdown values are MNote clickable source locators and are rendered by the MNote UI after the final answer. Do not copy citationMarkdown into the answer, do not hand-write /documents or mnote:// links, and never wrap local citation URLs with search engines. Mention source titles in plain text only when useful; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
|
||||
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and answers that require links/sources/citations. For book-like results, request includeDocumentStructureIndex and then use mnote_knowledge_rag_section_context to read bounded section blocks when the map is not enough. Returned uiCitations/citationMarkdown values are MNote clickable source locators and are rendered by the MNote UI after the final answer. Do not copy citationMarkdown into the answer, do not hand-write /documents or mnote:// links, and never wrap local citation URLs with search engines. Mention source titles in plain text only when useful; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
|
||||
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
|
||||
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
|
||||
'</available-skills>',
|
||||
|
||||
@@ -54,8 +54,8 @@ function startGateway(port) {
|
||||
async function validateAuthEntry(baseUrl) {
|
||||
const root = await fetchWithTimeout(`${baseUrl}/`);
|
||||
const rootText = await root.text();
|
||||
assert.equal(root.status, 303, `/ 未登录应跳转 /auth: ${root.status} ${rootText.slice(0, 160)}`);
|
||||
assert.equal(root.headers.get("location"), "/auth");
|
||||
assert.equal(root.status, 303, `/ 未登录应跳转 /auth 并保留 next: ${root.status} ${rootText.slice(0, 160)}`);
|
||||
assert.equal(root.headers.get("location"), "/auth?next=%2F");
|
||||
assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web");
|
||||
|
||||
const auth = await fetchWithTimeout(`${baseUrl}/auth`);
|
||||
|
||||
@@ -113,6 +113,17 @@ async function sendPrompt(page, prompt, expectedCompact) {
|
||||
return { runId, text };
|
||||
}
|
||||
|
||||
async function waitUntil(label, predicate, timeoutMs = UI_TIMEOUT_MS) {
|
||||
const start = Date.now();
|
||||
let last = null;
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
last = await predicate().catch((error) => error);
|
||||
if (last === true) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`${label}_timeout: ${String(last && last.message || last || '')}`);
|
||||
}
|
||||
|
||||
function sessionInfoForRuns(runIds) {
|
||||
const quoted = runIds.map(sqlQuote).join(",");
|
||||
return sqliteJson(`
|
||||
@@ -127,6 +138,15 @@ function sessionInfoForRuns(runIds) {
|
||||
}));
|
||||
}
|
||||
|
||||
function runtimeRunsForSession(sessionId) {
|
||||
return sqliteJson(`
|
||||
SELECT run_id AS runId, status
|
||||
FROM ai_runtime_runs
|
||||
WHERE session_id=${sqlQuote(sessionId)} AND run_id LIKE 'run_%'
|
||||
ORDER BY created_at ASC;
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
@@ -166,16 +186,53 @@ async function main() {
|
||||
|
||||
const firstMarker = `TASK558_FIRST_${suffix}`.toUpperCase();
|
||||
const secondMarker = `TASK558_SECOND_${suffix}`.toUpperCase();
|
||||
const first = await sendPrompt(
|
||||
page,
|
||||
const beforeAssistantCount = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
|
||||
await page.locator("[data-page-ai-input]").fill(
|
||||
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说“可以”,你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
|
||||
firstMarker,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const second = await sendPrompt(page, "可以", secondMarker);
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => ["queued", "running", "tool_calling"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator("[data-page-ai-input]").fill("可以", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(document.querySelector('[data-page-ai-queue-item]')),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const queuedPreview = await page.locator('[data-page-ai-queue-item]').first().textContent({ timeout: UI_TIMEOUT_MS });
|
||||
assert(String(queuedPreview || '').includes("可以"), `queued preview 应包含第二轮短回复: ${queuedPreview}`);
|
||||
await waitUntil("reasonix_two_runs_started", async () => capturedRuns.length >= 2, Math.max(UI_TIMEOUT_MS, 120_000));
|
||||
await page.waitForFunction(
|
||||
([firstNeedle, secondNeedle]) => {
|
||||
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return text.includes(firstNeedle)
|
||||
&& text.includes(secondNeedle)
|
||||
&& ["completed", "failed", "aborted"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "")
|
||||
&& !document.querySelector('[data-page-ai-streaming="true"]');
|
||||
},
|
||||
[firstMarker, secondMarker],
|
||||
{ timeout: Math.max(UI_TIMEOUT_MS, 120_000) },
|
||||
);
|
||||
const assistantTexts = await page.evaluate((countBefore) => {
|
||||
return Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'))
|
||||
.slice(countBefore)
|
||||
.map((node) => node.querySelector(".wolai-page-ai-message-text")?.textContent || "");
|
||||
}, beforeAssistantCount);
|
||||
assert(assistantTexts.some((text) => normalize(text) === firstMarker), `第一轮回复缺失: ${JSON.stringify(assistantTexts)}`);
|
||||
assert(assistantTexts.some((text) => normalize(text) === secondMarker), `第二轮回复缺失: ${JSON.stringify(assistantTexts)}`);
|
||||
assert.equal(capturedRuns.length, 2, `应捕获两次 Page AI run,实际 ${capturedRuns.length}`);
|
||||
assert(capturedRuns.every((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"), "两次 run 都应走 Reasonix ACP");
|
||||
assert(capturedRuns[1].acpSessionId, "第二轮请求应携带上一轮 acpSessionId");
|
||||
|
||||
const runtimeRuns = runtimeRunsForSession(capturedRuns[0].sessionId);
|
||||
assert(runtimeRuns.length >= 2, `应有至少两条 runtime run: ${JSON.stringify(runtimeRuns)}`);
|
||||
const first = { runId: runtimeRuns[0].runId, text: firstMarker };
|
||||
const second = { runId: runtimeRuns[1].runId, text: secondMarker };
|
||||
const infos = sessionInfoForRuns([first.runId, second.runId]);
|
||||
assert.equal(infos.length, 2, `应有两条 session.info.updated,实际 ${infos.length}: ${JSON.stringify(infos)}`);
|
||||
const acpSessionIds = infos.map((info) => String(info.payload.acpSessionId || "")).filter(Boolean);
|
||||
@@ -194,6 +251,7 @@ async function main() {
|
||||
documentId,
|
||||
first,
|
||||
second,
|
||||
queuedPreview,
|
||||
acpSessionId: acpSessionIds[0],
|
||||
capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })),
|
||||
sessionInfo: infos,
|
||||
|
||||
@@ -163,13 +163,43 @@ async function main() {
|
||||
assert(runId, "缺少 runId");
|
||||
const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS });
|
||||
assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker);
|
||||
const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(runtimeStrip || '').includes('reasonix'), `runtime strip 应显示 reasonix: ${runtimeStrip}`);
|
||||
assert(!String(runtimeStrip || '').includes('输出中'), `完成后 runtime strip 不应残留输出中: ${runtimeStrip}`);
|
||||
const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
|
||||
assert.equal(rows[0] && rows[0].status, "completed", "SQLite run status 应为 completed");
|
||||
const terminalEvents = sqliteJson(`SELECT COUNT(*) AS count FROM ai_runtime_events WHERE run_id=${sqlQuote(runId)} AND event_type='run.completed';`);
|
||||
assert(Number(terminalEvents[0] && terminalEvents[0].count) >= 1, "应持久化 run.completed");
|
||||
const screenshotPath = path.join(OUT_DIR, "task559-terminal-status.png");
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath };
|
||||
await page.locator('[data-page-ai-runtime-strip]').click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'status', null, { timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const detail = document.querySelector('[data-page-ai-runtime-detail]')?.textContent || '';
|
||||
const jobs = document.querySelector('[data-page-ai-jobs-list]')?.textContent || '';
|
||||
const logs = document.querySelector('[data-page-ai-logs-list]')?.textContent || '';
|
||||
const usage = document.querySelector('[data-page-ai-usage-detail]')?.textContent || '';
|
||||
return detail.includes('runtime') && detail.includes('MCP/tools') && jobs.length > 0 && logs.length > 0 && usage.length > 0;
|
||||
}, null, { timeout: TIMEOUT_MS });
|
||||
const statusText = await page.locator('[data-page-ai-panel="status"]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(statusText || '').includes('Current runtime'), '状态页应显示 Current runtime');
|
||||
assert(String(statusText || '').includes('Queue'), '状态页应显示 Queue');
|
||||
assert(String(statusText || '').includes('Jobs'), '状态页应显示 Jobs');
|
||||
assert(String(statusText || '').includes('Logs tail'), '状态页应显示 Logs tail');
|
||||
assert(String(statusText || '').includes('Usage'), '状态页应显示 Usage');
|
||||
const runtimePanelScreenshotPath = path.join(OUT_DIR, "task559-runtime-panel.png");
|
||||
await page.screenshot({ path: runtimePanelScreenshotPath, fullPage: true });
|
||||
await page.setViewportSize({ width: 452, height: 900 });
|
||||
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
|
||||
const mobileTabs = await page.locator('[data-page-ai-panel="agent"] .wolai-page-ai-settings-tabs [data-page-ai-tab]').evaluateAll((nodes) => nodes.map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { text: node.textContent.trim(), box: { width: rect.width, height: rect.height } };
|
||||
}));
|
||||
assert.deepEqual(mobileTabs.map((tab) => tab.text), ['MNote', 'Reasonix', 'Hermes'], `移动宽度设置 tabs 应保持三层: ${JSON.stringify(mobileTabs)}`);
|
||||
assert(mobileTabs.every((tab) => tab.box.width > 40 && tab.box.height > 24), `移动宽度 tabs 不应挤压不可点: ${JSON.stringify(mobileTabs)}`);
|
||||
const mobileSettingsScreenshotPath = path.join(OUT_DIR, "task559-settings-mobile.png");
|
||||
await page.screenshot({ path: mobileSettingsScreenshotPath, fullPage: true });
|
||||
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath, runtimePanelScreenshotPath, mobileSettingsScreenshotPath };
|
||||
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
await context.close().catch(() => undefined);
|
||||
|
||||
@@ -124,11 +124,48 @@ async function main() {
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
|
||||
const settingTabs = page.locator('[data-page-ai-panel="agent"] .wolai-page-ai-settings-tabs [data-page-ai-tab]');
|
||||
const settingTabTexts = await settingTabs.evaluateAll((nodes) => nodes.map((node) => node.textContent.trim()).filter(Boolean));
|
||||
assert.deepEqual(settingTabTexts, ['MNote', 'Reasonix', 'Hermes'], `设置页 tabs 应只包含 MNote / Reasonix / Hermes: ${JSON.stringify(settingTabTexts)}`);
|
||||
assert(!settingTabTexts.includes('Common') && !settingTabTexts.includes('Chat-only') && !settingTabTexts.includes('高级') && !settingTabTexts.includes('Runtime'), `设置页不应显示旧一级 tab: ${JSON.stringify(settingTabTexts)}`);
|
||||
const mnotePanelText = await page.locator('[data-page-ai-panel="agent"]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(mnotePanelText || '').includes('授权区域'), 'MNote tab 应显示授权区域');
|
||||
assert(String(mnotePanelText || '').includes('默认上下文'), 'MNote tab 应显示默认上下文');
|
||||
assert(String(mnotePanelText || '').includes('MNote 工具'), 'MNote tab 应显示 MNote 工具');
|
||||
assert(String(mnotePanelText || '').includes('会话与审计'), 'MNote tab 应显示会话与审计');
|
||||
await page.locator('[data-page-ai-panel="agent"] [data-page-ai-tab="reasonix-settings"]').click({ timeout: TIMEOUT_MS });
|
||||
const reasonixPanelText = await page.locator('[data-page-ai-panel="reasonix-settings"]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(reasonixPanelText || '').includes('ACP runtime'), 'Reasonix tab 应显示 ACP runtime');
|
||||
assert(!/MNOTE_WEB_HERMES_UPSTREAM_URL|API key|Hermes 设置入口/.test(String(reasonixPanelText || '')), `Reasonix tab 不应显示 Hermes gateway/API key 错误: ${reasonixPanelText}`);
|
||||
await page.locator('[data-page-ai-panel="reasonix-settings"] [data-page-ai-tab="hermes-settings"]').click({ timeout: TIMEOUT_MS });
|
||||
const hermesPanelText = await page.locator('[data-page-ai-panel="hermes-settings"]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(hermesPanelText || '').includes('Hermes profile'), 'Hermes tab 应显示 profile');
|
||||
assert(String(hermesPanelText || '').includes('Hermes gateway'), 'Hermes tab 应显示 gateway');
|
||||
assert(!String(hermesPanelText || '').includes('native-live queue'), 'Hermes tab 不应显示 Reasonix native-live queue 当前状态');
|
||||
const settingsScreenshotPath = path.join(OUT_DIR, "task561-settings-ia.png");
|
||||
await page.screenshot({ path: settingsScreenshotPath, fullPage: true });
|
||||
await page.locator('[data-page-ai-action="history"]').first().click({ timeout: TIMEOUT_MS });
|
||||
const row = page.locator(`[data-page-ai-session-row="${sessionId}"]`).first();
|
||||
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
const statusFilter = page.locator('[data-page-ai-session-status-filter]');
|
||||
await statusFilter.waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
const statusValues = await statusFilter.locator('option').evaluateAll((options) => options.map((option) => option.value));
|
||||
assert(statusValues.includes('completed'), `status filter 应包含 completed: ${JSON.stringify(statusValues)}`);
|
||||
assert.equal(await row.getAttribute('data-page-ai-session-status'), 'completed');
|
||||
await statusFilter.selectOption('completed', { timeout: TIMEOUT_MS });
|
||||
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-session-search]').fill(`dashboard seed ${suffix}`, { timeout: TIMEOUT_MS });
|
||||
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
const beforeDeleteScreenshotPath = path.join(OUT_DIR, "task561-session-dashboard-before-delete.png");
|
||||
await page.screenshot({ path: beforeDeleteScreenshotPath, fullPage: true });
|
||||
const exportApiResponse = await context.request.fetch(`${baseUrl}/api/page-ai/sessions/${encodeURIComponent(sessionId)}/export?workspaceId=${encodeURIComponent(workspaceId)}&documentId=${encodeURIComponent(documentId)}&limit=200`, { timeout: TIMEOUT_MS });
|
||||
const exportApiText = await exportApiResponse.text();
|
||||
assert(exportApiResponse.ok(), `/api/page-ai/sessions export 失败: ${exportApiResponse.status()} ${exportApiText.slice(0, 500)}`);
|
||||
const exportApiPayload = JSON.parse(exportApiText);
|
||||
assert.deepEqual(exportApiPayload.export.formats, ['json', 'markdown', 'jsonl']);
|
||||
assert(String(exportApiPayload.export.markdown || '').includes(sessionId), 'markdown export 应包含 sessionId');
|
||||
assert(String(exportApiPayload.export.jsonl || '').includes('"eventType"'), 'jsonl export 应包含事件行');
|
||||
await page.locator(`[data-page-ai-session-export="${sessionId}"]`).click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction((id) => document.documentElement.getAttribute('data-mnote-page-ai-session-exported') === id, sessionId, { timeout: TIMEOUT_MS });
|
||||
await page.evaluate(() => { window.prompt = () => 'Task561 Renamed'; });
|
||||
@@ -144,7 +181,7 @@ async function main() {
|
||||
assert(deletedRows[0] && deletedRows[0].deletedAt, "delete 应软删除 SQLite session runs");
|
||||
const screenshotPath = path.join(OUT_DIR, "task561-session-dashboard.png");
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const result = { ok: true, baseUrl, outDir: OUT_DIR, workspaceId, documentId, sessionId, runId, screenshotPath };
|
||||
const result = { ok: true, baseUrl, outDir: OUT_DIR, workspaceId, documentId, sessionId, runId, settingsScreenshotPath, beforeDeleteScreenshotPath, screenshotPath };
|
||||
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
await context.close().catch(() => undefined);
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn, execFileSync } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
|
||||
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_APPROVAL_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task562-reasonix-approval-"));
|
||||
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
const ACTOR = "mnote-e2e";
|
||||
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
|
||||
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
|
||||
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
|
||||
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
|
||||
function fileUrl(localPath) { return `file://${localPath}`; }
|
||||
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => { request.destroy(); retry(); });
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function writeFakeReasonixAcp(scriptPath) {
|
||||
fs.writeFileSync(scriptPath, `
|
||||
import * as readline from 'node:readline';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
const LOG = process.env.TASK562_FAKE_LOG || '';
|
||||
let nextSessionId = 1;
|
||||
let pendingPrompt = null;
|
||||
let permissionRequestId = 7;
|
||||
let permissionId = 'task562_perm_1';
|
||||
let marker = 'TASK562_DONE';
|
||||
function log(value) { if (LOG) appendFileSync(LOG, JSON.stringify(value) + '\\n', 'utf8'); }
|
||||
function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); }
|
||||
function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); }
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
log({ dir: 'in', method: msg.method || '', id: msg.id || null, hasResult: Boolean(msg.result), hasError: Boolean(msg.error) });
|
||||
if (msg.method === 'initialize') {
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task562-fake-reasonix', version: '1.0' }, authMethods: [] } });
|
||||
return;
|
||||
}
|
||||
if (msg.method === 'session/new') {
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task562_reasonix_session_' + nextSessionId++ } });
|
||||
return;
|
||||
}
|
||||
if (msg.method === 'session/prompt') {
|
||||
const sessionId = msg.params?.sessionId || '';
|
||||
const prompt = promptText(msg.params?.prompt);
|
||||
marker = (String(prompt).match(/TASK562_[A-Z0-9_]+/) || ['TASK562_DONE'])[0];
|
||||
pendingPrompt = { id: msg.id, sessionId, prompt };
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: permissionRequestId,
|
||||
method: 'session/request_permission',
|
||||
params: {
|
||||
permissionId,
|
||||
toolName: 'mcp__demo__write_file',
|
||||
toolCall: {
|
||||
toolCallId: 'gate-call_1',
|
||||
title: 'bash',
|
||||
kind: 'execute',
|
||||
rawInput: { command: 'echo reasonix permission' }
|
||||
},
|
||||
options: [
|
||||
{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
|
||||
{ optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' }
|
||||
]
|
||||
}
|
||||
});
|
||||
log({ dir: 'out', method: 'session/request_permission', id: permissionRequestId, permissionId });
|
||||
return;
|
||||
}
|
||||
if (msg.id === permissionRequestId && msg.result) {
|
||||
log({ dir: 'permission-resolved', result: msg.result });
|
||||
if (!pendingPrompt) return;
|
||||
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: pendingPrompt.sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } });
|
||||
send({ jsonrpc: '2.0', id: pendingPrompt.id, result: { stopReason: 'end_turn' } });
|
||||
pendingPrompt = null;
|
||||
}
|
||||
});
|
||||
`, "utf8");
|
||||
}
|
||||
|
||||
async function signIn(context, baseUrl) {
|
||||
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { account: ACTOR, password: PASSWORD, flow: "signIn" },
|
||||
},
|
||||
},
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
|
||||
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
|
||||
const whoamiText = await whoami.text();
|
||||
assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`);
|
||||
return JSON.parse(whoamiText);
|
||||
}
|
||||
|
||||
async function selectReasonix(page) {
|
||||
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs");
|
||||
const fakeLog = path.join(OUT_DIR, "fake-reasonix-acp.jsonl");
|
||||
writeFakeReasonixAcp(fakeScript);
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], env: { TASK562_FAKE_LOG: fakeLog }, title: "Reasonix Fake" }]);
|
||||
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(ROOT, "rust"),
|
||||
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: "reasonix" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
server.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task562-reasonix-approval-"));
|
||||
let browser;
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
|
||||
browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const viewer = await signIn(context, baseUrl);
|
||||
const actorId = viewer.userId || ACTOR;
|
||||
const suffix = Date.now().toString(36).toUpperCase();
|
||||
const workspaceId = `local-ws:${actorId}:task562-${suffix.toLowerCase()}`;
|
||||
const rootUri = fileUrl(root);
|
||||
const relativePath = `Task562-${suffix}.md`;
|
||||
const documentId = localMdDocumentId(relativePath);
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(path.join(root, relativePath), `# Task562 Reasonix Approval\n\n${suffix}\n`, "utf8");
|
||||
sqliteExec(`
|
||||
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
|
||||
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
|
||||
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
|
||||
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task562 Reasonix Approval', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
|
||||
`);
|
||||
sqliteExec(`
|
||||
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
|
||||
VALUES (${sqlQuote(`grant_task562_${suffix}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
|
||||
`);
|
||||
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
|
||||
await selectReasonix(page);
|
||||
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-tab="reasonix-settings"]').first().click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'reasonix-settings',
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const modelSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.model_id"]');
|
||||
const approvalSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.approval_mode"]');
|
||||
const planSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.plan_mode"]');
|
||||
await modelSelect.selectOption("mimo-pro", { timeout: TIMEOUT_MS });
|
||||
await approvalSelect.selectOption("ask", { timeout: TIMEOUT_MS });
|
||||
await planSelect.selectOption("auto", { timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const text = document.querySelector('[data-page-ai-model-status]')?.textContent || '';
|
||||
return text.includes('Reasonix') && text.includes('mimo-pro') && text.includes('审批:询问') && text.includes('计划:自动');
|
||||
},
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const buttons = Array.from(drawer ? drawer.querySelectorAll('[data-page-ai-tab="chat"]') : []);
|
||||
const button = buttons.find((node) => node instanceof HTMLElement && node.offsetParent !== null);
|
||||
if (button) button.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'chat',
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForSelector('[data-page-ai-reasonix-quick-controls]:not([hidden])', { timeout: TIMEOUT_MS });
|
||||
const quickControlsText = await page.locator('[data-page-ai-reasonix-quick-controls]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(quickControlsText || '').includes('模型'), `聊天输入区应显示模型选择: ${quickControlsText}`);
|
||||
assert(String(quickControlsText || '').includes('审批'), `聊天输入区应显示审批选择: ${quickControlsText}`);
|
||||
assert(String(quickControlsText || '').includes('Plan'), `聊天输入区应显示 Plan 选择: ${quickControlsText}`);
|
||||
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.model_id"]').inputValue({ timeout: TIMEOUT_MS }), 'mimo-pro', '模型 quick control 应同步设置值');
|
||||
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.approval_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'ask', '审批 quick control 应同步设置值');
|
||||
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.plan_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'auto', 'Plan quick control 应同步设置值');
|
||||
|
||||
const marker = `TASK562_DONE_${suffix}`;
|
||||
await page.locator('[data-page-ai-input]').fill(`只回复 ${marker},不要解释。`, { timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => !!document.querySelector('[data-page-ai-permission-dialog]') && !document.querySelector('[data-page-ai-permission-dialog]')?.hidden,
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
const permissionText = await page.locator('[data-page-ai-permission-dialog]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(permissionText || '').includes('bash') && String(permissionText || '').includes('echo reasonix permission'), `权限弹窗应展示 tool 和命令: ${permissionText}`);
|
||||
const resolveRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes('/resolve-permission') && request.method() === 'POST',
|
||||
{ timeout: 5000 },
|
||||
).catch(() => null);
|
||||
const resolveResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/resolve-permission'),
|
||||
{ timeout: 5000 },
|
||||
).catch(() => null);
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector('[data-page-ai-permission-dialog] [data-page-ai-permission-action="allow"]');
|
||||
if (button instanceof HTMLElement) button.click();
|
||||
});
|
||||
const resolveRequest = await resolveRequestPromise;
|
||||
assert(resolveRequest, '点击允许后应发出 resolve-permission 请求');
|
||||
const resolveResponse = await resolveResponsePromise;
|
||||
assert(resolveResponse && resolveResponse.ok(), `resolve-permission 应成功,实际 status=${resolveResponse && resolveResponse.status()} url=${resolveRequest.url()} body=${resolveRequest.postData() || ''}`);
|
||||
await page.waitForFunction(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') === 'completed', null, { timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS });
|
||||
|
||||
const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || '');
|
||||
assert(runId, '缺少 runId');
|
||||
const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS });
|
||||
assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker, `AI 回复不符合预期: ${JSON.stringify(text && text.slice(0, 800))}`);
|
||||
const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(runtimeStrip || '').includes('Reasonix'), `runtime strip 应显示 Reasonix: ${runtimeStrip}`);
|
||||
assert(String(runtimeStrip || '').includes('mimo-pro'), `runtime strip 应显示已选 model: ${runtimeStrip}`);
|
||||
assert(String(runtimeStrip || '').includes('审批:询问'), `runtime strip 应显示审批模式: ${runtimeStrip}`);
|
||||
assert(String(runtimeStrip || '').includes('计划:自动'), `runtime strip 应显示计划模式: ${runtimeStrip}`);
|
||||
|
||||
const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
|
||||
assert.equal(rows[0] && rows[0].status, 'completed', 'SQLite run status 应为 completed');
|
||||
const screenshotPath = path.join(OUT_DIR, 'task562-reasonix-approval.png');
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath };
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
await context.close().catch(() => undefined);
|
||||
} catch (error) {
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'failure.json'), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, 'utf8');
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
server.kill('SIGTERM');
|
||||
await new Promise((resolve) => server.once('exit', resolve));
|
||||
if (process.env.MNOTE_KEEP_TASK562_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });
|
||||
Reference in New Issue
Block a user