feat: EditorRuntimeActor - 三层缓存/delta/事件架构
Phase A — EditorRuntimeActor 内存缓存层 - 新增 editor_actor.rs: EditorBlockDocument 内存态 + apply_command + load_or_init - block.rs 四个写工具(replace/insert/delete/move)接入 actor 路径 - editor_actor feature flag(MNOTE_WEB_ENABLE_EDITOR_ACTOR=true 默认开启) - bridge-runtime 三个核心函数公开化 - rust-toolchain: 1.89 → stable(修复 spike WASM 编译阻塞) Phase B — 编辑器增量 delta channel - BlockDelta/DeltaOperation 类型 + actor.build_block_delta() - leptos-tiptap spike: mnote:editor:block-delta CustomEvent 监听 + JSON patch - DocumentAiAgentPanel: 拦截 blockDelta → window dispatchEvent - 工具响应含 blockDelta 字段供前端消费 Phase C — 事件 stream delta - broadcast channel 在 AppState/actor/SSE 三层贯通 - tree_events SSE 端点发 block.delta 事件 - 旧客户端降级兼容 环境修复 - rustc recursion_limit = 1024(修复 Leptos SSR 类型深度溢出) - run-convex-deploy.js(封装 Convex function 部署到本地后端 3210) ref: design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
use super::hermes_client;
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{artifact, manifest, page, ToolCallInput};
|
||||
use crate::hermes_tools::{artifact, block, doc, manifest, page, ToolCallInput};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
@@ -75,7 +76,7 @@ pub async fn mnote_call(
|
||||
let dry_run = input.dry_run.unwrap_or(false);
|
||||
let effect = if dry_run {
|
||||
"dry_run"
|
||||
} else if input.tool_name == "mnote.page.get" {
|
||||
} else if is_read_tool(&input.tool_name) {
|
||||
"read"
|
||||
} else {
|
||||
"write"
|
||||
@@ -98,6 +99,15 @@ pub async fn mnote_call(
|
||||
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,
|
||||
@@ -107,9 +117,34 @@ pub async fn mnote_call(
|
||||
"toolName": input.tool_name,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"dryRun": dry_run
|
||||
"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",
|
||||
@@ -150,6 +185,15 @@ pub async fn mnote_call(
|
||||
return Ok((StatusCode::OK, stamp_tool_headers(), Json(cached)));
|
||||
}
|
||||
let result = match input.tool_name.as_str() {
|
||||
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
|
||||
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
|
||||
"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.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,
|
||||
@@ -240,6 +284,13 @@ pub async fn mnote_call(
|
||||
Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
|
||||
}
|
||||
|
||||
fn is_read_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"mnote.page.get" | "mnote.doc.fetch" | "mnote.doc.find" | "mnote.block.fetch"
|
||||
)
|
||||
}
|
||||
|
||||
fn audit_log() -> &'static Mutex<Vec<Value>> {
|
||||
static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
|
||||
LOG.get_or_init(|| Mutex::new(Vec::new()))
|
||||
@@ -332,7 +383,7 @@ fn idempotency_cache_key(
|
||||
document_id: Option<&str>,
|
||||
dry_run: bool,
|
||||
) -> Option<String> {
|
||||
if dry_run || input.tool_name == "mnote.page.get" {
|
||||
if dry_run || is_read_tool(&input.tool_name) {
|
||||
return None;
|
||||
}
|
||||
let idempotency_key = input.idempotency_key.as_deref()?.trim();
|
||||
@@ -359,7 +410,8 @@ fn idempotency_cache_put(key: String, response: Value) {
|
||||
}
|
||||
|
||||
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
|
||||
let has_actor = context.auth.actor_id.trim() != "anonymous";
|
||||
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(());
|
||||
}
|
||||
@@ -377,24 +429,39 @@ fn authenticated_tool_context(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<RequestContext, WebError> {
|
||||
if ensure_authenticated(context).is_ok() {
|
||||
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")
|
||||
.filter(|value| !value.is_empty() && *value != "anonymous" && *value != "hermes")
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"mnote_tool_unauthorized",
|
||||
"mnote Hermes tool 需要登录后访问",
|
||||
"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()
|
||||
@@ -487,8 +554,15 @@ mod tests {
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
@@ -498,6 +572,7 @@ mod tests {
|
||||
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,
|
||||
@@ -523,6 +598,16 @@ mod tests {
|
||||
"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,
|
||||
@@ -531,13 +616,63 @@ mod tests {
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: None,
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn call_tool_ok(payload: Value) -> Value {
|
||||
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 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()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_manifest_returns_first_batch_tools() {
|
||||
let response = app()
|
||||
@@ -558,6 +693,27 @@ mod tests {
|
||||
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"));
|
||||
let page_save = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.page.save")
|
||||
@@ -622,6 +778,59 @@ mod tests {
|
||||
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()
|
||||
@@ -693,6 +902,540 @@ mod tests {
|
||||
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"]["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_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"],
|
||||
"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"],
|
||||
"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"],
|
||||
"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"],
|
||||
"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"],
|
||||
"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"],
|
||||
"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"],
|
||||
"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_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"],
|
||||
"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"],
|
||||
"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_reject_workspace_context_conflict_without_content_leak() {
|
||||
let response = app()
|
||||
|
||||
Reference in New Issue
Block a user