Retire legacy OCR/media/evidence fallbacks, add local-folder event bus and Page Aggregate guards, and archive completed design checklists. Validation: cargo test -p mnote-web -- --test-threads=1; cargo test --workspace -- --test-threads=1; git diff --check; codegraph sync .; codegraph_status.
6859 lines
275 KiB
Rust
6859 lines
275 KiB
Rust
use super::hermes_client;
|
|
use crate::app::AppState;
|
|
use crate::context::RequestContext;
|
|
use crate::error::WebError;
|
|
use crate::hermes_tools::{
|
|
artifact, block, context_tools, doc, evidence, knowledge_rag, manifest, onlyoffice_live, page,
|
|
resource, skill, ToolCallInput,
|
|
};
|
|
use axum::extract::{Extension, Query, State};
|
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
|
use axum::Json;
|
|
use serde_json::{json, Value};
|
|
use std::collections::HashMap;
|
|
use std::env;
|
|
use std::fs::{self, File, OpenOptions};
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::path::PathBuf;
|
|
use std::sync::{Mutex, OnceLock};
|
|
use tracing::{info, warn};
|
|
|
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
|
const HEADER_HERMES_TOOL_OWNER: &str = "x-mnote-hermes-tool-owner";
|
|
|
|
fn record_local_agent_write_rejection(
|
|
context: &RequestContext,
|
|
input: &ToolCallInput,
|
|
profile: &str,
|
|
code: &str,
|
|
message: &str,
|
|
) {
|
|
let Some(run_id) = input
|
|
.run_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
else {
|
|
return;
|
|
};
|
|
let payload = json!({
|
|
"toolName": input.tool_name.clone(),
|
|
"toolCallId": input.effective_tool_call_id(),
|
|
"sessionId": input.session_id.clone(),
|
|
"workspaceId": input.effective_workspace_id(),
|
|
"documentId": input.effective_document_id(),
|
|
"rootUri": input.effective_root_uri(),
|
|
"actorId": input.actor_id.clone(),
|
|
"actorType": input.arg_string("actorType").or_else(|| input.arg_string("actor_type")),
|
|
"permissionLevel": "read_only",
|
|
"rejection": {
|
|
"code": code,
|
|
"message": message,
|
|
"source": "local_ai_scope",
|
|
"toolName": input.tool_name.clone(),
|
|
},
|
|
});
|
|
if let Err(error) =
|
|
hermes_client::local_agent_audit_record_write_rejected(context, payload, run_id, profile)
|
|
{
|
|
warn!(
|
|
error = ?error,
|
|
run_id = %run_id,
|
|
tool_name = %input.tool_name,
|
|
"本地 agent 拒绝审计写入失败"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn record_local_agent_tool_write(
|
|
context: &RequestContext,
|
|
input: &ToolCallInput,
|
|
profile: &str,
|
|
result: &Value,
|
|
) {
|
|
if input.effective_source_kind().as_deref() != Some("local_folder") {
|
|
return;
|
|
}
|
|
let Some(run_id) = input
|
|
.run_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
else {
|
|
return;
|
|
};
|
|
let document_id = input.effective_document_id().unwrap_or_default();
|
|
let command_name = result
|
|
.get("commandName")
|
|
.or_else(|| result.pointer("/applyResult/commandName"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(input.tool_name.as_str());
|
|
let file_version = result
|
|
.get("fileVersion")
|
|
.or_else(|| result.pointer("/result/fileVersion"))
|
|
.or_else(|| result.pointer("/applyResult/result/fileVersion"))
|
|
.cloned()
|
|
.unwrap_or(Value::Null);
|
|
let changed_files = json!([{
|
|
"path": document_id,
|
|
"changeType": "modified",
|
|
"summary": format!("MNote tool 写入:{command_name}"),
|
|
"fileVersion": file_version
|
|
}]);
|
|
let payload = json!({
|
|
"toolName": input.tool_name.clone(),
|
|
"toolCallId": input.effective_tool_call_id(),
|
|
"sessionId": input.session_id.clone(),
|
|
"workspaceId": input.effective_workspace_id(),
|
|
"documentId": input.effective_document_id(),
|
|
"rootUri": input.effective_root_uri(),
|
|
"actorId": input.actor_id.clone(),
|
|
"actorType": input.arg_string("actorType").or_else(|| input.arg_string("actor_type")),
|
|
"permissionLevel": "read_write",
|
|
"commandName": command_name,
|
|
"changedFiles": changed_files,
|
|
});
|
|
if let Err(error) =
|
|
hermes_client::local_agent_audit_record_tool_write(context, payload, run_id, profile)
|
|
{
|
|
warn!(
|
|
error = ?error,
|
|
run_id = %run_id,
|
|
tool_name = %input.tool_name,
|
|
"本地 agent tool 写入审计失败"
|
|
);
|
|
}
|
|
}
|
|
|
|
pub async fn mnote_audit(
|
|
Extension(context): Extension<RequestContext>,
|
|
Query(query): Query<HashMap<String, String>>,
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
ensure_authenticated(&context)?;
|
|
let trace_id = query.get("traceId").map(String::as_str);
|
|
let tool_call_id = query.get("toolCallId").map(String::as_str);
|
|
let persisted_only = query
|
|
.get("persistedOnly")
|
|
.map(|value| value == "true" || value == "1")
|
|
.unwrap_or(false);
|
|
let events = if persisted_only {
|
|
audit_persisted_events(trace_id, tool_call_id)
|
|
} else {
|
|
audit_events(trace_id, tool_call_id)
|
|
};
|
|
Ok((
|
|
StatusCode::OK,
|
|
stamp_tool_headers(),
|
|
Json(json!({
|
|
"ok": true,
|
|
"traceId": context.trace.trace_id,
|
|
"auditStore": if persisted_only { "jsonl" } else { "memory" },
|
|
"events": events
|
|
})),
|
|
))
|
|
}
|
|
|
|
pub async fn mnote_manifest(
|
|
Extension(context): Extension<RequestContext>,
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
ensure_authenticated(&context)?;
|
|
Ok((
|
|
StatusCode::OK,
|
|
stamp_tool_headers(),
|
|
Json(json!({
|
|
"ok": true,
|
|
"traceId": context.trace.trace_id,
|
|
"manifest": manifest::manifest()
|
|
})),
|
|
))
|
|
}
|
|
|
|
pub async fn mnote_call(
|
|
State(state): State<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Json(input): Json<ToolCallInput>,
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
let response_body = execute_mnote_tool_call(&state, &context, input).await?;
|
|
Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
|
|
}
|
|
|
|
pub(crate) async fn execute_mnote_tool_call(
|
|
state: &AppState,
|
|
context: &RequestContext,
|
|
input: ToolCallInput,
|
|
) -> Result<Value, WebError> {
|
|
let context = authenticated_tool_context(context, &input)?;
|
|
let trace_id = input
|
|
.effective_trace_id(&context.trace.trace_id)
|
|
.to_string();
|
|
let tool_call_id = input.effective_tool_call_id();
|
|
let workspace_id = input.effective_workspace_id();
|
|
let document_id = input.effective_document_id();
|
|
let dry_run = input.dry_run.unwrap_or(false);
|
|
let effect = if dry_run {
|
|
"dry_run"
|
|
} else if is_read_tool(&input.tool_name) {
|
|
"read"
|
|
} else {
|
|
"write"
|
|
};
|
|
let idempotency_key = idempotency_cache_key(
|
|
&input,
|
|
workspace_id.as_deref(),
|
|
document_id.as_deref(),
|
|
dry_run,
|
|
);
|
|
info!(
|
|
trace_id = %trace_id,
|
|
session_id = input.session_id.as_deref().unwrap_or(""),
|
|
run_id = input.run_id.as_deref().unwrap_or(""),
|
|
tool_call_id = %tool_call_id,
|
|
tool_name = %input.tool_name,
|
|
workspace_id = workspace_id.as_deref().unwrap_or(""),
|
|
document_id = document_id.as_deref().unwrap_or(""),
|
|
actor_id = input.actor_id.as_deref().unwrap_or(""),
|
|
dry_run,
|
|
"mnote Hermes tool call started"
|
|
);
|
|
let profile = input
|
|
.profile
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| input.arg_string("profile"))
|
|
.or_else(hermes_client::active_profile_name)
|
|
.unwrap_or_else(|| "default".into());
|
|
audit_push(json!({
|
|
"phase": "started",
|
|
"traceId": trace_id,
|
|
"sessionId": input.session_id,
|
|
"runId": input.run_id,
|
|
"toolCallId": tool_call_id,
|
|
"toolName": input.tool_name,
|
|
"workspaceId": workspace_id,
|
|
"documentId": document_id,
|
|
"actorId": input.actor_id,
|
|
"dryRun": dry_run
|
|
}));
|
|
if hermes_client::is_mnote_tool_disabled(&profile, &input.tool_name) {
|
|
let error = WebError::new(
|
|
StatusCode::FORBIDDEN,
|
|
"mnote_tool_disabled",
|
|
"当前 Hermes profile 已关闭该 mnote tool",
|
|
)
|
|
.with_context(&context)
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools");
|
|
audit_push(json!({
|
|
"phase": "failed",
|
|
"traceId": trace_id,
|
|
"sessionId": input.session_id,
|
|
"runId": input.run_id,
|
|
"toolCallId": tool_call_id,
|
|
"toolName": input.tool_name,
|
|
"workspaceId": workspace_id,
|
|
"documentId": document_id,
|
|
"actorId": input.actor_id,
|
|
"profile": profile,
|
|
"status": error.status().as_u16(),
|
|
"message": error.message()
|
|
}));
|
|
return Err(error);
|
|
}
|
|
if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) {
|
|
audit_push(json!({
|
|
"phase": "failed",
|
|
"traceId": trace_id,
|
|
"sessionId": input.session_id,
|
|
"runId": input.run_id,
|
|
"toolCallId": tool_call_id,
|
|
"toolName": input.tool_name,
|
|
"workspaceId": workspace_id,
|
|
"documentId": document_id,
|
|
"actorId": input.actor_id,
|
|
"status": error.status().as_u16(),
|
|
"message": error.message()
|
|
}));
|
|
return Err(error);
|
|
}
|
|
if !dry_run && !is_read_tool(&input.tool_name) && is_shared_read_scope(&input) {
|
|
let error = WebError::new(
|
|
StatusCode::FORBIDDEN,
|
|
"mnote_tool_shared_read_write_forbidden",
|
|
"共享只读 AI 上下文不能执行写工具",
|
|
)
|
|
.with_context(&context)
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools");
|
|
audit_push(json!({
|
|
"phase": "failed",
|
|
"traceId": trace_id,
|
|
"sessionId": input.session_id,
|
|
"runId": input.run_id,
|
|
"toolCallId": tool_call_id,
|
|
"toolName": input.tool_name,
|
|
"workspaceId": workspace_id,
|
|
"documentId": document_id,
|
|
"actorId": input.actor_id,
|
|
"status": error.status().as_u16(),
|
|
"message": error.message(),
|
|
"permissionLevel": "shared_read"
|
|
}));
|
|
record_local_agent_write_rejection(
|
|
&context,
|
|
&input,
|
|
&profile,
|
|
error.code(),
|
|
error.message(),
|
|
);
|
|
return Err(error);
|
|
}
|
|
if let Err(error) = ensure_tool_capability_scope(&context, &input) {
|
|
audit_push(json!({
|
|
"phase": "failed",
|
|
"traceId": trace_id,
|
|
"sessionId": input.session_id,
|
|
"runId": input.run_id,
|
|
"toolCallId": tool_call_id,
|
|
"toolName": input.tool_name,
|
|
"workspaceId": workspace_id,
|
|
"documentId": document_id,
|
|
"actorId": input.actor_id,
|
|
"status": error.status().as_u16(),
|
|
"message": error.message(),
|
|
"capabilityScope": input.capability_scope
|
|
}));
|
|
return Err(error);
|
|
}
|
|
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
|
|
info!(
|
|
trace_id = %trace_id,
|
|
session_id = input.session_id.as_deref().unwrap_or(""),
|
|
run_id = input.run_id.as_deref().unwrap_or(""),
|
|
tool_call_id = %tool_call_id,
|
|
tool_name = %input.tool_name,
|
|
workspace_id = workspace_id.as_deref().unwrap_or(""),
|
|
document_id = document_id.as_deref().unwrap_or(""),
|
|
actor_id = input.actor_id.as_deref().unwrap_or(""),
|
|
"mnote Hermes tool call idempotency replay"
|
|
);
|
|
audit_push(json!({
|
|
"phase": "idempotency_replay",
|
|
"traceId": trace_id,
|
|
"sessionId": cached.get("sessionId").cloned().unwrap_or(Value::Null),
|
|
"runId": cached.get("runId").cloned().unwrap_or(Value::Null),
|
|
"toolCallId": cached.get("toolCallId").cloned().unwrap_or(Value::Null),
|
|
"toolName": cached.get("toolName").cloned().unwrap_or(Value::Null),
|
|
"audit": cached.get("audit").cloned().unwrap_or(Value::Null)
|
|
}));
|
|
return Ok(cached);
|
|
}
|
|
let result = match input.tool_name.as_str() {
|
|
"mnote.skill.read" => skill::skill_read(&context, &input).await,
|
|
"mnote.context.snapshot" => context_tools::context_snapshot(&state, &context, &input).await,
|
|
"mnote.context.read_current_page" => {
|
|
context_tools::read_current_page(&state, &context, &input).await
|
|
}
|
|
"mnote.context.resolve_target" => {
|
|
context_tools::resolve_target(&state, &context, &input).await
|
|
}
|
|
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
|
|
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
|
|
"docs_search" => evidence::legacy_docs_search(&state, &context, &input).await,
|
|
"docs_read" => evidence::legacy_docs_read(&state, &context, &input).await,
|
|
"mnote.evidence.search" | "mnote.evidence.read" | "mnote.evidence.open" => {
|
|
Err(WebError::new(
|
|
StatusCode::GONE,
|
|
"mnote_evidence_tools_retired",
|
|
"旧 evidence / LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
|
|
)
|
|
.with_context(&context))
|
|
}
|
|
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
|
|
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
|
|
"mnote.knowledge_rag.open_reference" => {
|
|
knowledge_rag::open_reference(&state, &context, &input).await
|
|
}
|
|
"mnote.index.status" | "mnote.index.refresh" | "mnote.index.update_settings" => Err(
|
|
WebError::new(
|
|
StatusCode::GONE,
|
|
"mnote_index_tools_retired",
|
|
"旧本地索引 tools 已退役;资料索引统一由 LightRAG provider 处理",
|
|
)
|
|
.with_context(&context),
|
|
),
|
|
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
|
|
"mnote.block.fetch" => block::block_fetch(&state, &context, &input).await,
|
|
"mnote.block.replace" => block::block_replace(&state, &context, &input).await,
|
|
"mnote.block.insert_after" => block::block_insert_after(&state, &context, &input).await,
|
|
"mnote.block.delete" => block::block_delete(&state, &context, &input).await,
|
|
"mnote.block.move_after" => block::block_move_after(&state, &context, &input).await,
|
|
"mnote.doc.apply_block_ops" => block::doc_apply_block_ops(&state, &context, &input).await,
|
|
"mnote.doc.markdown_edit" => doc::doc_markdown_edit(&state, &context, &input).await,
|
|
"mnote.page.get" => page::page_get(&state, &context, &input).await,
|
|
"mnote.page.save" => page::page_save(&state, &context, &input).await,
|
|
"mnote.page.update_title" => page::update_title(&state, &context, &input).await,
|
|
"mnote.page.update_options" => page::update_options(&state, &context, &input).await,
|
|
"mnote.mindmap.fetch" => resource::mindmap_fetch(&context, &input).await,
|
|
"mnote.mindmap.apply_ops" => resource::mindmap_apply_ops(&context, &input).await,
|
|
"mnote.mindmap.create_from_outline" => {
|
|
resource::mindmap_create_from_outline(&context, &input).await
|
|
}
|
|
"mnote.office.fetch_summary" => resource::office_fetch_summary(&context, &input).await,
|
|
"mnote.office.propose_changes" => resource::office_propose_changes(&context, &input).await,
|
|
"mnote.onlyoffice.session.current" => {
|
|
onlyoffice_live::session_current(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.capabilities" => onlyoffice_live::capabilities(&context, &input).await,
|
|
"mnote.onlyoffice.selection.get" => onlyoffice_live::selection_get(&context, &input).await,
|
|
"mnote.onlyoffice.document.insert_text" => {
|
|
onlyoffice_live::document_insert_text(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.document.replace_selection" => {
|
|
onlyoffice_live::document_replace_selection(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.document.insert_html" => {
|
|
onlyoffice_live::document_insert_html(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.document.export" => {
|
|
onlyoffice_live::document_export(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.document.search_replace" => {
|
|
onlyoffice_live::document_search_replace(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.document.insert_table" => {
|
|
onlyoffice_live::document_insert_table(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.document.get_comments" => {
|
|
onlyoffice_live::document_get_comments(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.document.add_comment" => {
|
|
onlyoffice_live::document_add_comment(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.get_sheets" => {
|
|
onlyoffice_live::sheet_get_sheets(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.add_sheet" => {
|
|
onlyoffice_live::sheet_add_sheet(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.rename_sheet" => {
|
|
onlyoffice_live::sheet_rename_sheet(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.get_range" => {
|
|
onlyoffice_live::sheet_get_range(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.get_range_values" => {
|
|
onlyoffice_live::sheet_get_range_values(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.get_values" => {
|
|
onlyoffice_live::sheet_get_values(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.set_value" => {
|
|
onlyoffice_live::sheet_set_value(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.set_formula" => {
|
|
onlyoffice_live::sheet_set_formula(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.batch_set_values" => {
|
|
onlyoffice_live::sheet_batch_set_values(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.set_range_values" => {
|
|
onlyoffice_live::sheet_set_range_values(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.format_range" => {
|
|
onlyoffice_live::sheet_format_range(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.set_dimensions" => {
|
|
onlyoffice_live::sheet_set_dimensions(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.sort_range" => {
|
|
onlyoffice_live::sheet_sort_range(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.sheet.add_chart" => {
|
|
onlyoffice_live::sheet_add_chart(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.presentation.get_slides" => {
|
|
onlyoffice_live::presentation_get_slides(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.presentation.get_slide_texts" => {
|
|
onlyoffice_live::presentation_get_slide_texts(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.presentation.get_shapes" => {
|
|
onlyoffice_live::presentation_get_shapes(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.presentation.add_text_slide" => {
|
|
onlyoffice_live::presentation_add_text_slide(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.presentation.replace_text" => {
|
|
onlyoffice_live::presentation_replace_text(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.presentation.set_shape_text" => {
|
|
onlyoffice_live::presentation_set_shape_text(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.presentation.delete_slide" => {
|
|
onlyoffice_live::presentation_delete_slide(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.presentation.add_table" => {
|
|
onlyoffice_live::presentation_add_table(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.presentation.clear_slide" => {
|
|
onlyoffice_live::presentation_clear_slide(&context, &input).await
|
|
}
|
|
"mnote.onlyoffice.presentation.add_shape" => {
|
|
onlyoffice_live::presentation_add_shape(&context, &input).await
|
|
}
|
|
"mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await,
|
|
"mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await,
|
|
_ => Err(
|
|
WebError::bad_request_code("mnote_tool_unknown", "未知 mnote Hermes tool")
|
|
.with_context(&context)
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"),
|
|
),
|
|
};
|
|
if let Err(error) = &result {
|
|
warn!(
|
|
trace_id = %trace_id,
|
|
session_id = input.session_id.as_deref().unwrap_or(""),
|
|
run_id = input.run_id.as_deref().unwrap_or(""),
|
|
tool_call_id = %tool_call_id,
|
|
tool_name = %input.tool_name,
|
|
workspace_id = workspace_id.as_deref().unwrap_or(""),
|
|
document_id = document_id.as_deref().unwrap_or(""),
|
|
actor_id = input.actor_id.as_deref().unwrap_or(""),
|
|
status = %error.status(),
|
|
message = %error.message(),
|
|
"mnote Hermes tool call failed"
|
|
);
|
|
if matches!(
|
|
error.code(),
|
|
"mnote_tool_ai_scope_write_forbidden" | "mnote_tool_shared_read_write_forbidden"
|
|
) {
|
|
record_local_agent_write_rejection(
|
|
&context,
|
|
&input,
|
|
&profile,
|
|
error.code(),
|
|
error.message(),
|
|
);
|
|
}
|
|
audit_push(json!({
|
|
"phase": "failed",
|
|
"traceId": trace_id,
|
|
"sessionId": input.session_id,
|
|
"runId": input.run_id,
|
|
"toolCallId": tool_call_id,
|
|
"toolName": input.tool_name,
|
|
"workspaceId": workspace_id,
|
|
"documentId": document_id,
|
|
"actorId": input.actor_id,
|
|
"status": error.status().as_u16(),
|
|
"message": error.message()
|
|
}));
|
|
}
|
|
let mut result = result?;
|
|
if !dry_run && !is_read_tool(&input.tool_name) {
|
|
record_local_agent_tool_write(&context, &input, &profile, &result);
|
|
}
|
|
let evidence_receipt = if is_evidence_receipt_tool(&input.tool_name) {
|
|
let evidence_ids = evidence_ids_for_result(&result);
|
|
let receipt = json!({
|
|
"schema": "mnote.agent_run_receipt.evidence.v1",
|
|
"traceId": trace_id.clone(),
|
|
"sessionId": input.session_id.clone(),
|
|
"runId": input.run_id.clone(),
|
|
"toolCallId": tool_call_id.clone(),
|
|
"toolName": input.tool_name.clone(),
|
|
"workspaceId": workspace_id.clone(),
|
|
"documentId": document_id.clone(),
|
|
"rootUri": input.effective_root_uri(),
|
|
"evidenceIds": evidence_ids,
|
|
});
|
|
if let Some(result_object) = result.as_object_mut() {
|
|
result_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone());
|
|
result_object.insert("runReceipt".into(), receipt.clone());
|
|
}
|
|
Some(receipt)
|
|
} else {
|
|
None
|
|
};
|
|
let command_id = result.get("commandId").cloned().unwrap_or(Value::Null);
|
|
info!(
|
|
trace_id = %trace_id,
|
|
session_id = input.session_id.as_deref().unwrap_or(""),
|
|
run_id = input.run_id.as_deref().unwrap_or(""),
|
|
tool_call_id = %tool_call_id,
|
|
tool_name = %input.tool_name,
|
|
workspace_id = workspace_id.as_deref().unwrap_or(""),
|
|
document_id = document_id.as_deref().unwrap_or(""),
|
|
actor_id = input.actor_id.as_deref().unwrap_or(""),
|
|
effect,
|
|
"mnote Hermes tool call completed"
|
|
);
|
|
let mut audit = json!({
|
|
"effect": effect,
|
|
"commandId": command_id,
|
|
"workspaceId": workspace_id,
|
|
"documentId": document_id,
|
|
"actorId": input.actor_id,
|
|
"dryRun": dry_run,
|
|
"idempotencyKey": input.idempotency_key,
|
|
"capabilityScope": input.capability_scope
|
|
});
|
|
if let Some(receipt) = &evidence_receipt {
|
|
if let Some(audit_object) = audit.as_object_mut() {
|
|
audit_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone());
|
|
audit_object.insert("runReceipt".into(), receipt.clone());
|
|
}
|
|
}
|
|
let mut response_body = json!({
|
|
"ok": true,
|
|
"toolName": input.tool_name,
|
|
"toolCallId": tool_call_id,
|
|
"traceId": trace_id,
|
|
"sessionId": input.session_id,
|
|
"runId": input.run_id,
|
|
"result": result,
|
|
"audit": audit,
|
|
"error": null
|
|
});
|
|
if let Some(receipt) = &evidence_receipt {
|
|
if let Some(response_object) = response_body.as_object_mut() {
|
|
response_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone());
|
|
response_object.insert("runReceipt".into(), receipt.clone());
|
|
}
|
|
}
|
|
if let Some(key) = idempotency_key {
|
|
idempotency_cache_put(key, response_body.clone());
|
|
}
|
|
audit_push(json!({
|
|
"phase": "completed",
|
|
"traceId": response_body.get("traceId").cloned().unwrap_or(Value::Null),
|
|
"sessionId": response_body.get("sessionId").cloned().unwrap_or(Value::Null),
|
|
"runId": response_body.get("runId").cloned().unwrap_or(Value::Null),
|
|
"toolCallId": response_body.get("toolCallId").cloned().unwrap_or(Value::Null),
|
|
"toolName": response_body.get("toolName").cloned().unwrap_or(Value::Null),
|
|
"audit": response_body.get("audit").cloned().unwrap_or(Value::Null)
|
|
}));
|
|
Ok(response_body)
|
|
}
|
|
|
|
fn ensure_tool_capability_scope(
|
|
context: &RequestContext,
|
|
input: &ToolCallInput,
|
|
) -> Result<(), WebError> {
|
|
let required = required_capability_scope(&input.tool_name);
|
|
if required.is_empty()
|
|
|| declared_capability_scope_covers(input.capability_scope.as_ref(), &required)
|
|
{
|
|
return Ok(());
|
|
}
|
|
Err(WebError::new(
|
|
StatusCode::FORBIDDEN,
|
|
"mnote_tool_capability_scope_forbidden",
|
|
"调用方声明的 capabilityScope 未覆盖目标 mnote tool 所需能力",
|
|
)
|
|
.with_context(context)
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
|
|
}
|
|
|
|
fn required_capability_scope(tool_name: &str) -> Vec<String> {
|
|
manifest::manifest()
|
|
.get("tools")
|
|
.and_then(Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name))
|
|
.and_then(|tool| tool.get("capabilityScope").and_then(Value::as_array))
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(Value::as_str)
|
|
.map(normalize_capability_scope)
|
|
.filter(|value| !value.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
|
|
let Some(declared) = declared else {
|
|
// 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。
|
|
return true;
|
|
};
|
|
let declared = declared
|
|
.iter()
|
|
.map(|value| normalize_capability_scope(value))
|
|
.filter(|value| !value.is_empty())
|
|
.collect::<Vec<_>>();
|
|
required.iter().all(|scope| {
|
|
declared
|
|
.iter()
|
|
.any(|candidate| capability_scope_satisfies(candidate, scope))
|
|
})
|
|
}
|
|
|
|
fn capability_scope_satisfies(candidate: &str, required: &str) -> bool {
|
|
if candidate == required {
|
|
return true;
|
|
}
|
|
required
|
|
.strip_suffix(".read")
|
|
.map(|prefix| format!("{prefix}.write"))
|
|
.as_deref()
|
|
== Some(candidate)
|
|
}
|
|
|
|
fn normalize_capability_scope(value: &str) -> String {
|
|
value.trim().to_ascii_lowercase()
|
|
}
|
|
|
|
fn is_read_tool(tool_name: &str) -> bool {
|
|
matches!(
|
|
tool_name,
|
|
"mnote.page.get"
|
|
| "mnote.skill.read"
|
|
| "mnote.context.snapshot"
|
|
| "mnote.context.read_current_page"
|
|
| "mnote.context.resolve_target"
|
|
| "mnote.doc.fetch"
|
|
| "mnote.doc.find"
|
|
| "docs_search"
|
|
| "docs_read"
|
|
| "mnote.evidence.search"
|
|
| "mnote.evidence.read"
|
|
| "mnote.evidence.open"
|
|
| "mnote.knowledge_rag.status"
|
|
| "mnote.knowledge_rag.query"
|
|
| "mnote.knowledge_rag.open_reference"
|
|
| "mnote.index.status"
|
|
| "mnote.index.refresh"
|
|
| "mnote.block.fetch"
|
|
| "mnote.mindmap.fetch"
|
|
| "mnote.office.fetch_summary"
|
|
| "mnote.office.propose_changes"
|
|
| "mnote.onlyoffice.session.current"
|
|
| "mnote.onlyoffice.capabilities"
|
|
| "mnote.onlyoffice.selection.get"
|
|
| "mnote.onlyoffice.document.export"
|
|
| "mnote.onlyoffice.document.get_comments"
|
|
| "mnote.onlyoffice.sheet.get_sheets"
|
|
| "mnote.onlyoffice.sheet.get_range"
|
|
| "mnote.onlyoffice.sheet.get_range_values"
|
|
| "mnote.onlyoffice.sheet.get_values"
|
|
| "mnote.onlyoffice.presentation.get_slides"
|
|
| "mnote.onlyoffice.presentation.get_slide_texts"
|
|
| "mnote.onlyoffice.presentation.get_shapes"
|
|
)
|
|
}
|
|
|
|
fn is_evidence_receipt_tool(tool_name: &str) -> bool {
|
|
matches!(
|
|
tool_name,
|
|
"docs_search"
|
|
| "docs_read"
|
|
| "mnote.evidence.search"
|
|
| "mnote.evidence.read"
|
|
| "mnote.evidence.open"
|
|
| "mnote.knowledge_rag.status"
|
|
| "mnote.knowledge_rag.query"
|
|
| "mnote.knowledge_rag.open_reference"
|
|
)
|
|
}
|
|
|
|
fn evidence_ids_for_result(result: &Value) -> Vec<String> {
|
|
let mut ids = Vec::new();
|
|
collect_evidence_ids(result, &mut ids);
|
|
ids
|
|
}
|
|
|
|
fn collect_evidence_ids(value: &Value, ids: &mut Vec<String>) {
|
|
match value {
|
|
Value::Object(object) => {
|
|
if let Some(id) = object
|
|
.get("evidenceId")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
let id = id.to_string();
|
|
if !ids.iter().any(|existing| existing == &id) {
|
|
ids.push(id);
|
|
}
|
|
}
|
|
for child in object.values() {
|
|
collect_evidence_ids(child, ids);
|
|
}
|
|
}
|
|
Value::Array(items) => {
|
|
for item in items {
|
|
collect_evidence_ids(item, ids);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn is_shared_read_scope(input: &ToolCallInput) -> bool {
|
|
let direct = input
|
|
.arg_string("permissionLevel")
|
|
.or_else(|| input.arg_string("permission_level"));
|
|
if matches!(direct.as_deref(), Some("shared_read")) {
|
|
return true;
|
|
}
|
|
input
|
|
.arg_value("aiAccessScope")
|
|
.and_then(|value| {
|
|
value
|
|
.get("permissionLevel")
|
|
.or_else(|| value.get("permission_level"))
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.map(ToOwned::to_owned)
|
|
})
|
|
.as_deref()
|
|
== Some("shared_read")
|
|
}
|
|
|
|
fn audit_log() -> &'static Mutex<Vec<Value>> {
|
|
static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
|
|
LOG.get_or_init(|| Mutex::new(Vec::new()))
|
|
}
|
|
|
|
fn audit_push(event: Value) {
|
|
if let Ok(mut log) = audit_log().lock() {
|
|
log.push(event.clone());
|
|
let overflow = log.len().saturating_sub(500);
|
|
if overflow > 0 {
|
|
log.drain(0..overflow);
|
|
}
|
|
}
|
|
if let Err(error) = audit_append_persistent(&event) {
|
|
warn!(message = %error, "mnote Hermes tool audit 持久化失败");
|
|
}
|
|
}
|
|
|
|
fn audit_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec<Value> {
|
|
let Ok(log) = audit_log().lock() else {
|
|
return Vec::new();
|
|
};
|
|
log.iter()
|
|
.filter(|event| {
|
|
trace_id
|
|
.map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected))
|
|
.unwrap_or(true)
|
|
})
|
|
.filter(|event| {
|
|
tool_call_id
|
|
.map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected))
|
|
.unwrap_or(true)
|
|
})
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn audit_log_path() -> PathBuf {
|
|
env::var("MNOTE_HERMES_TOOL_AUDIT_LOG")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from("tmp").join("mnote-hermes-tool-audit.jsonl"))
|
|
}
|
|
|
|
fn audit_append_persistent(event: &Value) -> Result<(), String> {
|
|
let path = audit_log_path();
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
|
}
|
|
let mut file = OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&path)
|
|
.map_err(|error| error.to_string())?;
|
|
let line = serde_json::to_string(event).map_err(|error| error.to_string())?;
|
|
writeln!(file, "{line}").map_err(|error| error.to_string())
|
|
}
|
|
|
|
fn audit_persisted_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec<Value> {
|
|
let path = audit_log_path();
|
|
let Ok(file) = File::open(path) else {
|
|
return Vec::new();
|
|
};
|
|
BufReader::new(file)
|
|
.lines()
|
|
.map_while(Result::ok)
|
|
.filter_map(|line| serde_json::from_str::<Value>(&line).ok())
|
|
.filter(|event| {
|
|
trace_id
|
|
.map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected))
|
|
.unwrap_or(true)
|
|
})
|
|
.filter(|event| {
|
|
tool_call_id
|
|
.map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected))
|
|
.unwrap_or(true)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn idempotency_cache() -> &'static Mutex<HashMap<String, Value>> {
|
|
static CACHE: OnceLock<Mutex<HashMap<String, Value>>> = OnceLock::new();
|
|
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
|
}
|
|
|
|
fn idempotency_cache_key(
|
|
input: &ToolCallInput,
|
|
workspace_id: Option<&str>,
|
|
document_id: Option<&str>,
|
|
dry_run: bool,
|
|
) -> Option<String> {
|
|
if dry_run || is_read_tool(&input.tool_name) {
|
|
return None;
|
|
}
|
|
let idempotency_key = input.idempotency_key.as_deref()?.trim();
|
|
if idempotency_key.is_empty() {
|
|
return None;
|
|
}
|
|
Some(format!(
|
|
"{}|{}|{}|{}",
|
|
input.tool_name,
|
|
workspace_id.unwrap_or(""),
|
|
document_id.unwrap_or(""),
|
|
idempotency_key
|
|
))
|
|
}
|
|
|
|
fn idempotency_cache_get(key: &str) -> Option<Value> {
|
|
idempotency_cache().lock().ok()?.get(key).cloned()
|
|
}
|
|
|
|
fn idempotency_cache_put(key: String, response: Value) {
|
|
if let Ok(mut cache) = idempotency_cache().lock() {
|
|
cache.insert(key, response);
|
|
}
|
|
}
|
|
|
|
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
|
|
let actor = context.auth.actor_id.trim();
|
|
let has_actor = actor != "anonymous" && actor != "hermes" && !actor.is_empty();
|
|
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
|
|
return Ok(());
|
|
}
|
|
Err(WebError::new(
|
|
StatusCode::UNAUTHORIZED,
|
|
"mnote_tool_unauthorized",
|
|
"mnote Hermes tool 需要登录后访问",
|
|
)
|
|
.with_context(context)
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
|
|
}
|
|
|
|
fn authenticated_tool_context(
|
|
context: &RequestContext,
|
|
input: &ToolCallInput,
|
|
) -> Result<RequestContext, WebError> {
|
|
let context_actor = context.auth.actor_id.trim();
|
|
if !context_actor.is_empty() && context_actor != "anonymous" && context_actor != "hermes" {
|
|
return Ok(context.clone());
|
|
}
|
|
let has_cookie_or_auth =
|
|
context.auth.authorization.is_some() || context.auth.cookie_header.is_some();
|
|
let actor_id = input
|
|
.actor_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty() && *value != "anonymous" && *value != "hermes")
|
|
.ok_or_else(|| {
|
|
WebError::new(
|
|
StatusCode::UNAUTHORIZED,
|
|
"mnote_tool_unauthorized",
|
|
"mnote Hermes tool 需要有效 actorId,不能使用 hermes/anonymous",
|
|
)
|
|
.with_context(context)
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")
|
|
})?;
|
|
if has_cookie_or_auth {
|
|
let mut next = context.clone();
|
|
next.auth.actor_id = actor_id.to_string();
|
|
next.auth.actor_type = input
|
|
.arg_string("actorType")
|
|
.or_else(|| input.arg_string("actor_type"))
|
|
.unwrap_or_else(|| "user".into());
|
|
if next.auth.session_id.is_none() {
|
|
next.auth.session_id = input.session_id.clone();
|
|
}
|
|
return Ok(next);
|
|
}
|
|
let has_run_identity = input
|
|
.session_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.is_some()
|
|
&& input
|
|
.run_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.is_some()
|
|
&& input
|
|
.tool_call_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.is_some()
|
|
&& input
|
|
.trace_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.is_some();
|
|
if !has_run_identity {
|
|
return Err(WebError::new(
|
|
StatusCode::UNAUTHORIZED,
|
|
"mnote_tool_unauthorized",
|
|
"mnote Hermes tool 委托调用缺少 sessionId/runId/toolCallId/traceId",
|
|
)
|
|
.with_context(context)
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"));
|
|
}
|
|
let mut next = context.clone();
|
|
next.auth.actor_id = actor_id.to_string();
|
|
next.auth.actor_type = "user".into();
|
|
if next.auth.session_id.is_none() {
|
|
next.auth.session_id = input.session_id.clone();
|
|
}
|
|
Ok(next)
|
|
}
|
|
|
|
fn ensure_workspace_context(
|
|
context: &RequestContext,
|
|
input_workspace_id: Option<&str>,
|
|
) -> Result<(), WebError> {
|
|
let Some(input_workspace_id) = input_workspace_id
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let Some(header_workspace_id) = context
|
|
.workspace
|
|
.workspace_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
else {
|
|
return Ok(());
|
|
};
|
|
if input_workspace_id == header_workspace_id {
|
|
return Ok(());
|
|
}
|
|
Err(WebError::new(
|
|
StatusCode::FORBIDDEN,
|
|
"workspace_context_conflict",
|
|
"mnote Hermes tool 请求的 workspaceId 与请求上下文不一致",
|
|
)
|
|
.with_context(context)
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
|
|
}
|
|
|
|
fn stamp_tool_headers() -> HeaderMap {
|
|
let mut headers = HeaderMap::new();
|
|
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
|
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
|
}
|
|
if let Ok(name) = HeaderName::from_lowercase(HEADER_HERMES_TOOL_OWNER.as_bytes()) {
|
|
headers.insert(name, HeaderValue::from_static("mnote-web-hermes-tools"));
|
|
}
|
|
headers
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::app::{build_app, AppConfig, AppState};
|
|
use axum::body::{to_bytes, Body};
|
|
use axum::http::{Request, StatusCode};
|
|
use serde_json::{json, Value};
|
|
use std::collections::BTreeMap;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::Mutex;
|
|
use tower::util::ServiceExt;
|
|
|
|
fn env_lock() -> &'static Mutex<()> {
|
|
crate::test_support::hermes_env_lock()
|
|
}
|
|
|
|
fn app() -> axum::Router {
|
|
build_app(AppState::new(AppConfig {
|
|
service_name: "mnote-web".into(),
|
|
service_version: "0.1.0".into(),
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
enable_legacy_next_compat: true,
|
|
enable_debug_shell_routes: false,
|
|
enable_editor_actor: true,
|
|
hermes_base_path: "/api/hermes".into(),
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
convex_url: None,
|
|
convex_admin_key: None,
|
|
allow_dev_fixtures: true,
|
|
query_fixtures_json: Some(
|
|
r#"{
|
|
"documents:getMeta": {
|
|
"id": "doc_1",
|
|
"workspace_id": "ws_demo",
|
|
"title": "服务端页面",
|
|
"can_edit": true,
|
|
"wide_layout": false,
|
|
"use_small_text": false,
|
|
"show_toc": true,
|
|
"block_count": 1
|
|
},
|
|
"documents:getContent": {
|
|
"title": "服务端页面",
|
|
"content": [
|
|
{
|
|
"id": "heading_1",
|
|
"type": "heading",
|
|
"props": { "level": 1 },
|
|
"content": [{ "type": "text", "text": "章节一" }]
|
|
},
|
|
{
|
|
"id": "p_1",
|
|
"type": "paragraph",
|
|
"content": [{ "type": "text", "text": "第一段" }]
|
|
},
|
|
{
|
|
"id": "p_2",
|
|
"type": "paragraph",
|
|
"content": [{ "type": "text", "text": "第二段" }]
|
|
}
|
|
],
|
|
"revision": 7,
|
|
"conflict_detection_key": "doc_1:7"
|
|
}
|
|
}"#
|
|
.into(),
|
|
),
|
|
mutation_fixtures_json: Some(
|
|
r#"{
|
|
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
|
|
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
|
|
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
|
|
}"#
|
|
.into(),
|
|
),
|
|
dev_user_id: "dev-user".into(),
|
|
dev_user_name: "开发用户".into(),
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
}))
|
|
}
|
|
|
|
fn app_with_content(content: Value) -> axum::Router {
|
|
build_app(AppState::new(AppConfig {
|
|
service_name: "mnote-web".into(),
|
|
service_version: "0.1.0".into(),
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
enable_legacy_next_compat: true,
|
|
enable_debug_shell_routes: false,
|
|
enable_editor_actor: true,
|
|
hermes_base_path: "/api/hermes".into(),
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
convex_url: None,
|
|
convex_admin_key: None,
|
|
allow_dev_fixtures: true,
|
|
query_fixtures_json: Some(
|
|
json!({
|
|
"documents:getMeta": {
|
|
"id": "doc_1",
|
|
"workspace_id": "ws_demo",
|
|
"title": "复杂块阻断页面",
|
|
"can_edit": true,
|
|
"wide_layout": false,
|
|
"use_small_text": false,
|
|
"show_toc": true,
|
|
"block_count": 6
|
|
},
|
|
"documents:getContent": {
|
|
"title": "复杂块阻断页面",
|
|
"content": content,
|
|
"revision": 7,
|
|
"conflict_detection_key": "doc_1:7"
|
|
}
|
|
})
|
|
.to_string(),
|
|
),
|
|
mutation_fixtures_json: Some(
|
|
r#"{
|
|
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
|
|
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
|
|
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
|
|
}"#
|
|
.into(),
|
|
),
|
|
dev_user_id: "dev-user".into(),
|
|
dev_user_name: "开发用户".into(),
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
}))
|
|
}
|
|
|
|
fn write_mindmap_apply_fixture(test_name: &str, content: Value) -> (PathBuf, String) {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-resource-mindmap-{test_name}-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("maps")).expect("maps");
|
|
fs::write(
|
|
root.join("maps").join("idea.mindmap.json"),
|
|
serde_json::to_string_pretty(&content).expect("mindmap json"),
|
|
)
|
|
.expect("mindmap");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
(root, root_uri)
|
|
}
|
|
|
|
async fn post_mindmap_apply_ops(
|
|
root_uri: &str,
|
|
dry_run: bool,
|
|
args: Value,
|
|
id_suffix: &str,
|
|
) -> axum::response::Response {
|
|
app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.mindmap.apply_ops",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": format!("sess_mindmap_apply_{id_suffix}"),
|
|
"runId": format!("run_mindmap_apply_{id_suffix}"),
|
|
"toolCallId": format!("call_mindmap_apply_{id_suffix}"),
|
|
"traceId": format!("trace_mindmap_apply_{id_suffix}"),
|
|
"idempotencyKey": format!("idem_mindmap_apply_{id_suffix}"),
|
|
"dryRun": dry_run,
|
|
"args": args
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response")
|
|
}
|
|
|
|
async fn call_tool_ok(payload: Value) -> Value {
|
|
let app = app();
|
|
maybe_register_onlyoffice_test_session(&app, &payload).await;
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(payload.to_string()))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
serde_json::from_slice(&body).expect("json")
|
|
}
|
|
|
|
async fn call_tool_ok_with_app(app: axum::Router, payload: Value) -> Value {
|
|
maybe_register_onlyoffice_test_session(&app, &payload).await;
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(payload.to_string()))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
serde_json::from_slice(&body).expect("json")
|
|
}
|
|
|
|
async fn maybe_register_onlyoffice_test_session(app: &axum::Router, payload: &Value) {
|
|
let Some(tool_name) = payload.get("toolName").and_then(Value::as_str) else {
|
|
return;
|
|
};
|
|
if !tool_name.starts_with("mnote.onlyoffice.")
|
|
|| tool_name == "mnote.onlyoffice.capabilities"
|
|
|| tool_name == "mnote.onlyoffice.session.current"
|
|
{
|
|
return;
|
|
}
|
|
let Some(session_id) = payload
|
|
.get("args")
|
|
.and_then(|args| {
|
|
args.get("onlyofficeSessionId")
|
|
.or_else(|| args.get("bridgeSessionId"))
|
|
})
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
else {
|
|
return;
|
|
};
|
|
let allowed_resource_id = payload
|
|
.get("args")
|
|
.and_then(|args| args.get("aiAccessScope"))
|
|
.and_then(|scope| {
|
|
scope
|
|
.get("allowedResourceIds")
|
|
.or_else(|| scope.get("allowed_resource_ids"))
|
|
})
|
|
.and_then(Value::as_array)
|
|
.and_then(|values| values.iter().find_map(Value::as_str))
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or(session_id);
|
|
let token = format!("token-{session_id}");
|
|
let register = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/onlyoffice/bridge/session")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"sessionId": session_id,
|
|
"token": token,
|
|
"editorType": "cell",
|
|
"documentId": "doc_1",
|
|
"assetId": allowed_resource_id,
|
|
"fileType": "xlsx"
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("register response");
|
|
assert_eq!(register.status(), StatusCode::OK);
|
|
}
|
|
|
|
async fn block_revision_ref(block_id: &str) -> String {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.doc.fetch",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_ref",
|
|
"runId": "run_ref",
|
|
"toolCallId": format!("call_ref_{block_id}"),
|
|
"traceId": format!("trace_ref_{block_id}"),
|
|
"capabilityScope": ["page.read"],
|
|
"args": {"scope": "full", "detail": "with_ids"}
|
|
}))
|
|
.await;
|
|
payload["result"]["blocks"]
|
|
.as_array()
|
|
.expect("blocks")
|
|
.iter()
|
|
.find(|block| block["blockId"] == json!(block_id))
|
|
.and_then(|block| block["revisionRef"].as_str())
|
|
.expect("revisionRef")
|
|
.to_string()
|
|
}
|
|
|
|
async fn block_revision_refs_with_app(app: axum::Router) -> BTreeMap<String, String> {
|
|
let payload = call_tool_ok_with_app(
|
|
app,
|
|
json!({
|
|
"toolName": "mnote.doc.fetch",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_ref_complex",
|
|
"runId": "run_ref_complex",
|
|
"toolCallId": "call_ref_complex",
|
|
"traceId": "trace_ref_complex",
|
|
"capabilityScope": ["page.read"],
|
|
"args": {"scope": "full", "detail": "with_ids"}
|
|
}),
|
|
)
|
|
.await;
|
|
payload["result"]["blocks"]
|
|
.as_array()
|
|
.expect("blocks")
|
|
.iter()
|
|
.filter_map(|block| {
|
|
Some((
|
|
block["blockId"].as_str()?.to_string(),
|
|
block["revisionRef"].as_str()?.to_string(),
|
|
))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_call_rejects_declared_scope_that_does_not_cover_tool() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.save",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_scope",
|
|
"runId": "run_scope",
|
|
"toolCallId": "call_scope",
|
|
"traceId": "trace_scope",
|
|
"actorId": "user_1",
|
|
"capabilityScope": ["page.read"],
|
|
"dryRun": true,
|
|
"idempotencyKey": "scope-mismatch",
|
|
"args": {"content": "不会写入"}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_capability_scope_forbidden")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_manifest_returns_first_batch_tools() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/hermes/tools/mnote/manifest")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let tools = payload["manifest"]["tools"].as_array().expect("tools");
|
|
assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.get"));
|
|
assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.save"));
|
|
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch"));
|
|
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find"));
|
|
assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.doc.plan_update"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.block.replace"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.block.insert_after"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.block.delete"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.block.move_after"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.mindmap.fetch"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops"));
|
|
let mindmap_create = tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == "mnote.mindmap.create_from_outline")
|
|
.expect("mindmap create_from_outline tool");
|
|
assert_eq!(
|
|
mindmap_create["inputSchema"]["properties"]["outline"]["type"],
|
|
"array"
|
|
);
|
|
assert!(mindmap_create["capabilityScope"]
|
|
.as_array()
|
|
.expect("capability scope")
|
|
.iter()
|
|
.any(|scope| scope == "mindmap.write"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.office.fetch_summary"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.office.propose_changes"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.session.current"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.capabilities"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.set_value"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.document.export"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.get_values"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.batch_set_values"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.document.search_replace"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.document.insert_table"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.document.get_comments"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.document.add_comment"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.get_sheets"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.get_range_values"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.set_range_values"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.format_range"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.set_dimensions"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.sort_range"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.add_chart"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.presentation.get_slide_texts"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.presentation.get_shapes"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.presentation.replace_text"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.presentation.set_shape_text"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.presentation.delete_slide"));
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.onlyoffice.presentation.add_table"));
|
|
let page_save = tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == "mnote.page.save")
|
|
.expect("page save tool");
|
|
assert_eq!(page_save["status"], "available");
|
|
assert_eq!(
|
|
payload["manifest"]["schemaVersion"],
|
|
"mnote.hermes_tool_manifest.v1"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_manifest_exposes_mnote_capability_packs() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/hermes/tools/mnote/manifest")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let manifest = &payload["manifest"];
|
|
let capabilities = manifest["capabilities"].as_array().expect("capabilities");
|
|
assert!(!capabilities
|
|
.iter()
|
|
.any(|capability| capability["id"] == "mnote-local-index"));
|
|
assert!(capabilities
|
|
.iter()
|
|
.any(|capability| capability["id"] == "mnote-knowledge-rag"));
|
|
assert!(capabilities
|
|
.iter()
|
|
.any(|capability| capability["id"] == "mnote-onlyoffice-live"));
|
|
|
|
let tools = manifest["tools"].as_array().expect("tools");
|
|
assert!(!tools
|
|
.iter()
|
|
.any(|tool| tool["name"] == "mnote.index.status"));
|
|
let knowledge_rag_query = tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == "mnote.knowledge_rag.query")
|
|
.expect("knowledge rag query tool");
|
|
assert!(knowledge_rag_query["capabilityIds"]
|
|
.as_array()
|
|
.expect("knowledge rag capability ids")
|
|
.iter()
|
|
.any(|id| id == "mnote-knowledge-rag"));
|
|
|
|
let onlyoffice_batch_set = tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == "mnote.onlyoffice.sheet.batch_set_values")
|
|
.expect("onlyoffice batch set tool");
|
|
assert_eq!(
|
|
onlyoffice_batch_set["capabilityId"],
|
|
"mnote-onlyoffice-live"
|
|
);
|
|
assert!(onlyoffice_batch_set["capabilityIds"]
|
|
.as_array()
|
|
.expect("onlyoffice capability ids")
|
|
.iter()
|
|
.any(|id| id == "mnote-onlyoffice-live"));
|
|
|
|
let context_snapshot = tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == "mnote.context.snapshot")
|
|
.expect("context snapshot tool");
|
|
assert!(
|
|
context_snapshot["capabilityIds"]
|
|
.as_array()
|
|
.expect("context capability ids")
|
|
.len()
|
|
> 1
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_session_current_reads_registered_bridge_session() {
|
|
let app = app();
|
|
let bridge_session_id = format!("mnote-oo-tool-test-{}", std::process::id());
|
|
let bridge_token = format!("token-{bridge_session_id}");
|
|
let register = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/onlyoffice/bridge/session")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"sessionId": bridge_session_id,
|
|
"token": bridge_token,
|
|
"editorType": "cell",
|
|
"documentId": "doc_meta",
|
|
"assetId": "asset_meta",
|
|
"fileType": "xlsx"
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("register response");
|
|
assert_eq!(register.status(), StatusCode::OK);
|
|
|
|
let payload = call_tool_ok_with_app(
|
|
app,
|
|
json!({
|
|
"toolName": "mnote.onlyoffice.session.current",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_current",
|
|
"runId": "run_oo_current",
|
|
"toolCallId": "call_oo_current",
|
|
"traceId": "trace_oo_current",
|
|
"capabilityScope": ["office.read"],
|
|
"args": {
|
|
"onlyofficeSessionId": bridge_session_id,
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read",
|
|
"allowedResourceIds": ["asset_meta"]
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(payload["result"]["schema"], "mnote.onlyoffice.session.v1");
|
|
assert_eq!(payload["result"]["session"]["sessionId"], bridge_session_id);
|
|
assert_eq!(payload["result"]["session"]["editorType"], "cell");
|
|
assert_eq!(payload["result"]["session"]["documentId"], "doc_meta");
|
|
assert_eq!(payload["result"]["session"]["assetId"], "asset_meta");
|
|
assert_eq!(payload["result"]["session"]["fileType"], "xlsx");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_session_current_requires_explicit_authorized_scope() {
|
|
let app = app();
|
|
let bridge_session_id = format!("mnote-oo-current-scope-{}", std::process::id());
|
|
let bridge_token = format!("token-{bridge_session_id}");
|
|
let register = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/onlyoffice/bridge/session")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"sessionId": bridge_session_id,
|
|
"token": bridge_token,
|
|
"editorType": "word",
|
|
"documentId": "doc_current_b",
|
|
"assetId": "asset_current_b",
|
|
"fileType": "docx"
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("register response");
|
|
assert_eq!(register.status(), StatusCode::OK);
|
|
|
|
let implicit_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.onlyoffice.session.current",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_current_implicit",
|
|
"runId": "run_oo_current_implicit",
|
|
"toolCallId": "call_oo_current_implicit",
|
|
"traceId": "trace_oo_current_implicit",
|
|
"capabilityScope": ["office.read"],
|
|
"args": {
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read",
|
|
"allowedResourceIds": ["asset_current_b"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("implicit response");
|
|
assert_eq!(implicit_response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
implicit_response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_onlyoffice_session_explicit_required")
|
|
);
|
|
|
|
let forbidden_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.onlyoffice.session.current",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_current_forbidden",
|
|
"runId": "run_oo_current_forbidden",
|
|
"toolCallId": "call_oo_current_forbidden",
|
|
"traceId": "trace_oo_current_forbidden",
|
|
"capabilityScope": ["office.read"],
|
|
"args": {
|
|
"onlyofficeSessionId": bridge_session_id,
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read",
|
|
"allowedResourceIds": ["asset_current_a"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("forbidden response");
|
|
assert_eq!(forbidden_response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
forbidden_response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_onlyoffice_resource_scope_forbidden")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_capabilities_lists_shape_and_range_actions() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.capabilities",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_caps",
|
|
"runId": "run_oo_caps",
|
|
"toolCallId": "call_oo_caps",
|
|
"traceId": "trace_oo_caps",
|
|
"capabilityScope": ["office.read"]
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.bridge_capabilities.v1"
|
|
);
|
|
let actions = payload["result"]["actions"].as_array().expect("actions");
|
|
assert!(actions
|
|
.iter()
|
|
.any(|action| action == "sheet.set_range_values"));
|
|
assert!(actions.iter().any(|action| action == "sheet.format_range"));
|
|
assert!(actions
|
|
.iter()
|
|
.any(|action| action == "sheet.set_dimensions"));
|
|
assert!(actions.iter().any(|action| action == "sheet.sort_range"));
|
|
assert!(actions.iter().any(|action| action == "sheet.add_chart"));
|
|
assert!(actions
|
|
.iter()
|
|
.any(|action| action == "presentation.set_shape_text"));
|
|
assert!(actions
|
|
.iter()
|
|
.any(|action| action == "presentation.delete_slide"));
|
|
assert!(actions
|
|
.iter()
|
|
.any(|action| action == "presentation.add_table"));
|
|
assert!(actions
|
|
.iter()
|
|
.any(|action| action == "presentation.clear_slide"));
|
|
assert!(actions
|
|
.iter()
|
|
.any(|action| action == "presentation.add_shape"));
|
|
assert!(actions
|
|
.iter()
|
|
.any(|action| action == "document.insert_table"));
|
|
assert!(actions
|
|
.iter()
|
|
.any(|action| action == "document.get_comments"));
|
|
assert!(actions
|
|
.iter()
|
|
.any(|action| action == "document.add_comment"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_selection_get_round_trips_bridge_command() {
|
|
let app = app();
|
|
let bridge_session_id = format!("mnote-oo-selection-test-{}", std::process::id());
|
|
let bridge_token = format!("token-{bridge_session_id}");
|
|
let register = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/onlyoffice/bridge/session")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"sessionId": bridge_session_id,
|
|
"token": bridge_token,
|
|
"editorType": "word"
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("register response");
|
|
assert_eq!(register.status(), StatusCode::OK);
|
|
|
|
let worker_app = app.clone();
|
|
let worker_session_id = bridge_session_id.clone();
|
|
let worker = tokio::spawn(async move {
|
|
let next = worker_app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!(
|
|
"/api/onlyoffice/bridge/commands/next?sessionId={worker_session_id}&token={bridge_token}&timeoutMs=5000"
|
|
))
|
|
.body(Body::empty())
|
|
.expect("next request"),
|
|
)
|
|
.await
|
|
.expect("next response");
|
|
assert_eq!(next.status(), StatusCode::OK);
|
|
let body = to_bytes(next.into_body(), usize::MAX)
|
|
.await
|
|
.expect("next body");
|
|
let command: Value = serde_json::from_slice(&body).expect("command json");
|
|
assert_eq!(command["action"], "selection.get");
|
|
let command_id = command["id"].as_str().expect("command id");
|
|
|
|
let posted = worker_app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/onlyoffice/bridge/results")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"sessionId": worker_session_id,
|
|
"token": bridge_token,
|
|
"id": command_id,
|
|
"ok": true,
|
|
"result": {
|
|
"editorType": "word",
|
|
"text": "选中文本"
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("post result request"),
|
|
)
|
|
.await
|
|
.expect("post result response");
|
|
assert_eq!(posted.status(), StatusCode::OK);
|
|
});
|
|
|
|
let payload = call_tool_ok_with_app(
|
|
app,
|
|
json!({
|
|
"toolName": "mnote.onlyoffice.selection.get",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_select",
|
|
"runId": "run_oo_select",
|
|
"toolCallId": "call_oo_select",
|
|
"traceId": "trace_oo_select",
|
|
"capabilityScope": ["office.read"],
|
|
"args": {
|
|
"onlyofficeSessionId": bridge_session_id,
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read",
|
|
"allowedResourceIds": [bridge_session_id]
|
|
},
|
|
"timeoutMs": 5000
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
worker.await.expect("worker");
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_result.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "selection.get");
|
|
assert_eq!(payload["result"]["result"]["text"], "选中文本");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_write_tool_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.sheet.set_value",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_sheet",
|
|
"runId": "run_oo_sheet",
|
|
"toolCallId": "call_oo_sheet",
|
|
"traceId": "trace_oo_sheet",
|
|
"idempotencyKey": "idem_oo_sheet",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"address": "A1",
|
|
"value": "after"
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "sheet.set_value");
|
|
assert_eq!(payload["result"]["payload"]["address"], "A1");
|
|
assert_eq!(payload["result"]["payload"]["value"], "after");
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_write_tool_rejects_implicit_current_session() {
|
|
let app = app();
|
|
let bridge_session_id = format!("mnote-oo-implicit-write-{}", std::process::id());
|
|
let bridge_token = format!("token-{bridge_session_id}");
|
|
let register = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/onlyoffice/bridge/session")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"sessionId": bridge_session_id,
|
|
"token": bridge_token,
|
|
"editorType": "cell",
|
|
"documentId": "doc_office",
|
|
"assetId": "asset_office",
|
|
"fileType": "xlsx"
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("register response");
|
|
assert_eq!(register.status(), StatusCode::OK);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.onlyoffice.sheet.set_value",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_implicit",
|
|
"runId": "run_oo_implicit",
|
|
"toolCallId": "call_oo_implicit",
|
|
"traceId": "trace_oo_implicit",
|
|
"idempotencyKey": "idem_oo_implicit",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"address": "A1",
|
|
"value": "after"
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_onlyoffice_session_explicit_required")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_read_tool_rejects_implicit_current_session() {
|
|
let app = app();
|
|
let bridge_session_id = format!("mnote-oo-read-implicit-{}", std::process::id());
|
|
let bridge_token = format!("token-{bridge_session_id}");
|
|
let register = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/onlyoffice/bridge/session")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"sessionId": bridge_session_id,
|
|
"token": bridge_token,
|
|
"editorType": "word",
|
|
"documentId": "doc_office",
|
|
"assetId": "asset_office",
|
|
"fileType": "docx"
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("register response");
|
|
assert_eq!(register.status(), StatusCode::OK);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.onlyoffice.document.export",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_read_implicit",
|
|
"runId": "run_oo_read_implicit",
|
|
"toolCallId": "call_oo_read_implicit",
|
|
"traceId": "trace_oo_read_implicit",
|
|
"capabilityScope": ["office.read"],
|
|
"args": {
|
|
"format": "markdown",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read",
|
|
"allowedResourceIds": ["asset_office"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_onlyoffice_session_explicit_required")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_write_tool_rejects_missing_resource_scope() {
|
|
let app = app();
|
|
let bridge_session_id = format!("mnote-oo-missing-scope-{}", std::process::id());
|
|
let bridge_token = format!("token-{bridge_session_id}");
|
|
let register = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/onlyoffice/bridge/session")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"sessionId": bridge_session_id,
|
|
"token": bridge_token,
|
|
"editorType": "cell",
|
|
"documentId": "doc_office",
|
|
"assetId": "asset_office",
|
|
"fileType": "xlsx"
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("register response");
|
|
assert_eq!(register.status(), StatusCode::OK);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.onlyoffice.sheet.set_value",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_missing_scope",
|
|
"runId": "run_oo_missing_scope",
|
|
"toolCallId": "call_oo_missing_scope",
|
|
"traceId": "trace_oo_missing_scope",
|
|
"idempotencyKey": "idem_oo_missing_scope",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": bridge_session_id,
|
|
"address": "A1",
|
|
"value": "after"
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_onlyoffice_resource_scope_required")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_write_tool_requires_allowed_resource_scope() {
|
|
let app = app();
|
|
let bridge_session_id = format!("mnote-oo-scope-write-{}", std::process::id());
|
|
let bridge_token = format!("token-{bridge_session_id}");
|
|
let register = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/onlyoffice/bridge/session")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"sessionId": bridge_session_id,
|
|
"token": bridge_token,
|
|
"editorType": "cell",
|
|
"documentId": "doc_office",
|
|
"assetId": "asset_b",
|
|
"fileType": "xlsx"
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("register response");
|
|
assert_eq!(register.status(), StatusCode::OK);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.onlyoffice.sheet.set_value",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_scope",
|
|
"runId": "run_oo_scope",
|
|
"toolCallId": "call_oo_scope",
|
|
"traceId": "trace_oo_scope",
|
|
"idempotencyKey": "idem_oo_scope",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": bridge_session_id,
|
|
"address": "A1",
|
|
"value": "after",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["asset_a"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_onlyoffice_resource_scope_forbidden")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_write_tool_allows_authorized_resource_scope() {
|
|
let app = app();
|
|
let bridge_session_id = format!("mnote-oo-scope-allow-{}", std::process::id());
|
|
let bridge_token = format!("token-{bridge_session_id}");
|
|
let register = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/onlyoffice/bridge/session")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"sessionId": bridge_session_id,
|
|
"token": bridge_token,
|
|
"editorType": "cell",
|
|
"documentId": "doc_office",
|
|
"assetId": "asset_allowed",
|
|
"fileType": "xlsx"
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("register response");
|
|
assert_eq!(register.status(), StatusCode::OK);
|
|
|
|
let payload = call_tool_ok_with_app(
|
|
app,
|
|
json!({
|
|
"toolName": "mnote.onlyoffice.sheet.set_value",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_scope_allow",
|
|
"runId": "run_oo_scope_allow",
|
|
"toolCallId": "call_oo_scope_allow",
|
|
"traceId": "trace_oo_scope_allow",
|
|
"idempotencyKey": "idem_oo_scope_allow",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": bridge_session_id,
|
|
"address": "A1",
|
|
"value": "after",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["resource:onlyoffice:doc_office:asset_allowed"]
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["sessionId"], bridge_session_id);
|
|
assert_eq!(payload["result"]["action"], "sheet.set_value");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_batch_write_tool_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.sheet.batch_set_values",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_batch",
|
|
"runId": "run_oo_batch",
|
|
"toolCallId": "call_oo_batch",
|
|
"traceId": "trace_oo_batch",
|
|
"idempotencyKey": "idem_oo_batch",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"startRow": 1,
|
|
"startCol": 2,
|
|
"values": [["A", "B"], ["C", "D"]]
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "sheet.batch_set_values");
|
|
assert_eq!(payload["result"]["payload"]["startRow"], 1);
|
|
assert_eq!(payload["result"]["payload"]["startCol"], 2);
|
|
assert_eq!(payload["result"]["payload"]["values"][1][1], "D");
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_range_write_tool_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.sheet.set_range_values",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_range",
|
|
"runId": "run_oo_range",
|
|
"toolCallId": "call_oo_range",
|
|
"traceId": "trace_oo_range",
|
|
"idempotencyKey": "idem_oo_range",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"address": "B2:C3",
|
|
"values": [["A", "B"], ["C", "D"]]
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "sheet.set_range_values");
|
|
assert_eq!(payload["result"]["payload"]["address"], "B2:C3");
|
|
assert_eq!(payload["result"]["payload"]["values"][1][1], "D");
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_search_replace_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.document.search_replace",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_doc_replace",
|
|
"runId": "run_oo_doc_replace",
|
|
"toolCallId": "call_oo_doc_replace",
|
|
"traceId": "trace_oo_doc_replace",
|
|
"idempotencyKey": "idem_oo_doc_replace",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"search": "old",
|
|
"replace": "new",
|
|
"matchCase": true
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "document.search_replace");
|
|
assert_eq!(payload["result"]["payload"]["search"], "old");
|
|
assert_eq!(payload["result"]["payload"]["replace"], "new");
|
|
assert_eq!(payload["result"]["payload"]["matchCase"], true);
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_insert_table_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.document.insert_table",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_table",
|
|
"runId": "run_oo_table",
|
|
"toolCallId": "call_oo_table",
|
|
"traceId": "trace_oo_table",
|
|
"idempotencyKey": "idem_oo_table",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"rows": 2,
|
|
"cols": 3,
|
|
"data": [["A", "B", "C"], ["1", "2", "3"]]
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "document.insert_table");
|
|
assert_eq!(payload["result"]["payload"]["rows"], 2);
|
|
assert_eq!(payload["result"]["payload"]["cols"], 3);
|
|
assert_eq!(payload["result"]["payload"]["data"][1][2], "3");
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_add_comment_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.document.add_comment",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_comment",
|
|
"runId": "run_oo_comment",
|
|
"toolCallId": "call_oo_comment",
|
|
"traceId": "trace_oo_comment",
|
|
"idempotencyKey": "idem_oo_comment",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"text": "Review this paragraph",
|
|
"author": "Hermes",
|
|
"target": "document_start"
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "document.add_comment");
|
|
assert_eq!(
|
|
payload["result"]["payload"]["text"],
|
|
"Review this paragraph"
|
|
);
|
|
assert_eq!(payload["result"]["payload"]["author"], "Hermes");
|
|
assert_eq!(payload["result"]["payload"]["target"], "document_start");
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_presentation_replace_text_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.presentation.replace_text",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_ppt_replace",
|
|
"runId": "run_oo_ppt_replace",
|
|
"toolCallId": "call_oo_ppt_replace",
|
|
"traceId": "trace_oo_ppt_replace",
|
|
"idempotencyKey": "idem_oo_ppt_replace",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"search": "Quarterly",
|
|
"replace": "Monthly",
|
|
"slideIndex": 0
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "presentation.replace_text");
|
|
assert_eq!(payload["result"]["payload"]["search"], "Quarterly");
|
|
assert_eq!(payload["result"]["payload"]["replace"], "Monthly");
|
|
assert_eq!(payload["result"]["payload"]["slideIndex"], 0);
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_shape_text_tool_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.presentation.set_shape_text",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_ppt_shape",
|
|
"runId": "run_oo_ppt_shape",
|
|
"toolCallId": "call_oo_ppt_shape",
|
|
"traceId": "trace_oo_ppt_shape",
|
|
"idempotencyKey": "idem_oo_ppt_shape",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"slideIndex": 0,
|
|
"shapeIndex": 1,
|
|
"text": "Precise shape text"
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "presentation.set_shape_text");
|
|
assert_eq!(payload["result"]["payload"]["slideIndex"], 0);
|
|
assert_eq!(payload["result"]["payload"]["shapeIndex"], 1);
|
|
assert_eq!(payload["result"]["payload"]["text"], "Precise shape text");
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_sheet_format_tool_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.sheet.format_range",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_sheet_format",
|
|
"runId": "run_oo_sheet_format",
|
|
"toolCallId": "call_oo_sheet_format",
|
|
"traceId": "trace_oo_sheet_format",
|
|
"idempotencyKey": "idem_oo_sheet_format",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"address": "B2:C3",
|
|
"bold": true,
|
|
"fillColor": "#FFE599",
|
|
"fontColor": "#CC0000",
|
|
"horizontalAlign": "center",
|
|
"numberFormat": "0.00"
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "sheet.format_range");
|
|
assert_eq!(payload["result"]["payload"]["address"], "B2:C3");
|
|
assert_eq!(payload["result"]["payload"]["bold"], true);
|
|
assert_eq!(payload["result"]["payload"]["fillColor"], "#FFE599");
|
|
assert_eq!(payload["result"]["payload"]["fontColor"], "#CC0000");
|
|
assert_eq!(payload["result"]["payload"]["horizontalAlign"], "center");
|
|
assert_eq!(payload["result"]["payload"]["numberFormat"], "0.00");
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_sheet_dimensions_tool_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.sheet.set_dimensions",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_sheet_dimensions",
|
|
"runId": "run_oo_sheet_dimensions",
|
|
"toolCallId": "call_oo_sheet_dimensions",
|
|
"traceId": "trace_oo_sheet_dimensions",
|
|
"idempotencyKey": "idem_oo_sheet_dimensions",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"columnIndex": 2,
|
|
"columnWidth": 24,
|
|
"rowIndex": 3,
|
|
"rowHeight": 28
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "sheet.set_dimensions");
|
|
assert_eq!(payload["result"]["payload"]["columnIndex"], 2);
|
|
assert_eq!(payload["result"]["payload"]["columnWidth"], 24.0);
|
|
assert_eq!(payload["result"]["payload"]["rowIndex"], 3);
|
|
assert_eq!(payload["result"]["payload"]["rowHeight"], 28.0);
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_delete_slide_tool_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.presentation.delete_slide",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_ppt_delete",
|
|
"runId": "run_oo_ppt_delete",
|
|
"toolCallId": "call_oo_ppt_delete",
|
|
"traceId": "trace_oo_ppt_delete",
|
|
"idempotencyKey": "idem_oo_ppt_delete",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"slideIndex": 1
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "presentation.delete_slide");
|
|
assert_eq!(payload["result"]["payload"]["slideIndex"], 1);
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_sheet_sort_tool_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.sheet.sort_range",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_sheet_sort",
|
|
"runId": "run_oo_sheet_sort",
|
|
"toolCallId": "call_oo_sheet_sort",
|
|
"traceId": "trace_oo_sheet_sort",
|
|
"idempotencyKey": "idem_oo_sheet_sort",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"address": "A1:B4",
|
|
"keyColumn": 2,
|
|
"order": "descending",
|
|
"header": true
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "sheet.sort_range");
|
|
assert_eq!(payload["result"]["payload"]["address"], "A1:B4");
|
|
assert_eq!(payload["result"]["payload"]["keyColumn"], 2);
|
|
assert_eq!(payload["result"]["payload"]["order"], "descending");
|
|
assert_eq!(payload["result"]["payload"]["header"], true);
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_sheet_chart_tool_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.sheet.add_chart",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_sheet_chart",
|
|
"runId": "run_oo_sheet_chart",
|
|
"toolCallId": "call_oo_sheet_chart",
|
|
"traceId": "trace_oo_sheet_chart",
|
|
"idempotencyKey": "idem_oo_sheet_chart",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"address": "A1:B4",
|
|
"chartType": "bar",
|
|
"widthMm": 110,
|
|
"heightMm": 70
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "sheet.add_chart");
|
|
assert_eq!(payload["result"]["payload"]["address"], "A1:B4");
|
|
assert_eq!(payload["result"]["payload"]["chartType"], "bar");
|
|
assert_eq!(payload["result"]["payload"]["widthMm"], 110.0);
|
|
assert_eq!(payload["result"]["payload"]["heightMm"], 70.0);
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_presentation_table_tool_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.presentation.add_table",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_ppt_table",
|
|
"runId": "run_oo_ppt_table",
|
|
"toolCallId": "call_oo_ppt_table",
|
|
"traceId": "trace_oo_ppt_table",
|
|
"idempotencyKey": "idem_oo_ppt_table",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"slideIndex": 0,
|
|
"rows": 2,
|
|
"cols": 2,
|
|
"data": [["Metric", "Value"], ["Bridge", "PPT table"]]
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "presentation.add_table");
|
|
assert_eq!(payload["result"]["payload"]["slideIndex"], 0);
|
|
assert_eq!(payload["result"]["payload"]["rows"], 2);
|
|
assert_eq!(payload["result"]["payload"]["cols"], 2);
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_presentation_clear_slide_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.presentation.clear_slide",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_ppt_clear",
|
|
"runId": "run_oo_ppt_clear",
|
|
"toolCallId": "call_oo_ppt_clear",
|
|
"traceId": "trace_oo_ppt_clear",
|
|
"idempotencyKey": "idem_oo_ppt_clear",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"slideIndex": 2
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "presentation.clear_slide");
|
|
assert_eq!(payload["result"]["payload"]["slideIndex"], 2);
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_onlyoffice_presentation_shape_supports_dry_run_plan() {
|
|
let payload = call_tool_ok(json!({
|
|
"toolName": "mnote.onlyoffice.presentation.add_shape",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_oo_ppt_shape_add",
|
|
"runId": "run_oo_ppt_shape_add",
|
|
"toolCallId": "call_oo_ppt_shape_add",
|
|
"traceId": "trace_oo_ppt_shape_add",
|
|
"idempotencyKey": "idem_oo_ppt_shape_add",
|
|
"dryRun": true,
|
|
"capabilityScope": ["office.write"],
|
|
"args": {
|
|
"onlyofficeSessionId": "mnote-oo-dry-run",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["mnote-oo-dry-run"]
|
|
},
|
|
"slideIndex": 1,
|
|
"shapeType": "roundRect",
|
|
"text": "Bridge shape",
|
|
"fillColor": "#4F81BD",
|
|
"widthMm": 150,
|
|
"heightMm": 60
|
|
}
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
payload["result"]["schema"],
|
|
"mnote.onlyoffice.action_plan.v1"
|
|
);
|
|
assert_eq!(payload["result"]["action"], "presentation.add_shape");
|
|
assert_eq!(payload["result"]["payload"]["slideIndex"], 1);
|
|
assert_eq!(payload["result"]["payload"]["shapeType"], "roundRect");
|
|
assert_eq!(payload["result"]["payload"]["text"], "Bridge shape");
|
|
assert_eq!(payload["result"]["payload"]["fillColor"], "#4F81BD");
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_manifest_describes_markdown_edit_write_contract() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/hermes/tools/mnote/manifest")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let tools = payload["manifest"]["tools"].as_array().expect("tools");
|
|
let markdown_edit = tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == "mnote.doc.markdown_edit")
|
|
.expect("markdown edit tool");
|
|
let required = markdown_edit["inputSchema"]["required"]
|
|
.as_array()
|
|
.expect("required");
|
|
for field in [
|
|
"workspaceId",
|
|
"documentId",
|
|
"actorId",
|
|
"dryRun",
|
|
"idempotencyKey",
|
|
] {
|
|
assert!(required.iter().any(|value| value == field));
|
|
}
|
|
assert!(markdown_edit["inputSchema"]["properties"]["operations"].is_object());
|
|
assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object());
|
|
assert!(markdown_edit["inputSchema"]["anyOf"]
|
|
.as_array()
|
|
.expect("anyOf")
|
|
.iter()
|
|
.any(|rule| rule["required"] == json!(["operations"])));
|
|
assert!(markdown_edit["inputSchema"]["anyOf"]
|
|
.as_array()
|
|
.expect("anyOf")
|
|
.iter()
|
|
.any(|rule| rule["required"] == json!(["full_content"])));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_manifest_describes_onlyoffice_live_scope() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/hermes/tools/mnote/manifest")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let tools = payload["manifest"]["tools"].as_array().expect("tools");
|
|
let write_tool = tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == "mnote.onlyoffice.sheet.set_value")
|
|
.expect("onlyoffice write tool");
|
|
let schema = &write_tool["inputSchema"];
|
|
let required = schema["required"].as_array().expect("required");
|
|
assert!(required.iter().any(|value| value == "aiAccessScope"));
|
|
assert!(
|
|
schema["properties"]["aiAccessScope"]["properties"]["allowedResourceIds"].is_object()
|
|
);
|
|
assert_eq!(
|
|
schema["properties"]["aiAccessScope"]["properties"]["allowedResourceIds"]["minItems"],
|
|
1
|
|
);
|
|
let any_of = schema["anyOf"].as_array().expect("anyOf");
|
|
assert!(any_of
|
|
.iter()
|
|
.any(|rule| rule["required"] == json!(["onlyofficeSessionId"])));
|
|
assert!(any_of
|
|
.iter()
|
|
.any(|rule| rule["required"] == json!(["bridgeSessionId"])));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_manifest_marks_write_tools_as_compat_fallbacks() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/hermes/tools/mnote/manifest")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let tools = payload["manifest"]["tools"].as_array().expect("tools");
|
|
let markdown_edit = tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == "mnote.doc.markdown_edit")
|
|
.expect("markdown edit tool");
|
|
let page_save = tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == "mnote.page.save")
|
|
.expect("page save tool");
|
|
|
|
assert!(markdown_edit["description"]
|
|
.as_str()
|
|
.expect("description")
|
|
.contains("compat"));
|
|
assert!(markdown_edit["description"]
|
|
.as_str()
|
|
.expect("description")
|
|
.contains("agent 原生 patch/diff"));
|
|
assert_eq!(
|
|
page_save["annotations"]["requiresWritePermission"],
|
|
Value::Bool(true)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_manifest_describes_block_tools_selection_scope() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/hermes/tools/mnote/manifest")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let tools = payload["manifest"]["tools"].as_array().expect("tools");
|
|
for name in [
|
|
"mnote.block.replace",
|
|
"mnote.block.insert_after",
|
|
"mnote.block.delete",
|
|
"mnote.block.move_after",
|
|
] {
|
|
let tool = tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == name)
|
|
.unwrap_or_else(|| panic!("missing tool {name}"));
|
|
assert!(
|
|
tool["inputSchema"]["properties"]["allowedTargetBlockIds"].is_object(),
|
|
"{name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_page_get_requires_auth() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({"toolName":"mnote.page.get","documentId":"doc_1"}).to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_resource_tools_require_allowed_resource_scope() {
|
|
let root =
|
|
std::env::temp_dir().join(format!("mnote-resource-scope-{}", std::process::id()));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("maps")).expect("maps");
|
|
fs::write(
|
|
root.join("maps").join("idea.mindmap.json"),
|
|
r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#,
|
|
)
|
|
.expect("mindmap");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.mindmap.fetch",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_resource_scope",
|
|
"runId": "run_resource_scope",
|
|
"toolCallId": "call_resource_scope",
|
|
"traceId": "trace_resource_scope",
|
|
"args": {
|
|
"mindmapId": "mind_allowed",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "shared_read",
|
|
"allowedResourceIds": ["mind_other"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_resource_ai_scope_forbidden")
|
|
);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_context_read_current_page_reads_local_markdown() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-context-read-current-page-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(&root).expect("root");
|
|
fs::write(
|
|
root.join("README.md"),
|
|
"# 当前页\n\n来自 mnote.context.read_current_page 的正文。\n",
|
|
)
|
|
.expect("markdown");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.context.read_current_page",
|
|
"workspaceId": "local-ws-context",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"profile": "reasonix",
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_context_read",
|
|
"runId": "run_context_read",
|
|
"toolCallId": "call_context_read",
|
|
"traceId": "trace_context_read",
|
|
"args": {
|
|
"format": "markdown",
|
|
"contextRefs": [{ "kind": "current_page" }],
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedRoots": [{ "rootUri": root_uri, "permission": "write" }],
|
|
"allowedResourceIds": ["local-md:README.md"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(status, StatusCode::OK, "{payload}");
|
|
assert_eq!(payload["ok"], true);
|
|
assert_eq!(payload["result"]["ok"], true);
|
|
assert!(
|
|
payload["result"]["content"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("mnote.context.read_current_page"),
|
|
"{payload}"
|
|
);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_fetch_reads_authorized_local_resource() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-resource-mindmap-fetch-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("maps")).expect("maps");
|
|
fs::write(
|
|
root.join("maps").join("idea.mindmap.json"),
|
|
r#"{"data":{"text":"中心主题","uid":"root"},"children":[{"data":{"text":"分支一","uid":"child_1"},"children":[]}]}"#,
|
|
)
|
|
.expect("mindmap");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.mindmap.fetch",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_mindmap_fetch",
|
|
"runId": "run_mindmap_fetch",
|
|
"toolCallId": "call_mindmap_fetch",
|
|
"traceId": "trace_mindmap_fetch",
|
|
"args": {
|
|
"mindmapId": "mind_allowed",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "shared_read",
|
|
"allowedResourceIds": ["mind_allowed"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["toolName"], "mnote.mindmap.fetch");
|
|
assert_eq!(payload["audit"]["effect"], "read");
|
|
assert_eq!(payload["result"]["resourceKind"], "mindmap");
|
|
assert_eq!(
|
|
payload["result"]["objectIdentity"],
|
|
"resource:mindmap:local-md:README.md:mind_allowed"
|
|
);
|
|
assert!(payload["result"]["markdownSummary"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("中心主题"));
|
|
assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_fetch_reads_default_envelope() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-resource-mindmap-envelope-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("maps")).expect("maps");
|
|
fs::write(
|
|
root.join("maps").join("default.mindmap.json"),
|
|
json!({
|
|
"data": {
|
|
"children": [
|
|
{
|
|
"data": {
|
|
"expand": true,
|
|
"isActive": false,
|
|
"text": "分支",
|
|
"uid": "node_1"
|
|
},
|
|
"children": []
|
|
}
|
|
],
|
|
"data": {
|
|
"expand": true,
|
|
"isActive": false,
|
|
"text": "KMIND",
|
|
"uid": "root"
|
|
}
|
|
},
|
|
"view": {
|
|
"state": { "scale": 1, "sx": 0, "sy": 0, "x": 0, "y": 0 },
|
|
"transform": { "a": 1, "b": 0, "c": 0, "d": 1, "e": 0, "f": 0 }
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("mindmap");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.mindmap.fetch",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_mindmap_envelope",
|
|
"runId": "run_mindmap_envelope",
|
|
"toolCallId": "call_mindmap_envelope",
|
|
"traceId": "trace_mindmap_envelope",
|
|
"args": {
|
|
"mindmapId": "mind_envelope",
|
|
"resourcePath": "maps/default.mindmap.json",
|
|
"scope": "full_envelope",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "shared_read",
|
|
"allowedResourceIds": ["mind_envelope"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["root"]["data"]["text"], "KMIND");
|
|
assert_eq!(payload["result"]["nodes"][0]["id"], "root");
|
|
assert_eq!(payload["result"]["nodes"][1]["text"], "分支");
|
|
assert_eq!(payload["result"]["envelope"]["data"]["data"]["uid"], "root");
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_apply_ops_shared_read_is_forbidden() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-resource-mindmap-write-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("maps")).expect("maps");
|
|
fs::write(
|
|
root.join("maps").join("idea.mindmap.json"),
|
|
r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#,
|
|
)
|
|
.expect("mindmap");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.mindmap.apply_ops",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_mindmap_write",
|
|
"runId": "run_mindmap_write",
|
|
"toolCallId": "call_mindmap_write",
|
|
"traceId": "trace_mindmap_write",
|
|
"idempotencyKey": "idem_mindmap_write",
|
|
"dryRun": false,
|
|
"args": {
|
|
"mindmapId": "mind_allowed",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"ops": [{"op": "update_node", "nodeId": "root", "text": "改名"}],
|
|
"aiAccessScope": {
|
|
"permissionLevel": "shared_read",
|
|
"allowedResourceIds": ["mind_allowed"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_shared_read_write_forbidden")
|
|
);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_apply_ops_writes_and_preserves_envelope_fields() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-resource-mindmap-apply-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("maps")).expect("maps");
|
|
fs::write(
|
|
root.join("maps").join("idea.mindmap.json"),
|
|
serde_json::to_string_pretty(&json!({
|
|
"data": {
|
|
"data": {
|
|
"text": "中心主题",
|
|
"uid": "root",
|
|
"customRootField": "keep-root"
|
|
},
|
|
"children": [
|
|
{
|
|
"data": {
|
|
"text": "旧分支",
|
|
"uid": "node_1",
|
|
"style": { "color": "red" }
|
|
},
|
|
"children": []
|
|
}
|
|
]
|
|
},
|
|
"view": { "state": { "scale": 2 } },
|
|
"unknownTop": { "keep": true }
|
|
}))
|
|
.expect("json"),
|
|
)
|
|
.expect("mindmap");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.mindmap.apply_ops",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_mindmap_apply",
|
|
"runId": "run_mindmap_apply",
|
|
"toolCallId": "call_mindmap_apply",
|
|
"traceId": "trace_mindmap_apply",
|
|
"idempotencyKey": "idem_mindmap_apply",
|
|
"dryRun": false,
|
|
"args": {
|
|
"mindmapId": "maps/idea.mindmap.json",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"ops": [
|
|
{ "op": "updateText", "nodeId": "node_1", "text": "新分支" },
|
|
{
|
|
"op": "insertChild",
|
|
"parentId": "root",
|
|
"node": {
|
|
"id": "node_new",
|
|
"text": "新子节点",
|
|
"metadata": { "sourceRefs": [{ "page": 1 }] }
|
|
}
|
|
}
|
|
],
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["maps/idea.mindmap.json"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(status, StatusCode::OK, "{payload}");
|
|
assert_eq!(
|
|
payload["result"]["root"]["children"][0]["data"]["text"],
|
|
"新分支"
|
|
);
|
|
assert_eq!(
|
|
payload["result"]["root"]["children"][1]["data"]["text"],
|
|
"新子节点"
|
|
);
|
|
assert!(payload["result"]["changedFiles"]
|
|
.as_array()
|
|
.expect("changed files")
|
|
.iter()
|
|
.any(|value| value == "maps/idea.mindmap.json"));
|
|
|
|
let written = fs::read_to_string(root.join("maps").join("idea.mindmap.json"))
|
|
.expect("written mindmap");
|
|
let written: Value = serde_json::from_str(&written).expect("written json");
|
|
assert_eq!(written["data"]["children"][0]["data"]["text"], "新分支");
|
|
assert_eq!(
|
|
written["data"]["children"][0]["data"]["style"]["color"],
|
|
"red"
|
|
);
|
|
assert_eq!(written["data"]["children"][1]["data"]["uid"], "node_new");
|
|
assert_eq!(written["view"]["state"]["scale"], 2);
|
|
assert_eq!(written["unknownTop"]["keep"], true);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_apply_ops_rejects_stale_revision() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-resource-mindmap-conflict-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("maps")).expect("maps");
|
|
fs::write(
|
|
root.join("maps").join("idea.mindmap.json"),
|
|
r#"{"data":{"data":{"text":"中心主题","uid":"root"},"children":[]}}"#,
|
|
)
|
|
.expect("mindmap");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.mindmap.apply_ops",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_mindmap_conflict",
|
|
"runId": "run_mindmap_conflict",
|
|
"toolCallId": "call_mindmap_conflict",
|
|
"traceId": "trace_mindmap_conflict",
|
|
"idempotencyKey": "idem_mindmap_conflict",
|
|
"dryRun": false,
|
|
"args": {
|
|
"mindmapId": "maps/idea.mindmap.json",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"expectedRevision": "stale-revision",
|
|
"ops": [{ "op": "updateText", "nodeId": "root", "text": "改名" }],
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["maps/idea.mindmap.json"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_resource_revision_conflict")
|
|
);
|
|
let written = fs::read_to_string(root.join("maps").join("idea.mindmap.json"))
|
|
.expect("written mindmap");
|
|
assert!(written.contains("中心主题"));
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_apply_ops_deletes_child_node() {
|
|
let (root, root_uri) = write_mindmap_apply_fixture(
|
|
"delete-node",
|
|
json!({
|
|
"data": {
|
|
"data": { "text": "中心主题", "uid": "root" },
|
|
"children": [
|
|
{ "data": { "text": "保留节点", "uid": "keep" }, "children": [] },
|
|
{ "data": { "text": "删除节点", "uid": "remove_me" }, "children": [] }
|
|
]
|
|
}
|
|
}),
|
|
);
|
|
|
|
let response = post_mindmap_apply_ops(
|
|
&root_uri,
|
|
false,
|
|
json!({
|
|
"mindmapId": "maps/idea.mindmap.json",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"ops": [{ "op": "deleteNode", "nodeId": "remove_me" }],
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["maps/idea.mindmap.json"]
|
|
}
|
|
}),
|
|
"delete_node",
|
|
)
|
|
.await;
|
|
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(status, StatusCode::OK, "{payload}");
|
|
assert_eq!(
|
|
payload["result"]["root"]["children"]
|
|
.as_array()
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
payload["result"]["root"]["children"][0]["data"]["uid"],
|
|
"keep"
|
|
);
|
|
let written =
|
|
fs::read_to_string(root.join("maps").join("idea.mindmap.json")).expect("written");
|
|
assert!(written.contains("保留节点"));
|
|
assert!(!written.contains("删除节点"));
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_apply_ops_accepts_common_aliases() {
|
|
let (root, root_uri) = write_mindmap_apply_fixture(
|
|
"aliases",
|
|
json!({
|
|
"data": {
|
|
"data": { "text": "中心主题", "uid": "root" },
|
|
"children": [
|
|
{ "data": { "text": "旧标题", "uid": "child_1" }, "children": [] }
|
|
]
|
|
}
|
|
}),
|
|
);
|
|
|
|
let response = post_mindmap_apply_ops(
|
|
&root_uri,
|
|
false,
|
|
json!({
|
|
"mindmapId": "maps/idea.mindmap.json",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"ops": [
|
|
{ "type": "update_node", "id": "child_1", "title": "别名更新" },
|
|
{ "action": "add-child", "nodeId": "root", "id": "alias_child", "title": "别名新增" }
|
|
],
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["maps/idea.mindmap.json"]
|
|
}
|
|
}),
|
|
"aliases",
|
|
)
|
|
.await;
|
|
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(status, StatusCode::OK, "{payload}");
|
|
assert_eq!(
|
|
payload["result"]["root"]["children"][0]["data"]["text"],
|
|
"别名更新"
|
|
);
|
|
assert_eq!(
|
|
payload["result"]["root"]["children"][1]["data"]["uid"],
|
|
"alias_child"
|
|
);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_apply_ops_dry_run_returns_diff_without_writing() {
|
|
let (root, root_uri) = write_mindmap_apply_fixture(
|
|
"dry-run",
|
|
json!({
|
|
"data": {
|
|
"data": { "text": "中心主题", "uid": "root" },
|
|
"children": []
|
|
}
|
|
}),
|
|
);
|
|
|
|
let response = post_mindmap_apply_ops(
|
|
&root_uri,
|
|
true,
|
|
json!({
|
|
"mindmapId": "maps/idea.mindmap.json",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"ops": [{ "op": "updateText", "nodeId": "root", "text": "dry-run 改名" }],
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["maps/idea.mindmap.json"]
|
|
}
|
|
}),
|
|
"dry_run",
|
|
)
|
|
.await;
|
|
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(status, StatusCode::OK, "{payload}");
|
|
assert_eq!(payload["result"]["dryRun"], true);
|
|
assert_eq!(payload["result"]["diff"][0]["op"], "mindmap.apply_ops");
|
|
let written =
|
|
fs::read_to_string(root.join("maps").join("idea.mindmap.json")).expect("written");
|
|
assert!(written.contains("中心主题"));
|
|
assert!(!written.contains("dry-run 改名"));
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_apply_ops_rejects_unsupported_op() {
|
|
let (root, root_uri) = write_mindmap_apply_fixture(
|
|
"unsupported-op",
|
|
json!({
|
|
"data": {
|
|
"data": { "text": "中心主题", "uid": "root" },
|
|
"children": []
|
|
}
|
|
}),
|
|
);
|
|
|
|
let response = post_mindmap_apply_ops(
|
|
&root_uri,
|
|
false,
|
|
json!({
|
|
"mindmapId": "maps/idea.mindmap.json",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"ops": [{ "op": "moveNode", "nodeId": "root" }],
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["maps/idea.mindmap.json"]
|
|
}
|
|
}),
|
|
"unsupported_op",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_resource_op_unsupported")
|
|
);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_apply_ops_rejects_invalid_ops_payload() {
|
|
let (root, root_uri) = write_mindmap_apply_fixture(
|
|
"invalid-ops",
|
|
json!({
|
|
"data": {
|
|
"data": { "text": "中心主题", "uid": "root" },
|
|
"children": []
|
|
}
|
|
}),
|
|
);
|
|
|
|
let response = post_mindmap_apply_ops(
|
|
&root_uri,
|
|
false,
|
|
json!({
|
|
"mindmapId": "maps/idea.mindmap.json",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"ops": { "op": "updateText", "nodeId": "root", "text": "bad" },
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["maps/idea.mindmap.json"]
|
|
}
|
|
}),
|
|
"invalid_ops",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_resource_ops_invalid")
|
|
);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_apply_ops_rejects_root_delete() {
|
|
let (root, root_uri) = write_mindmap_apply_fixture(
|
|
"root-delete",
|
|
json!({
|
|
"data": {
|
|
"data": { "text": "中心主题", "uid": "root" },
|
|
"children": []
|
|
}
|
|
}),
|
|
);
|
|
|
|
let response = post_mindmap_apply_ops(
|
|
&root_uri,
|
|
false,
|
|
json!({
|
|
"mindmapId": "maps/idea.mindmap.json",
|
|
"resourcePath": "maps/idea.mindmap.json",
|
|
"ops": [{ "op": "deleteNode", "nodeId": "root" }],
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["maps/idea.mindmap.json"]
|
|
}
|
|
}),
|
|
"root_delete",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_resource_root_delete_forbidden")
|
|
);
|
|
let written =
|
|
fs::read_to_string(root.join("maps").join("idea.mindmap.json")).expect("written");
|
|
assert!(written.contains("中心主题"));
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_create_from_outline_writes_default_envelope() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-resource-mindmap-create-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("maps")).expect("maps");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.mindmap.create_from_outline",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_mindmap_create",
|
|
"runId": "run_mindmap_create",
|
|
"toolCallId": "call_mindmap_create",
|
|
"traceId": "trace_mindmap_create",
|
|
"idempotencyKey": "idem_mindmap_create",
|
|
"dryRun": false,
|
|
"args": {
|
|
"mindmapId": "maps/generated.mindmap.json",
|
|
"resourcePath": "maps/generated.mindmap.json",
|
|
"embedIntoPage": false,
|
|
"title": "PDF 摘要导图",
|
|
"outline": [
|
|
{
|
|
"text": "章节一",
|
|
"children": [
|
|
{ "text": "要点 1", "children": [] }
|
|
]
|
|
}
|
|
],
|
|
"sourceRefs": [
|
|
{ "kind": "pdf", "title": "source.pdf", "page": 1 }
|
|
],
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["maps/generated.mindmap.json"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(status, StatusCode::OK, "{payload}");
|
|
assert_eq!(payload["result"]["resourceKind"], "mindmap");
|
|
assert_eq!(payload["result"]["root"]["data"]["text"], "PDF 摘要导图");
|
|
assert_eq!(
|
|
payload["result"]["root"]["children"][0]["data"]["text"],
|
|
"章节一"
|
|
);
|
|
assert_eq!(payload["result"]["envelope"]["data"]["data"]["uid"], "root");
|
|
assert!(root.join("maps").join("generated.mindmap.json").exists());
|
|
|
|
let written = fs::read_to_string(root.join("maps").join("generated.mindmap.json"))
|
|
.expect("written mindmap");
|
|
let written: Value = serde_json::from_str(&written).expect("written json");
|
|
assert_eq!(written["data"]["data"]["text"], "PDF 摘要导图");
|
|
assert_eq!(written["data"]["children"][0]["data"]["text"], "章节一");
|
|
assert!(written["view"]["transform"].is_object());
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_mindmap_create_from_outline_can_embed_into_local_markdown_page() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-resource-mindmap-create-embed-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("maps")).expect("maps");
|
|
fs::write(root.join("README.md"), "# README\n").expect("markdown");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.mindmap.create_from_outline",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_mindmap_create_embed",
|
|
"runId": "run_mindmap_create_embed",
|
|
"toolCallId": "call_mindmap_create_embed",
|
|
"traceId": "trace_mindmap_create_embed",
|
|
"idempotencyKey": "idem_mindmap_create_embed",
|
|
"dryRun": false,
|
|
"args": {
|
|
"mindmapId": "maps/generated-embed.mindmap.json",
|
|
"resourcePath": "maps/generated-embed.mindmap.json",
|
|
"title": "PDF 摘要导图",
|
|
"outline": [
|
|
{ "text": "章节一", "children": [] }
|
|
],
|
|
"embedIntoPage": true,
|
|
"aiAccessScope": {
|
|
"permissionLevel": "read_write",
|
|
"allowedResourceIds": ["maps/generated-embed.mindmap.json"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(status, StatusCode::OK, "{payload}");
|
|
assert_eq!(payload["result"]["embedResult"]["status"], "embedded");
|
|
assert!(payload["result"]["changedFiles"]
|
|
.as_array()
|
|
.expect("changed files")
|
|
.iter()
|
|
.any(|value| value == "README.md"));
|
|
let markdown = fs::read_to_string(root.join("README.md")).expect("markdown");
|
|
assert!(markdown.contains("[PDF 摘要导图](maps/generated-embed.mindmap.json)"));
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_office_fetch_and_propose_changes_do_not_write_file() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-resource-office-fetch-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("office")).expect("office");
|
|
fs::write(root.join("office").join("report.docx"), "Office text").expect("office file");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("workspace");
|
|
|
|
let fetch = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.office.fetch_summary",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_office_fetch",
|
|
"runId": "run_office_fetch",
|
|
"toolCallId": "call_office_fetch",
|
|
"traceId": "trace_office_fetch",
|
|
"args": {
|
|
"assetId": "asset_report",
|
|
"resourcePath": "office/report.docx",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "shared_read",
|
|
"allowedResourceIds": ["resource:onlyoffice:local-md:README.md:asset_report"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(fetch.status(), StatusCode::OK);
|
|
let fetch_body = to_bytes(fetch.into_body(), usize::MAX)
|
|
.await
|
|
.expect("fetch body");
|
|
let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("fetch json");
|
|
assert_eq!(fetch_payload["audit"]["effect"], "read");
|
|
assert_eq!(fetch_payload["result"]["resourceKind"], "only_office");
|
|
assert_eq!(fetch_payload["result"]["fileName"], "report.docx");
|
|
|
|
let before = fs::read(root.join("office").join("report.docx")).expect("before");
|
|
let propose = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.office.propose_changes",
|
|
"workspaceId": "local-ws-resource",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_office_propose",
|
|
"runId": "run_office_propose",
|
|
"toolCallId": "call_office_propose",
|
|
"traceId": "trace_office_propose",
|
|
"args": {
|
|
"assetId": "asset_report",
|
|
"resourcePath": "office/report.docx",
|
|
"instructions": "补充结论",
|
|
"aiAccessScope": {
|
|
"permissionLevel": "shared_read",
|
|
"allowedResourceIds": ["asset_report"]
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(propose.status(), StatusCode::OK);
|
|
let propose_body = to_bytes(propose.into_body(), usize::MAX)
|
|
.await
|
|
.expect("propose body");
|
|
let propose_payload: Value = serde_json::from_slice(&propose_body).expect("propose json");
|
|
assert_eq!(propose_payload["audit"]["effect"], "read");
|
|
assert_eq!(propose_payload["result"]["writesBinary"], false);
|
|
assert_eq!(
|
|
fs::read(root.join("office").join("report.docx")).expect("after"),
|
|
before
|
|
);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_page_get_accepts_delegated_actor_from_hermes_payload() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.get",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"actorId": "user_hermes",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"capabilityScope": ["page.read"]
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["audit"]["actorId"], "user_hermes");
|
|
assert_eq!(payload["result"]["title"], "服务端页面");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_call_rejects_profile_disabled_tool() {
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
let hermes_home = std::env::temp_dir().join(format!(
|
|
"mnote-web-hermes-tool-disabled-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&hermes_home);
|
|
let profile_dir = hermes_home.join("profiles").join("blocked");
|
|
fs::create_dir_all(&profile_dir).expect("profile dir");
|
|
fs::write(
|
|
profile_dir.join("config.yaml"),
|
|
"mnote:\n tools:\n disabled:\n - mnote.page.get\n",
|
|
)
|
|
.expect("profile config");
|
|
std::env::set_var("HERMES_HOME", &hermes_home);
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.get",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_disabled",
|
|
"runId": "run_disabled",
|
|
"toolCallId": "call_disabled",
|
|
"traceId": "trace_disabled",
|
|
"profile": "blocked",
|
|
"capabilityScope": ["page.read"]
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["code"], "mnote_tool_disabled");
|
|
|
|
std::env::remove_var("HERMES_HOME");
|
|
let _ = fs::remove_dir_all(&hermes_home);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_write_tools_require_auth() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.save",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"idempotencyKey": "idem_1",
|
|
"dryRun": false,
|
|
"args": {"content": []}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_page_get_returns_page_aggregate_summary() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.get",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"capabilityScope": ["page.read"]
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["toolName"], "mnote.page.get");
|
|
assert_eq!(payload["toolCallId"], "call_1");
|
|
assert_eq!(payload["result"]["title"], "服务端页面");
|
|
assert!(payload["result"]["bodySummary"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("章节一"));
|
|
assert_eq!(payload["audit"]["effect"], "read");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_doc_fetch_returns_block_projection() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.fetch",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_doc_fetch_1",
|
|
"traceId": "trace_doc_fetch_1",
|
|
"capabilityScope": ["page.read"],
|
|
"args": {"scope": "full", "detail": "with_ids"}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["toolName"], "mnote.doc.fetch");
|
|
assert_eq!(payload["audit"]["effect"], "read");
|
|
assert_eq!(payload["result"]["revision"], json!(7));
|
|
assert_eq!(payload["result"]["conflictDetectionKey"], json!("doc_1:7"));
|
|
assert_eq!(payload["result"]["fileVersion"], json!("doc_1:7"));
|
|
assert_eq!(
|
|
payload["result"]["blocks"][0]["blockId"],
|
|
json!("heading_1")
|
|
);
|
|
assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一"));
|
|
assert!(payload["result"]["blocks"][0]["revisionRef"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.starts_with("pageRev:7:block:heading_1:hash:"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.fetch",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_2",
|
|
"sessionId": "sess_scope_read",
|
|
"runId": "run_scope_read",
|
|
"toolCallId": "call_scope_read",
|
|
"traceId": "trace_scope_read",
|
|
"args": {
|
|
"aiAccessScope": {
|
|
"permissionLevel": "shared_read",
|
|
"allowedResourceIds": ["doc_1"],
|
|
"shareContext": {"shareId": "share_read_1"}
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_ai_scope_read_forbidden")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_page_get_rejects_out_of_scope_ai_resource() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.get",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_2",
|
|
"sessionId": "sess_page_scope_read",
|
|
"runId": "run_page_scope_read",
|
|
"toolCallId": "call_page_scope_read",
|
|
"traceId": "trace_page_scope_read",
|
|
"args": {
|
|
"aiAccessScope": {
|
|
"permissionLevel": "shared_read",
|
|
"allowedResourceIds": ["doc_1"],
|
|
"shareContext": {"shareId": "share_read_1"}
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_ai_scope_read_forbidden")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_doc_fetch_supports_selection_and_page_xml() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.fetch",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_doc_fetch_selection_1",
|
|
"traceId": "trace_doc_fetch_selection_1",
|
|
"capabilityScope": ["page.read"],
|
|
"args": {
|
|
"scope": "selection",
|
|
"selectedBlockIds": ["heading_1"],
|
|
"format": "page_xml"
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["schema"], "mnote.page_ai_context.v1");
|
|
assert_eq!(payload["result"]["scope"], "selection");
|
|
assert_eq!(payload["result"]["format"], "page_xml");
|
|
assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1);
|
|
assert!(payload["result"]["content"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("<block id=\"heading_1\""));
|
|
assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_doc_find_and_block_fetch_use_block_projection() {
|
|
let find_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.find",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_doc_find_1",
|
|
"traceId": "trace_doc_find_1",
|
|
"capabilityScope": ["page.read"],
|
|
"args": {"query": "章节一"}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(find_response.status(), StatusCode::OK);
|
|
let find_body = to_bytes(find_response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let find_payload: Value = serde_json::from_slice(&find_body).expect("json");
|
|
assert_eq!(
|
|
find_payload["result"]["matches"][0]["blockId"],
|
|
json!("heading_1")
|
|
);
|
|
|
|
let fetch_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.fetch",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_block_fetch_1",
|
|
"traceId": "trace_block_fetch_1",
|
|
"capabilityScope": ["block.read", "page.read"],
|
|
"args": {"blockId": "heading_1", "contextBefore": 1, "contextAfter": 1}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(fetch_response.status(), StatusCode::OK);
|
|
let fetch_body = to_bytes(fetch_response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("json");
|
|
assert_eq!(
|
|
fetch_payload["result"]["block"]["blockId"],
|
|
json!("heading_1")
|
|
);
|
|
assert_eq!(fetch_payload["result"]["block"]["text"], json!("章节一"));
|
|
assert_eq!(fetch_payload["audit"]["effect"], "read");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_plan_update_and_block_move_after_are_dry_run_only() {
|
|
let heading_ref = block_revision_ref("heading_1").await;
|
|
let plan_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.plan_update",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_plan_1",
|
|
"traceId": "trace_plan_1",
|
|
"idempotencyKey": "idem_plan_1",
|
|
"dryRun": true,
|
|
"capabilityScope": ["page.write", "block.write"],
|
|
"args": {
|
|
"command": "block_replace",
|
|
"blockId": "heading_1",
|
|
"content": "替换标题"
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(plan_response.status(), StatusCode::OK);
|
|
let plan_body = to_bytes(plan_response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let plan_payload: Value = serde_json::from_slice(&plan_body).expect("json");
|
|
assert_eq!(plan_payload["audit"]["effect"], "dry_run");
|
|
assert_eq!(plan_payload["result"]["diff"][0]["op"], "replace");
|
|
|
|
let move_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.move_after",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_move_1",
|
|
"traceId": "trace_move_1",
|
|
"idempotencyKey": "idem_move_1",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"blockId": "heading_1",
|
|
"anchorBlockId": "heading_1",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"blockRevisionRef": heading_ref.clone(),
|
|
"anchorRevisionRef": heading_ref.clone()
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(move_response.status(), StatusCode::OK);
|
|
let move_body = to_bytes(move_response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let move_payload: Value = serde_json::from_slice(&move_body).expect("json");
|
|
assert_eq!(move_payload["result"]["blocked"], true);
|
|
assert_eq!(
|
|
move_payload["result"]["warnings"][0]["code"],
|
|
"block_move_after_blocked"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_block_replace_and_insert_after_write_through_page_body_save() {
|
|
let heading_ref = block_revision_ref("heading_1").await;
|
|
let p1_ref = block_revision_ref("p_1").await;
|
|
let p2_ref = block_revision_ref("p_2").await;
|
|
let replace_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.replace",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_replace_1",
|
|
"traceId": "trace_replace_1",
|
|
"idempotencyKey": "idem_replace_1",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"blockId": "heading_1",
|
|
"content": "替换后的章节",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"blockRevisionRef": heading_ref.clone()
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(replace_response.status(), StatusCode::OK);
|
|
let replace_body = to_bytes(replace_response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let replace_payload: Value = serde_json::from_slice(&replace_body).expect("json");
|
|
assert_eq!(replace_payload["audit"]["effect"], "write");
|
|
assert_eq!(
|
|
replace_payload["result"]["changedBlocks"][0]["blockId"],
|
|
json!("heading_1")
|
|
);
|
|
assert_eq!(
|
|
replace_payload["result"]["commandName"],
|
|
json!("page.body.save")
|
|
);
|
|
|
|
let insert_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.insert_after",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_insert_1",
|
|
"traceId": "trace_insert_1",
|
|
"idempotencyKey": "idem_insert_1",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"anchorBlockId": "heading_1",
|
|
"content": "新增段落",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"anchorRevisionRef": heading_ref.clone()
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(insert_response.status(), StatusCode::OK);
|
|
let insert_body = to_bytes(insert_response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let insert_payload: Value = serde_json::from_slice(&insert_body).expect("json");
|
|
assert_eq!(insert_payload["audit"]["effect"], "write");
|
|
assert_eq!(
|
|
insert_payload["result"]["changedBlocks"][0]["op"],
|
|
json!("insert_after")
|
|
);
|
|
assert!(insert_payload["result"]["changedBlocks"][0]["blockId"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.starts_with("ai_block_"));
|
|
|
|
let delete_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.delete",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_delete_1",
|
|
"traceId": "trace_delete_1",
|
|
"idempotencyKey": "idem_delete_1",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"blockId": "p_1",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"blockRevisionRef": p1_ref.clone()
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(delete_response.status(), StatusCode::OK);
|
|
let delete_body = to_bytes(delete_response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let delete_payload: Value = serde_json::from_slice(&delete_body).expect("json");
|
|
assert_eq!(delete_payload["audit"]["effect"], "write");
|
|
assert_eq!(
|
|
delete_payload["result"]["changedBlocks"][0]["op"],
|
|
json!("delete")
|
|
);
|
|
assert_eq!(
|
|
delete_payload["result"]["changedBlocks"][0]["blockId"],
|
|
json!("p_1")
|
|
);
|
|
|
|
let move_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.move_after",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_move_write_1",
|
|
"traceId": "trace_move_write_1",
|
|
"idempotencyKey": "idem_move_write_1",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"blockId": "heading_1",
|
|
"anchorBlockId": "p_2",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"blockRevisionRef": heading_ref.clone(),
|
|
"anchorRevisionRef": p2_ref.clone()
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(move_response.status(), StatusCode::OK);
|
|
let move_body = to_bytes(move_response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let move_payload: Value = serde_json::from_slice(&move_body).expect("json");
|
|
assert_eq!(move_payload["audit"]["effect"], "write");
|
|
assert_eq!(
|
|
move_payload["result"]["changedBlocks"][0]["op"],
|
|
json!("move_after")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_block_tools_selection_scope_rejects_out_of_scope_targets() {
|
|
let heading_ref = block_revision_ref("heading_1").await;
|
|
let p1_ref = block_revision_ref("p_1").await;
|
|
let p2_ref = block_revision_ref("p_2").await;
|
|
let cases = vec![
|
|
json!({
|
|
"toolName": "mnote.block.replace",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_scope_replace",
|
|
"runId": "run_scope_replace",
|
|
"toolCallId": "call_scope_replace",
|
|
"traceId": "trace_scope_replace",
|
|
"idempotencyKey": "idem_scope_replace",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"blockId": "heading_1",
|
|
"content": "越界替换",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"blockRevisionRef": heading_ref,
|
|
"allowedTargetBlockIds": ["p_1"]
|
|
}
|
|
}),
|
|
json!({
|
|
"toolName": "mnote.block.insert_after",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_scope_insert",
|
|
"runId": "run_scope_insert",
|
|
"toolCallId": "call_scope_insert",
|
|
"traceId": "trace_scope_insert",
|
|
"idempotencyKey": "idem_scope_insert",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"anchorBlockId": "heading_1",
|
|
"content": "越界插入",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"anchorRevisionRef": heading_ref,
|
|
"allowedTargetBlockIds": ["p_1"]
|
|
}
|
|
}),
|
|
json!({
|
|
"toolName": "mnote.block.delete",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_scope_delete",
|
|
"runId": "run_scope_delete",
|
|
"toolCallId": "call_scope_delete",
|
|
"traceId": "trace_scope_delete",
|
|
"idempotencyKey": "idem_scope_delete",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"blockId": "p_1",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"blockRevisionRef": p1_ref,
|
|
"allowedTargetBlockIds": ["heading_1"]
|
|
}
|
|
}),
|
|
json!({
|
|
"toolName": "mnote.block.move_after",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_scope_move",
|
|
"runId": "run_scope_move",
|
|
"toolCallId": "call_scope_move",
|
|
"traceId": "trace_scope_move",
|
|
"idempotencyKey": "idem_scope_move",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"blockId": "heading_1",
|
|
"anchorBlockId": "p_2",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"blockRevisionRef": heading_ref,
|
|
"anchorRevisionRef": p2_ref,
|
|
"allowedTargetBlockIds": ["heading_1"]
|
|
}
|
|
}),
|
|
];
|
|
|
|
for payload in cases {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(payload.to_string()))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_block_target_out_of_scope")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_block_move_after_blocks_complex_and_nested_blocks() {
|
|
let content = json!([
|
|
{
|
|
"id": "heading_parent",
|
|
"type": "heading",
|
|
"props": { "level": 2 },
|
|
"content": [{ "type": "text", "text": "带子块标题" }],
|
|
"children": [
|
|
{
|
|
"id": "heading_child",
|
|
"type": "paragraph",
|
|
"content": [{ "type": "text", "text": "标题子段落" }]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "p_anchor",
|
|
"type": "paragraph",
|
|
"content": [{ "type": "text", "text": "锚点段落" }]
|
|
},
|
|
{
|
|
"id": "list_item_1",
|
|
"type": "bullet_list_item",
|
|
"content": [{ "type": "text", "text": "列表项" }]
|
|
},
|
|
{
|
|
"id": "table_1",
|
|
"type": "table",
|
|
"content": [{ "type": "text", "text": "表格块" }]
|
|
},
|
|
{
|
|
"id": "mindmap_1",
|
|
"type": "mindmap",
|
|
"props": { "mindmapId": "mindmap_fixture_1" },
|
|
"content": [{ "type": "text", "text": "思维导图块" }]
|
|
},
|
|
{
|
|
"id": "resource_1",
|
|
"type": "resource",
|
|
"content": [{ "type": "text", "text": "资源块" }]
|
|
}
|
|
]);
|
|
let refs = block_revision_refs_with_app(app_with_content(content.clone())).await;
|
|
let anchor_ref = refs.get("p_anchor").expect("anchor ref").clone();
|
|
for block_id in [
|
|
"heading_parent",
|
|
"list_item_1",
|
|
"table_1",
|
|
"mindmap_1",
|
|
"resource_1",
|
|
] {
|
|
let response = app_with_content(content.clone())
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.move_after",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": format!("sess_move_blocked_{block_id}"),
|
|
"runId": format!("run_move_blocked_{block_id}"),
|
|
"toolCallId": format!("call_move_blocked_{block_id}"),
|
|
"traceId": format!("trace_move_blocked_{block_id}"),
|
|
"idempotencyKey": format!("idem_move_blocked_{block_id}"),
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"blockId": block_id,
|
|
"anchorBlockId": "p_anchor",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"blockRevisionRef": refs.get(block_id).expect("block ref"),
|
|
"anchorRevisionRef": anchor_ref
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["blocked"], json!(true), "{block_id}");
|
|
assert_eq!(
|
|
payload["result"]["warnings"][0]["code"],
|
|
json!("block_move_after_blocked"),
|
|
"{block_id}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_block_insert_after_accepts_multiple_blocks_and_returns_ids() {
|
|
let heading_ref = block_revision_ref("heading_1").await;
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.insert_after",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_multi_insert",
|
|
"runId": "run_multi_insert",
|
|
"toolCallId": "call_multi_insert",
|
|
"traceId": "trace_multi_insert",
|
|
"idempotencyKey": "idem_multi_insert",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"anchorBlockId": "heading_1",
|
|
"blocks": ["新增第一段", {"type": "todo", "content": "新增待办"}],
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"anchorRevisionRef": heading_ref
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let inserted_ids = payload["result"]["insertedBlockIds"]
|
|
.as_array()
|
|
.expect("insertedBlockIds");
|
|
assert_eq!(inserted_ids.len(), 2);
|
|
assert!(inserted_ids
|
|
.iter()
|
|
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") }));
|
|
assert_eq!(
|
|
payload["result"]["changedBlocks"]
|
|
.as_array()
|
|
.expect("changedBlocks")
|
|
.len(),
|
|
2
|
|
);
|
|
assert_eq!(payload["result"]["changedBlocks"][0]["op"], "insert_after");
|
|
assert_eq!(payload["result"]["changedBlocks"][1]["op"], "insert_after");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_block_insert_after_rejects_more_than_twenty_blocks() {
|
|
let heading_ref = block_revision_ref("heading_1").await;
|
|
let blocks = (0..21)
|
|
.map(|index| json!(format!("新增段落 {index}")))
|
|
.collect::<Vec<_>>();
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.insert_after",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_multi_insert_limit",
|
|
"runId": "run_multi_insert_limit",
|
|
"toolCallId": "call_multi_insert_limit",
|
|
"traceId": "trace_multi_insert_limit",
|
|
"idempotencyKey": "idem_multi_insert_limit",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"anchorBlockId": "heading_1",
|
|
"blocks": blocks,
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"anchorRevisionRef": heading_ref
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_bad_request")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_block_write_requires_fresh_revision_and_block_ref() {
|
|
let missing_revision_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.replace",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_missing_revision_1",
|
|
"traceId": "trace_missing_revision_1",
|
|
"idempotencyKey": "idem_missing_revision_1",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"blockId": "heading_1",
|
|
"content": "不应写入"
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(missing_revision_response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
missing_revision_response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_write_precondition_required")
|
|
);
|
|
|
|
let stale_ref_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.block.replace",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_stale_ref_1",
|
|
"traceId": "trace_stale_ref_1",
|
|
"idempotencyKey": "idem_stale_ref_1",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"blockId": "heading_1",
|
|
"content": "不应写入",
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"blockRevisionRef": "pageRev:old:block:heading_1:hash:stale"
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(stale_ref_response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
stale_ref_response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_conflict")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_apply_block_ops_requires_page_preconditions() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.apply_block_ops",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_apply_missing_revision_1",
|
|
"traceId": "trace_apply_missing_revision_1",
|
|
"idempotencyKey": "idem_apply_missing_revision_1",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"operations": [{
|
|
"op": "replace",
|
|
"blockId": "p_2",
|
|
"content": "不应写入"
|
|
}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_write_precondition_required")
|
|
);
|
|
|
|
let missing_block_ref_response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.apply_block_ops",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_apply_missing_block_ref_1",
|
|
"traceId": "trace_apply_missing_block_ref_1",
|
|
"idempotencyKey": "idem_apply_missing_block_ref_1",
|
|
"dryRun": false,
|
|
"capabilityScope": ["block.write", "page.write"],
|
|
"args": {
|
|
"revision": 7,
|
|
"conflictDetectionKey": "doc_1:7",
|
|
"operations": [{
|
|
"op": "replace",
|
|
"blockId": "p_2",
|
|
"content": "不应写入"
|
|
}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(missing_block_ref_response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
missing_block_ref_response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_write_precondition_required")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_legacy_docs_search_forwards_to_evidence_search() {
|
|
let root =
|
|
std::env::temp_dir().join(format!("mnote-docs-search-evidence-{}", std::process::id()));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
|
fs::write(
|
|
root.join(".mnote").join("workspace.json"),
|
|
r#"{"workspaceId":"local-ws-docs-search","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
|
|
)
|
|
.expect("manifest");
|
|
fs::write(
|
|
root.join("README.md"),
|
|
"# Evidence Home\n\ncompat-evidence-token 正文\n",
|
|
)
|
|
.expect("markdown");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_search_index::write_local_index_settings(
|
|
&root,
|
|
&[String::from(".")],
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("settings");
|
|
crate::routes::local_search_index::refresh_local_search_index(
|
|
&root,
|
|
&root_uri,
|
|
"local-ws-docs-search",
|
|
)
|
|
.expect("refresh");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "docs_search",
|
|
"workspaceId": "local-ws-docs-search",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"sessionId": "sess_docs_search",
|
|
"runId": "run_docs_search",
|
|
"toolCallId": "call_docs_search",
|
|
"traceId": "trace_docs_search",
|
|
"args": {
|
|
"query": "compat-evidence-token",
|
|
"includeOcr": true,
|
|
"limit": 5
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["compatTool"], "docs_search");
|
|
assert_eq!(payload["result"]["source"], "mnote.evidence.search");
|
|
let result = payload["result"]["results"]
|
|
.as_array()
|
|
.and_then(|items| items.first())
|
|
.expect("evidence result");
|
|
assert_eq!(
|
|
result["source"]["schema"].as_str(),
|
|
Some("mnote.evidence_locator.v1")
|
|
);
|
|
assert_eq!(
|
|
result["source"]["ownerDocumentPath"].as_str(),
|
|
Some("README.md")
|
|
);
|
|
assert!(result["citationMarkdown"]
|
|
.as_str()
|
|
.is_some_and(|value| value.contains("](/documents/")));
|
|
assert!(result["citationUrl"]
|
|
.as_str()
|
|
.is_some_and(|value| value.contains("resourceTab=")));
|
|
let evidence_id = result["evidenceId"].as_str().expect("evidence id");
|
|
assert_eq!(
|
|
payload["result"]["evidenceIds"][0].as_str(),
|
|
Some(evidence_id)
|
|
);
|
|
assert_eq!(
|
|
payload["result"]["runReceipt"]["schema"].as_str(),
|
|
Some("mnote.agent_run_receipt.evidence.v1")
|
|
);
|
|
assert_eq!(
|
|
payload["result"]["runReceipt"]["evidenceIds"][0].as_str(),
|
|
Some(evidence_id)
|
|
);
|
|
assert_eq!(payload["evidenceIds"][0].as_str(), Some(evidence_id));
|
|
assert_eq!(
|
|
payload["runReceipt"]["toolCallId"].as_str(),
|
|
Some("call_docs_search")
|
|
);
|
|
assert_eq!(
|
|
payload["audit"]["evidenceIds"][0].as_str(),
|
|
Some(evidence_id)
|
|
);
|
|
let completed_audit =
|
|
super::audit_events(Some("trace_docs_search"), Some("call_docs_search"))
|
|
.into_iter()
|
|
.find(|event| event["phase"] == "completed")
|
|
.expect("completed audit");
|
|
assert_eq!(
|
|
completed_audit["audit"]["runReceipt"]["evidenceIds"][0].as_str(),
|
|
Some(evidence_id)
|
|
);
|
|
assert!(payload["result"]["evidence"]
|
|
.as_array()
|
|
.expect("evidence")
|
|
.iter()
|
|
.any(|item| item["quote"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("compat-evidence-token")));
|
|
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_local_index_tools_are_retired() {
|
|
let root = std::env::temp_dir().join(format!("mnote-index-tool-{}", std::process::id()));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
|
fs::create_dir_all(root.join("docs")).expect("docs");
|
|
fs::write(
|
|
root.join(".mnote").join("workspace.json"),
|
|
r#"{"workspaceId":"local-ws-index-tool","ownerId":"user_1","createdAt":"2026-06-04T00:00:00Z","capabilities":["local_files","search"]}"#,
|
|
)
|
|
.expect("manifest");
|
|
fs::write(root.join("docs").join("indexed.md"), "# Indexed\n").expect("markdown");
|
|
let root_uri = format!("file://{}", root.display());
|
|
let app = app();
|
|
|
|
let update_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.index.update_settings",
|
|
"workspaceId": "local-ws-index-tool",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"sessionId": "sess_index_tool",
|
|
"runId": "run_index_tool",
|
|
"toolCallId": "call_index_update",
|
|
"traceId": "trace_index_tool",
|
|
"dryRun": false,
|
|
"idempotencyKey": "idem_index_tool_update",
|
|
"args": {
|
|
"includePaths": ["docs"],
|
|
"scheduleMode": "manual",
|
|
"scheduleTime": "02:00",
|
|
"runOnChange": false
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("update request"),
|
|
)
|
|
.await
|
|
.expect("update response");
|
|
assert_eq!(update_response.status(), StatusCode::GONE);
|
|
let update_body = to_bytes(update_response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("update body");
|
|
let update_payload: Value = serde_json::from_slice(&update_body).expect("update json");
|
|
assert_eq!(
|
|
update_payload["code"].as_str(),
|
|
Some("mnote_index_tools_retired")
|
|
);
|
|
assert!(!root.join(".mnote/index/search-index.json").exists());
|
|
|
|
let status_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.index.status",
|
|
"workspaceId": "local-ws-index-tool",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"sessionId": "sess_index_tool",
|
|
"runId": "run_index_tool",
|
|
"toolCallId": "call_index_status",
|
|
"traceId": "trace_index_tool",
|
|
"args": {}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("status request"),
|
|
)
|
|
.await
|
|
.expect("status response");
|
|
assert_eq!(status_response.status(), StatusCode::GONE);
|
|
let status_body = to_bytes(status_response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("status body");
|
|
let status_payload: Value = serde_json::from_slice(&status_body).expect("status json");
|
|
assert_eq!(
|
|
status_payload["code"].as_str(),
|
|
Some("mnote_index_tools_retired")
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_legacy_docs_read_returns_document_with_locator() {
|
|
let root =
|
|
std::env::temp_dir().join(format!("mnote-docs-read-evidence-{}", std::process::id()));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
|
fs::write(
|
|
root.join(".mnote").join("workspace.json"),
|
|
r#"{"workspaceId":"local-ws-docs-read","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
|
|
)
|
|
.expect("manifest");
|
|
fs::write(root.join("README.md"), "# Docs Read\n\nlegacy docs read\n").expect("markdown");
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "docs_read",
|
|
"workspaceId": "local-ws-docs-read",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"sessionId": "sess_docs_read",
|
|
"runId": "run_docs_read",
|
|
"toolCallId": "call_docs_read",
|
|
"traceId": "trace_docs_read",
|
|
"args": {
|
|
"documentId": "local-md:README.md",
|
|
"includeContent": true
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["compatTool"], "docs_read");
|
|
assert_eq!(
|
|
payload["result"]["source"]["locator"]["schema"].as_str(),
|
|
Some("mnote.evidence_locator.v1")
|
|
);
|
|
assert_eq!(
|
|
payload["result"]["source"]["locator"]["ownerDocumentPath"].as_str(),
|
|
Some("README.md")
|
|
);
|
|
assert!(payload["result"]["document"]
|
|
.to_string()
|
|
.contains("legacy docs read"));
|
|
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-workspace-id", "ws_other")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.get",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"capabilityScope": ["page.read"]
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("workspace_context_conflict")
|
|
);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
|
assert!(!text.contains("服务端页面"));
|
|
assert!(!text.contains("章节一"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_write_tools_require_idempotency_and_dry_run_flag() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.save",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"args": {"content": []}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_idempotency_required")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_page_save_local_folder_writes_markdown_file() {
|
|
let _env_guard = env_lock().lock().expect("env lock");
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-page-save-local-folder-{}",
|
|
std::process::id()
|
|
));
|
|
let audit_dir = std::env::temp_dir().join(format!(
|
|
"mnote-local-agent-audit-tool-write-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
let _ = fs::remove_dir_all(&audit_dir);
|
|
std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir);
|
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
|
fs::write(
|
|
root.join(".mnote").join("workspace.json"),
|
|
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
|
|
)
|
|
.expect("manifest");
|
|
fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown");
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.save",
|
|
"workspaceId": "local-ws-user-1",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"sessionId": "sess_page_save_local",
|
|
"runId": "run_page_save_local",
|
|
"toolCallId": "call_page_save_local",
|
|
"traceId": "trace_page_save_local",
|
|
"idempotencyKey": "idem_page_save_local",
|
|
"dryRun": false,
|
|
"args": {
|
|
"content": [
|
|
{"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]}
|
|
]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
|
assert_eq!(status, StatusCode::OK, "{text}");
|
|
let payload: Value = serde_json::from_str(&text).expect("json");
|
|
assert_eq!(payload["result"]["source"], "local_folder");
|
|
assert_eq!(payload["result"]["commandName"], "page.body.write");
|
|
let saved = fs::read_to_string(root.join("README.md")).expect("read");
|
|
assert!(saved.contains("本地 page.save 写入"), "{saved}");
|
|
let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl");
|
|
let event = jsonl
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
|
.find(|event| event["runId"] == "run_page_save_local")
|
|
.expect("audit event");
|
|
assert_eq!(event["origin"], "mnote_tool");
|
|
assert_eq!(event["toolName"], "mnote.page.save");
|
|
assert_eq!(event["toolCallId"], "call_page_save_local");
|
|
assert_eq!(event["writeAttemptRejected"], false);
|
|
assert_eq!(event["changedFiles"][0]["path"], "local-md:README.md");
|
|
assert_eq!(event["changedFiles"][0]["changeType"], "modified");
|
|
std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR");
|
|
let _ = fs::remove_dir_all(&root);
|
|
let _ = fs::remove_dir_all(&audit_dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_update_options_local_folder_stores_ui_preferences_in_sqlite() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-page-options-local-folder-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
|
fs::write(
|
|
root.join(".mnote").join("workspace.json"),
|
|
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
|
|
)
|
|
.expect("manifest");
|
|
fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown");
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.update_options",
|
|
"workspaceId": "local-ws-user-1",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"sessionId": "sess_page_options_local",
|
|
"runId": "run_page_options_local",
|
|
"toolCallId": "call_page_options_local",
|
|
"traceId": "trace_page_options_local",
|
|
"idempotencyKey": "idem_page_options_local",
|
|
"dryRun": false,
|
|
"args": {
|
|
"options": {
|
|
"wideLayout": true,
|
|
"showHeadingNumbers": true,
|
|
"hideTitleHeader": false
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
|
assert_eq!(status, StatusCode::OK, "{text}");
|
|
let payload: Value = serde_json::from_str(&text).expect("json");
|
|
assert_eq!(payload["result"]["source"], "local_folder");
|
|
assert_eq!(
|
|
payload["result"]["commandName"],
|
|
"page.layout.updateOptions"
|
|
);
|
|
assert_eq!(
|
|
payload["result"]["result"]["pageOptions"]["wideLayout"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
payload["result"]["result"]["pageOptions"]["showHeadingNumbers"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
payload["result"]["result"]["pageOptions"]["hideTitleHeader"],
|
|
false
|
|
);
|
|
assert!(
|
|
!root.join(".mnote").join("page-options.json").exists(),
|
|
"AI 页面设置工具不应继续写入 .mnote/page-options.json"
|
|
);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.save",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"idempotencyKey": "idem_save_1",
|
|
"dryRun": true,
|
|
"args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
assert_eq!(payload["result"]["dryRun"], true);
|
|
assert_eq!(payload["result"]["commandName"], "page.body.save");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_local_requires_write_contract() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-markdown-edit-contract-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(&root).expect("create temp root");
|
|
let path = root.join("page.md");
|
|
fs::write(&path, "第一段\n\n第二段\n").expect("write markdown");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": path.to_string_lossy(),
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"dryRun": false,
|
|
"args": {
|
|
"operations": [{"search": "第二段", "replace": "测试123"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_idempotency_required")
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&path).expect("read markdown"),
|
|
"第一段\n\n第二段\n"
|
|
);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_local_dry_run_does_not_write() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-markdown-edit-dry-run-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(&root).expect("create temp root");
|
|
let path = root.join("page.md");
|
|
fs::write(&path, "第一段\n\n第二段\n").expect("write markdown");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": path.to_string_lossy(),
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"idempotencyKey": "idem_markdown_local_1",
|
|
"dryRun": true,
|
|
"args": {
|
|
"operations": [{"search": "第二段", "replace": "测试123"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["audit"]["effect"], "dry_run");
|
|
assert_eq!(payload["result"]["operationsApplied"], 1);
|
|
assert_eq!(payload["result"]["applyResult"]["written"], false);
|
|
assert_eq!(payload["result"]["applyResult"]["dryRun"], true);
|
|
assert_eq!(
|
|
fs::read_to_string(&path).expect("read markdown"),
|
|
"第一段\n\n第二段\n"
|
|
);
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_shared_read_is_forbidden() {
|
|
let _env_guard = env_lock().lock().expect("env lock");
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-markdown-edit-shared-read-{}",
|
|
std::process::id()
|
|
));
|
|
let audit_dir = std::env::temp_dir().join(format!(
|
|
"mnote-local-agent-audit-shared-read-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
let _ = fs::remove_dir_all(&audit_dir);
|
|
std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir);
|
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
|
fs::write(
|
|
root.join(".mnote").join("workspace.json"),
|
|
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
|
|
)
|
|
.expect("manifest");
|
|
fs::write(root.join("README.md"), "原文\n").expect("write markdown");
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "local-ws-user-1",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_shared_read_md",
|
|
"runId": "run_shared_read_md",
|
|
"toolCallId": "call_shared_read_md",
|
|
"traceId": "trace_shared_read_md",
|
|
"idempotencyKey": "idem_shared_read_md",
|
|
"dryRun": false,
|
|
"args": {
|
|
"aiAccessScope": {
|
|
"permissionLevel": "shared_read",
|
|
"shareContext": {"shareId": "share_read_1"}
|
|
},
|
|
"operations": [{"search": "原文", "replace": "不应写入"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_tool_shared_read_write_forbidden")
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(root.join("README.md")).expect("read"),
|
|
"原文\n"
|
|
);
|
|
std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR");
|
|
let _ = fs::remove_dir_all(&root);
|
|
let _ = fs::remove_dir_all(&audit_dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_shared_read_rejection_writes_local_agent_audit() {
|
|
let _env_guard = env_lock().lock().expect("env lock");
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-markdown-edit-shared-read-audit-{}",
|
|
std::process::id()
|
|
));
|
|
let audit_dir = std::env::temp_dir().join(format!(
|
|
"mnote-local-agent-audit-rejected-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
let _ = fs::remove_dir_all(&audit_dir);
|
|
std::env::set_var("MNOTE_LOCAL_AGENT_AUDIT_DIR", &audit_dir);
|
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
|
fs::write(
|
|
root.join(".mnote").join("workspace.json"),
|
|
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
|
|
)
|
|
.expect("manifest");
|
|
fs::write(root.join("README.md"), "原文\n").expect("write markdown");
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "local-ws-user-1",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_shared_read_md_audit",
|
|
"runId": "run_shared_read_md_audit",
|
|
"toolCallId": "call_shared_read_md_audit",
|
|
"traceId": "trace_shared_read_md_audit",
|
|
"idempotencyKey": "idem_shared_read_md_audit",
|
|
"dryRun": false,
|
|
"args": {
|
|
"aiAccessScope": {
|
|
"permissionLevel": "shared_read",
|
|
"shareContext": {"shareId": "share_read_1"}
|
|
},
|
|
"operations": [{"search": "原文", "replace": "不应写入"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let jsonl = fs::read_to_string(audit_dir.join("agent-audit.jsonl")).expect("audit jsonl");
|
|
let event = jsonl
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
|
.find(|event| event["runId"] == "run_shared_read_md_audit")
|
|
.expect("audit event");
|
|
assert_eq!(event["runId"], "run_shared_read_md_audit");
|
|
assert_eq!(event["toolName"], "mnote.doc.markdown_edit");
|
|
assert_eq!(event["toolCallId"], "call_shared_read_md_audit");
|
|
assert_eq!(event["status"], "read_only_write_rejected");
|
|
assert_eq!(event["writeAttemptRejected"], true);
|
|
assert_eq!(
|
|
event["rejection"]["code"],
|
|
"mnote_tool_shared_read_write_forbidden"
|
|
);
|
|
assert!(event["changedFiles"].as_array().unwrap().is_empty());
|
|
assert_eq!(
|
|
fs::read_to_string(root.join("README.md")).expect("read"),
|
|
"原文\n"
|
|
);
|
|
|
|
std::env::remove_var("MNOTE_LOCAL_AGENT_AUDIT_DIR");
|
|
let _ = fs::remove_dir_all(&root);
|
|
let _ = fs::remove_dir_all(&audit_dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_reports_empty_block_mapping_before_apply() {
|
|
// 7-27 修复后:即使 search 吃掉了块注释,也不会崩溃或泄露 apply_block_ops 错误。
|
|
// 新行为:search 命中后标记为 applied,块文本变为纯文本「测试123」(无块注释),
|
|
// parse_final_markdown_to_blocks 将其识别为新块,原 p_2 块保留。
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"idempotencyKey": "idem_markdown_mapping_empty_2",
|
|
"dryRun": false,
|
|
"args": {
|
|
"operations": [{"search": "第二段 <!-- block:p_2 -->", "replace": "测试123"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["operationsApplied"], 1);
|
|
// 确认没有泄露 apply_block_ops 错误
|
|
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
|
assert!(!text.contains("mnote.doc.apply_block_ops operations 不能为空"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping() {
|
|
// 7-27 修复后:full_content 不再被拒绝。新行为:纯文本(无块注释)全部识别为新块,
|
|
// 写入后与原有复杂块(如有)合并保留。
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"idempotencyKey": "idem_markdown_full_content_online_2",
|
|
"dryRun": false,
|
|
"args": {
|
|
"full_content": "章节一\n\n第一段已改\n\n第二段已改"
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["operationsApplied"], 1);
|
|
// 确认没有泄露旧错误
|
|
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
|
assert!(!text.contains("mnote_doc_markdown_edit_block_mapping_empty"));
|
|
assert!(!text.contains("mnote.doc.apply_block_ops operations 不能为空"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_maps_normalized_search_to_block() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"idempotencyKey": "idem_markdown_normalized_1",
|
|
"dryRun": true,
|
|
"args": {
|
|
"operations": [{"search": "第二 段", "replace": "测试123"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["operationsApplied"], 1);
|
|
// 7-27: 新路径 changedBlocks 格式验证
|
|
let changed = payload["result"]["applyResult"]["diff"]
|
|
.as_array()
|
|
.expect("diff");
|
|
assert!(!changed.is_empty(), "diff should not be empty");
|
|
assert_eq!(
|
|
payload["result"]["applyResult"]["diff"][0]["blockId"],
|
|
"p_2"
|
|
);
|
|
assert_eq!(payload["result"]["applyResult"]["diff"][0]["op"], "replace");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_online_page_body_save_carries_revision_conflict_key() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_markdown_precondition",
|
|
"runId": "run_markdown_precondition",
|
|
"toolCallId": "call_markdown_precondition",
|
|
"traceId": "trace_markdown_precondition",
|
|
"idempotencyKey": "idem_markdown_precondition",
|
|
"dryRun": false,
|
|
"args": {
|
|
"operations": [{"search": "第二段", "replace": "测试123"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let command_payload =
|
|
&payload["result"]["applyResult"]["artifacts"]["commandLog"]["payload"];
|
|
assert_eq!(command_payload["revision"], 7);
|
|
assert_eq!(command_payload["conflictDetectionKey"], "doc_1:7");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_local_folder_writes_same_markdown_file() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-markdown-edit-local-folder-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("README.assets")).expect("create asset dir");
|
|
fs::write(
|
|
root.join("README.md"),
|
|
"---\ntitle: AI Local\n---\n# AI Local\n\n第一段\n\n\n",
|
|
)
|
|
.expect("write markdown");
|
|
fs::write(root.join("README.assets").join("photo.png"), b"png").expect("write asset");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_1", &root_uri,
|
|
)
|
|
.expect("initialize workspace");
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "local-ws-test",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"actorId": "user_1",
|
|
"sessionId": "sess_local_folder_md",
|
|
"runId": "run_local_folder_md",
|
|
"toolCallId": "call_local_folder_md",
|
|
"traceId": "trace_local_folder_md",
|
|
"idempotencyKey": "idem_local_folder_md",
|
|
"dryRun": false,
|
|
"args": {
|
|
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let status = response.status();
|
|
let headers = response.headers().clone();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
|
if status != StatusCode::OK {
|
|
panic!("status={status} headers={headers:?} body={text}");
|
|
}
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["source"], "local_folder");
|
|
assert_eq!(
|
|
payload["result"]["applyResult"]["commandName"],
|
|
"page.body.write"
|
|
);
|
|
let saved = fs::read_to_string(root.join("README.md")).expect("read markdown");
|
|
assert!(saved.contains("第一段已由 AI 修改"));
|
|
assert!(saved.contains("README.assets/photo.png"));
|
|
assert!(!saved.contains("/api/media"));
|
|
assert!(!saved.contains("assetId"));
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_rejects_no_applied_operations() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_markdown_noop",
|
|
"runId": "run_markdown_noop",
|
|
"toolCallId": "call_markdown_noop",
|
|
"traceId": "trace_markdown_noop",
|
|
"idempotencyKey": "idem_markdown_noop",
|
|
"dryRun": false,
|
|
"args": {
|
|
"operations": [{"search": "不存在的段落", "replace": "测试123"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_markdown_edit_no_operations_applied")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_rejects_selection_out_of_scope() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_markdown_scope",
|
|
"runId": "run_markdown_scope",
|
|
"toolCallId": "call_markdown_scope",
|
|
"traceId": "trace_markdown_scope",
|
|
"idempotencyKey": "idem_markdown_scope",
|
|
"dryRun": true,
|
|
"args": {
|
|
"allowedTargetBlockIds": ["p_2"],
|
|
"operations": [{"search": "第一段", "replace": "不应越权修改"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("mnote_markdown_edit_target_out_of_scope")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_markdown_edit_merges_same_block_operations() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.doc.markdown_edit",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"idempotencyKey": "idem_markdown_same_block_1",
|
|
"dryRun": true,
|
|
"args": {
|
|
"operations": [
|
|
{"search": "二", "replace": "2"},
|
|
{"search": "2段", "replace": "2段落"}
|
|
]
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["operationsApplied"], 2);
|
|
let diff = payload["result"]["applyResult"]["diff"]
|
|
.as_array()
|
|
.expect("diff");
|
|
assert_eq!(diff.len(), 1);
|
|
assert_eq!(diff[0]["blockId"], "p_2");
|
|
// dryRun 时文本不落盘,但 diff 记录预期修改后的内容
|
|
assert!(diff[0]["content"].as_str().unwrap_or("").contains("2段落"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_update_options_dry_run_filters_unwired_fields() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.page.update_options",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"idempotencyKey": "idem_options_1",
|
|
"dryRun": true,
|
|
"args": {"options": {"wideLayout": true, "pageFont": "serif"}}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let options = &payload["result"]["diff"][0]["payload"]["options"];
|
|
assert_eq!(options["wideLayout"], true);
|
|
assert!(options.get("pageFont").is_none());
|
|
assert_eq!(payload["result"]["ignoredOptions"][0], "pageFont");
|
|
assert_eq!(
|
|
payload["result"]["warnings"][0]["code"],
|
|
"page_option_not_wired"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_artifact_dry_run_returns_artifact_plan() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.artifact.create_summary",
|
|
"workspaceId": "ws_demo",
|
|
"documentId": "doc_1",
|
|
"sessionId": "sess_1",
|
|
"runId": "run_1",
|
|
"toolCallId": "call_1",
|
|
"traceId": "trace_1",
|
|
"idempotencyKey": "idem_summary_1",
|
|
"dryRun": true,
|
|
"args": {"summary": "摘要内容"}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["result"]["dryRun"], true);
|
|
assert_eq!(payload["result"]["artifactType"], "summary");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hermes_tools_artifact_summary_local_folder_writes_sidecar_file() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-artifact-local-folder-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
|
fs::write(
|
|
root.join(".mnote").join("workspace.json"),
|
|
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
|
|
)
|
|
.expect("manifest");
|
|
fs::write(root.join("README.md"), "# Local\n").expect("markdown");
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hermes/tools/mnote/call")
|
|
.header("content-type", "application/json")
|
|
.header("x-mnote-actor-id", "user_1")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(
|
|
json!({
|
|
"toolName": "mnote.artifact.create_summary",
|
|
"workspaceId": "local-ws-user-1",
|
|
"documentId": "local-md:README.md",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"sessionId": "sess_artifact_local",
|
|
"runId": "run_artifact_local",
|
|
"toolCallId": "call_artifact_local",
|
|
"traceId": "trace_artifact_local",
|
|
"idempotencyKey": "idem_artifact_local",
|
|
"dryRun": false,
|
|
"args": {"summary": "本地摘要"}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
|
assert_eq!(status, StatusCode::OK, "{text}");
|
|
let payload: Value = serde_json::from_str(&text).expect("json");
|
|
assert_eq!(payload["result"]["source"], "local_folder");
|
|
let artifact_path = root
|
|
.join(".mnote")
|
|
.join("artifacts")
|
|
.join("summary_local-md_README.md.json");
|
|
let artifact = fs::read_to_string(&artifact_path).expect("artifact");
|
|
assert!(artifact.contains("本地摘要"), "{artifact}");
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
}
|