From d47f6447fd03cd385cdb514d6de6eb8ced9ce274 Mon Sep 17 00:00:00 2001 From: Agent Board Date: Sat, 11 Jul 2026 20:34:21 +0800 Subject: [PATCH] fix: restore pi rust builtin tool surface - expose Pi Rust built-in tools by permission mode instead of replacing them with MNote file tools - update Pi Lab smoke coverage for native ls/read/bash usage - document the overreplacement regression and verification evidence --- ...i-rust-builtin-tools-overreplacement-v1.md | 39 + .../crates/mnote-web/src/routes/page_ai_pi.rs | 2810 ++++++++++++++++- ...pi-lab-full-access-builtin-delete-smoke.js | 170 +- scripts/task-pi-lab-rpc-api-smoke.js | 31 +- scripts/task-pi-lab-static-smoke.js | 159 +- 5 files changed, 3063 insertions(+), 146 deletions(-) create mode 100644 bugs/07-ai/done/7-62-page-ai-pi-rust-builtin-tools-overreplacement-v1.md diff --git a/bugs/07-ai/done/7-62-page-ai-pi-rust-builtin-tools-overreplacement-v1.md b/bugs/07-ai/done/7-62-page-ai-pi-rust-builtin-tools-overreplacement-v1.md new file mode 100644 index 00000000..e389a877 --- /dev/null +++ b/bugs/07-ai/done/7-62-page-ai-pi-rust-builtin-tools-overreplacement-v1.md @@ -0,0 +1,39 @@ +# 7-62 Page AI Pi Rust 内置工具被 MNote 工具过度替代 + +## 状态 + +- 已修复 +- 日期:2026-07-11 +- Owner:`07-ai` + +## 症状 + +Pi Lab 切到 `pi_agent_rust` 后,普通对话可用,但文件/目录类任务会退化为只看到 `mnote_local_file_read` / `mnote_local_file_patch` 等 MNote bridge 工具。用户要求列目录或删除测试文件时,模型会判断“没有删除工具”“不能直接列目录”,甚至尝试把 MNote read 当作文件系统能力。 + +## 根因 + +MNote 启动 Pi Rust RPC 时固定传 `--tools `。此前 allowlist 主要由 MNote bridge 工具组成,Pi Rust 官方 8 个内置工具只有在旧外部 `pi-permission-system` 条件下才会加入。当前默认使用本地官方 `permission-gate` 镜像时,这个条件不成立,导致 `read/write/edit/hashline_edit/bash/grep/find/ls` 被系统性裁掉。 + +这和 Pi Rust 官方定位冲突:Pi Rust 的文件、搜索和 shell 能力应由 Pi 原生 builtins 承载,MNote 只补当前页、allowed roots、URL/reference、知识库和宿主上下文。 + +## 修复 + +- `full_access`:恢复 Pi Rust 官方 8 个 builtins:`read/write/edit/hashline_edit/bash/grep/find/ls`。 +- `auto_edit`:开放读写编辑和检索类 builtins,但不开放 `bash`。 +- `plan`:仅开放只读 builtins:`read/grep/find/ls`。 +- `mnote_allowed_roots_describe` 返回的 `managedPiBuiltinTools` / `deniedPiBuiltinTools` / `permissionProvider` / `note` 按 permission mode 精确说明,避免继续诱导模型把 MNote file tool 当主文件系统工具。 +- full_access smoke 改为要求真实调用 Pi Rust `ls/read/bash`,并断言不调用 `mnote_local_file_read` / `mnote_local_file_patch`。 + +## 验证 + +- `node scripts/task-pi-lab-static-smoke.js`:246 checks passed +- `node --check scripts/task-pi-lab-full-access-builtin-delete-smoke.js` +- `node --check scripts/task-pi-lab-static-smoke.js` +- `node --check scripts/task-pi-lab-rpc-api-smoke.js` +- `node --check scripts/task-pi-lab-user-exact-web-smoke.js` +- `cargo test -p mnote-web page_ai_pi::tests::permission_modes_expose_pi_builtins_by_mode -- --nocapture`:passed +- `cargo test -p mnote-web page_ai_pi::tests::start_uses_ai_settings_for_model_skills_mcp_and_tools -- --nocapture`:passed + +## 遗留 + +`confirm` 模式仍未接入 Pi Rust 原生 tool approval 流;在 MNote 能可靠承接 approval 前,不应把 `bash/write/edit` 暴露给 confirm 模式并假装已有审批闭环。 diff --git a/rust/crates/mnote-web/src/routes/page_ai_pi.rs b/rust/crates/mnote-web/src/routes/page_ai_pi.rs index 6e5cde1c..297d747c 100644 --- a/rust/crates/mnote-web/src/routes/page_ai_pi.rs +++ b/rust/crates/mnote-web/src/routes/page_ai_pi.rs @@ -6,6 +6,7 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; +use crate::hermes_tools::knowledge_rag as knowledge_rag_agent_output; use crate::routes::{ai_settings, knowledge_rag, local_folder_source}; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, StatusCode}; @@ -23,13 +24,15 @@ use std::collections::{HashMap, HashSet}; use std::convert::Infallible; use std::fs::{self, OpenOptions}; use std::io::{Read as _, Write as _}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; -use tokio::sync::{broadcast, Mutex as AsyncMutex}; +use tokio::sync::{broadcast, oneshot, Mutex as AsyncMutex}; use tokio_stream::wrappers::BroadcastStream; const PI_LAB_VERSION: &str = "0.1.0-pi-lab-spike"; @@ -37,8 +40,15 @@ const PI_LAB_PROVIDER: &str = "pi"; const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1"; const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1"; const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1"; +const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1"; +const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1"; +const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1"; const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute"; -const PI_LAB_DEFAULT_MODEL_ID: &str = "freefirst"; +const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1"; +const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1"; +const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1"; + +const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini"; const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1"; const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token"; const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000; @@ -81,6 +91,8 @@ static PI_LAB_EVENT_TX: LazyLock> = LazyLock::new(|| { let (tx, _) = broadcast::channel(1024); tx }); +static PI_LAB_PENDING_RPC_RESPONSES: LazyLock>>> = + LazyLock::new(|| AsyncMutex::new(HashMap::new())); #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -92,6 +104,16 @@ pub enum PiLabSessionStatus { Error, } +fn status_after_agent_end(status: &PiLabSessionStatus) -> PiLabSessionStatus { + match status { + PiLabSessionStatus::Aborted => PiLabSessionStatus::Aborted, + PiLabSessionStatus::Error => PiLabSessionStatus::Error, + PiLabSessionStatus::Idle + | PiLabSessionStatus::RuntimeRunning + | PiLabSessionStatus::TurnRunning => PiLabSessionStatus::RuntimeRunning, + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabSession { @@ -197,6 +219,30 @@ pub struct PiLabBootstrapRequest { pub permission_mode: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabStateRequest { + pub session_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabCompactRequest { + pub session_id: String, + pub custom_instructions: Option, + pub reserve_tokens: Option, + pub keep_recent_tokens: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabQueueConfigRequest { + pub session_id: String, + pub steering_mode: Option, + pub follow_up_mode: Option, + pub auto_compaction: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabStartRequest { @@ -648,6 +694,7 @@ fn resolve_file_path( state: &AppState, context: &RequestContext, params: &Value, + session: Option<&PiLabSession>, require_write: bool, ) -> Result<(PathBuf, Option, Option), WebError> { let root_uri = params @@ -655,7 +702,9 @@ fn resolve_file_path( .or_else(|| params.get("root_uri")) .and_then(Value::as_str) .map(str::trim) - .filter(|value| !value.is_empty()); + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| session.and_then(|session| session.root_uri.clone())); let path = params .get("path") .or_else(|| params.get("relativePath")) @@ -665,9 +714,34 @@ fn resolve_file_path( .filter(|value| !value.is_empty()) .ok_or_else(|| WebError::bad_request_code("page_ai_pi_lab_path_required", "缺少 path"))?; - if let Some(root_uri) = root_uri { - let target = resolve_root_relative_path(state, context, root_uri, path, require_write)?; - return Ok((target, Some(root_uri.to_string()), Some(path.to_string()))); + if let Some(root_uri) = root_uri.as_deref() { + let folder_path = params + .get("folderPath") + .or_else(|| params.get("folder_path")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let relative_path = if let Some(folder_path) = folder_path { + let normalized_path = path.replace('\\', "/").trim_start_matches('/').to_string(); + let normalized_folder = folder_path + .replace('\\', "/") + .trim_matches('/') + .to_string(); + if normalized_folder.is_empty() + || Path::new(&normalized_path).is_absolute() + || normalized_path == normalized_folder + || normalized_path.starts_with(&format!("{normalized_folder}/")) + { + normalized_path + } else { + format!("{normalized_folder}/{normalized_path}") + } + } else { + path.to_string() + }; + let target = + resolve_root_relative_path(state, context, root_uri, &relative_path, require_write)?; + return Ok((target, Some(root_uri.to_string()), Some(relative_path))); } let requested = PathBuf::from(path); @@ -796,8 +870,9 @@ fn session_from_request( })?; let mut runtime_policy_snapshot = serde_json::to_value(&runtime_policy).unwrap_or_else(|_| json!({})); - if let Some(permission_mode) = permission_mode { - runtime_policy_snapshot["permissionMode"] = json!(permission_mode); + if let Some(permission_mode) = permission_mode.as_deref() { + runtime_policy_snapshot = + refresh_runtime_policy_permission_mode_value(runtime_policy_snapshot, permission_mode); } let now = now_ms(); Ok(PiLabSession { @@ -1171,10 +1246,26 @@ fn pi_lab_official_permission_gate_enabled(session: &PiLabSession) -> bool { } fn pi_lab_enabled_builtin_tools(session: &PiLabSession) -> Vec { - if pi_lab_permission_system_enabled(session) { - pi_lab_managed_builtin_tools() - } else { - Vec::new() + match session_permission_mode(session) { + Some("full_access") => pi_lab_managed_builtin_tools(), + Some("auto_edit") => [ + "read", + "write", + "edit", + "grep", + "find", + "ls", + "hashline_edit", + ] + .iter() + .map(|tool| (*tool).to_string()) + .collect(), + Some("plan") => ["read", "grep", "find", "ls"] + .iter() + .map(|tool| (*tool).to_string()) + .collect(), + _ if pi_lab_permission_system_enabled(session) => pi_lab_managed_builtin_tools(), + _ => Vec::new(), } } @@ -1223,6 +1314,26 @@ fn normalize_permission_mode(value: Option<&str>) -> Result, Stri } } +fn normalize_queue_mode(value: &str, field: &str) -> Result { + let raw = value.trim(); + let normalized = match raw + .chars() + .filter(|ch| !matches!(ch, '-' | '_' | ' ')) + .collect::() + .to_ascii_lowercase() + .as_str() + { + "all" => "all".to_string(), + "oneatatime" => "one-at-a-time".to_string(), + _ => { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_invalid_queue_mode", + format!("{field} 的值无效: \"{value}\"。支持的值: \"all\" 或 \"one-at-a-time\"",), + )); + } + }; + Ok(normalized) +} fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy: &str) -> String { match mode { Some("plan") => match tool_name { @@ -1274,6 +1385,36 @@ fn session_permission_mode(session: &PiLabSession) -> Option<&str> { .and_then(Value::as_str) } +fn refresh_runtime_policy_permission_mode_value(mut policy: Value, mode: &str) -> Value { + policy["permissionMode"] = json!(mode); + let tool_names = policy + .get("mnoteToolNames") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut policies = serde_json::Map::new(); + for tool_name in tool_names.iter().filter_map(Value::as_str) { + policies.insert( + tool_name.to_string(), + json!(permission_mode_tool_policy(Some(mode), tool_name, "allow")), + ); + } + if !policies.is_empty() { + policy["mnoteToolPolicies"] = Value::Object(policies); + } + policy +} + +fn refresh_runtime_policy_permission_mode(session: &PiLabSession, mode: &str) -> Value { + refresh_runtime_policy_permission_mode_value( + session + .runtime_policy_snapshot + .clone() + .unwrap_or_else(|| json!({})), + mode, + ) +} + fn pi_lab_effective_prompt_for_session(session: &PiLabSession, message: &str) -> String { if session_permission_mode(session) != Some("plan") { return message.to_string(); @@ -1293,18 +1434,16 @@ fn pi_lab_command_message_for_session( input_context_prefix: &str, ) -> String { if !message.starts_with("/skill:") { - return format!( - "{}{}", - input_context_prefix, - pi_lab_effective_prompt_for_session(session, message) - ); + let contextual_message = format!("{input_context_prefix}{message}"); + return pi_lab_effective_prompt_for_session(session, &contextual_message); } let command_end = message.find(char::is_whitespace).unwrap_or(message.len()); let command = &message[..command_end]; let skill_args = message[command_end..].trim_start_matches(char::is_whitespace); - let effective_args = pi_lab_effective_prompt_for_session(session, skill_args); - format!("{command}\n{input_context_prefix}{effective_args}") + let contextual_args = format!("{input_context_prefix}{skill_args}"); + let effective_args = pi_lab_effective_prompt_for_session(session, &contextual_args); + format!("{command}\n{effective_args}") } fn omniroute_base_url() -> String { @@ -1320,24 +1459,151 @@ fn omniroute_base_url_is_local() -> bool { || value.starts_with("http://0.0.0.0:") } -fn omniroute_api_key() -> Option { +fn configured_omniroute_api_key() -> Option { env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_API_KEY") .or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_OPENAI_API_KEY")) .or_else(|| env_trimmed("OPENAI_API_KEY")) - .or_else(|| { - if omniroute_base_url_is_local() { - Some("mnote-local-omniroute".into()) - } else { - None - } +} + +fn omniroute_api_key() -> Option { + configured_omniroute_api_key().or_else(|| { + if omniroute_base_url_is_local() { + Some("mnote-local-omniroute".into()) + } else { + None + } + }) +} + +fn omniroute_models_url() -> String { + format!("{}/models", omniroute_base_url().trim_end_matches('/')) +} + +fn omniroute_models_api_key() -> Option { + env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_API_KEY").or_else(|| { + (!omniroute_base_url_is_local()) + .then(|| { + env_trimmed("MNOTE_PAGE_AI_PI_OPENAI_API_KEY") + .or_else(|| env_trimmed("OPENAI_API_KEY")) + }) + .flatten() + }) +} + +fn omniroute_model_tool_calling_capability(catalog: &Value, model_id: &str) -> Option { + catalog + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .find(|model| model.get("id").and_then(Value::as_str) == Some(model_id)) + .map(|model| { + model + .get("capabilities") + .and_then(|capabilities| capabilities.get("tool_calling")) + .and_then(Value::as_bool) + .unwrap_or(false) }) } +async fn ensure_session_model_supports_tools(session: &PiLabSession) -> Result { + let provider = session + .model_provider + .as_deref() + .unwrap_or(PI_LAB_DEFAULT_MODEL_PROVIDER); + if provider != "omniroute" || session.runtime_mode == "mock" { + return Ok(true); + } + let model_id = session + .model_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(PI_LAB_DEFAULT_MODEL_ID); + let models_url = omniroute_models_url(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .map_err(|error| { + WebError::bad_gateway_code( + "page_ai_pi_model_capabilities_unavailable", + format!("创建 OmniRoute 模型能力检查客户端失败: {error}"), + ) + })?; + let mut request = client.get(&models_url); + if let Some(api_key) = omniroute_models_api_key() { + request = request.bearer_auth(api_key); + } + let response = request.send().await.map_err(|error| { + WebError::bad_gateway_code( + "page_ai_pi_model_capabilities_unavailable", + format!("无法读取 OmniRoute 模型能力: {error}"), + ) + .with_details(json!({ + "provider": provider, + "modelId": model_id, + "modelsUrl": models_url, + })) + })?; + let status = response.status(); + if !status.is_success() { + return Err(WebError::bad_gateway_code( + "page_ai_pi_model_capabilities_unavailable", + format!("OmniRoute 模型能力接口返回 {status}"), + ) + .with_details(json!({ + "provider": provider, + "modelId": model_id, + "modelsUrl": models_url, + }))); + } + let catalog = response.json::().await.map_err(|error| { + WebError::bad_gateway_code( + "page_ai_pi_model_capabilities_unavailable", + format!("解析 OmniRoute 模型能力失败: {error}"), + ) + .with_details(json!({ + "provider": provider, + "modelId": model_id, + "modelsUrl": models_url, + })) + })?; + match omniroute_model_tool_calling_capability(&catalog, model_id) { + Some(true) => Ok(true), + Some(false) => Err(WebError::new( + StatusCode::BAD_REQUEST, + "page_ai_pi_model_tools_unsupported", + format!( + "模型 omniroute/{model_id} 不支持工具调用;Page AI 已启用 skill、扩展、MCP 与文件工具,请选择支持 tool_calling 的模型" + ), + ) + .with_details(json!({ + "provider": provider, + "modelId": model_id, + "requiredCapability": "tool_calling", + "recommendedModel": format!("{PI_LAB_DEFAULT_MODEL_PROVIDER}/{PI_LAB_DEFAULT_MODEL_ID}"), + }))), + None => Err(WebError::new( + StatusCode::BAD_REQUEST, + "page_ai_pi_model_tools_unsupported", + format!("OmniRoute 模型目录中未找到模型 {model_id},无法确认工具调用能力"), + ) + .with_details(json!({ + "provider": provider, + "modelId": model_id, + "requiredCapability": "tool_calling", + "modelsUrl": models_url, + }))), + } +} + /// 为 Pi 子进程生成每 session 受控的 models.json。 /// - 写入 `/config/models.json` -/// - API key 仅以环境变量引用($OPENAI_API_KEY),不写入明文 +/// - API key 仅以环境变量名引用(OPENAI_API_KEY),不写入明文 /// - 调用方应将 `PI_CODING_AGENT_DIR` 设为返回的 config 目录 -fn ensure_session_models_config(session: &PiLabSession) -> Result { +fn ensure_session_models_config( + session: &PiLabSession, + supports_tools: bool, +) -> Result { let config_dir = PathBuf::from(&session.pi_session_dir).join("config"); fs::create_dir_all(&config_dir).map_err(|error| { WebError::internal(format!("创建 Pi Lab session config 目录失败: {error}")) @@ -1352,34 +1618,46 @@ fn ensure_session_models_config(session: &PiLabSession) -> Result>(); let models = match model_provider { "omniroute" => json!({ "providers": { "omniroute": { "baseUrl": omniroute_base_url(), "api": "openai-completions", - "apiKey": "$OPENAI_API_KEY", + "apiKey": "OPENAI_API_KEY", "authHeader": true, "compat": { "supportsDeveloperRole": false, - "supportsReasoningEffort": false + "supportsReasoningEffort": false, + "supportsTools": supports_tools, + "supportsUsageInStreaming": true }, - "models": [ - { - "id": model_id, - "name": format!("OmniRoute {}", model_id), - "input": ["text"], - "reasoning": false, - "contextWindow": 128000, - "maxTokens": 65536, - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - } - } - ] + "models": omniroute_models } } }), @@ -1954,6 +2232,10 @@ fn ui_response_key(session_id: &str, request_id: &str) -> String { format!("{session_id}:{request_id}") } +fn rpc_response_key(session_id: &str, rpc_id: &str) -> String { + format!("{session_id}:{rpc_id}") +} + fn record_pending_approval_from_event(session_id: &str, payload: &Value) { if payload.get("type").and_then(Value::as_str) != Some("extension_ui_request") || payload.get("method").and_then(Value::as_str) != Some("confirm") @@ -2286,6 +2568,55 @@ fn stage_extension_file( Ok(target_path.to_string_lossy().to_string()) } +fn bind_mnote_bridge_context_path(extension_path: &str, context_path: &Path) -> Result<(), WebError> { + let target = Path::new(extension_path); + let source = fs::read_to_string(target).map_err(|error| { + WebError::internal(format!( + "读取 staged MNote Pi bridge 扩展失败: {}: {error}", + target.display() + )) + })?; + let canonical_context_path = context_path + .canonicalize() + .unwrap_or_else(|_| context_path.to_path_buf()); + let context_literal = serde_json::to_string(&canonical_context_path.to_string_lossy().to_string()) + .map_err(|error| WebError::internal(format!("编码 Pi Rust MNote context 路径失败: {error}")))?; + let context_payload: Value = serde_json::from_slice(&fs::read(context_path).map_err(|error| { + WebError::internal(format!( + "读取 Pi Rust MNote context 失败: {}: {error}", + context_path.display() + )) + })?) + .map_err(|error| WebError::internal(format!("解析 Pi Rust MNote context 失败: {error}")))?; + let context_snapshot_literal = serde_json::to_string(&context_payload) + .map_err(|error| WebError::internal(format!("编码 Pi Rust MNote context 快照失败: {error}")))?; + let context_file_needle = r#"const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json");"#; + let context_snapshot_needle = + r#"const EMBEDDED_CONTEXT: Record | undefined = undefined;"#; + if !source.contains(context_file_needle) || !source.contains(context_snapshot_needle) { + return Err(WebError::internal( + "staged MNote Pi bridge 扩展缺少 context 绑定点", + )); + } + let next = source.replace( + context_file_needle, + &format!("const DEFAULT_CONTEXT_FILE = {context_literal};"), + ) + .replace( + context_snapshot_needle, + &format!( + "const EMBEDDED_CONTEXT: Record | undefined = {context_snapshot_literal};" + ), + ); + fs::write(target, next).map_err(|error| { + WebError::internal(format!( + "写入 staged MNote Pi bridge 扩展失败: {}: {error}", + target.display() + )) + })?; + Ok(()) +} + fn pi_mnote_context_path(session: &PiLabSession) -> PathBuf { PathBuf::from(&session.pi_session_dir) .join("config") @@ -2303,10 +2634,14 @@ fn pi_mnote_context_payload( "schema": "mnote.pi.context.v1", "runtimeImplementation": pi_runtime_impl(), "sessionId": session.session_id, + "bridgeBaseUrl": pi_lab_public_base_url(), "rootUri": session.root_uri, "workspaceId": session.workspace_id, "pagePath": session.page_path, "pageTitle": session.page_title, + "modelProvider": session.model_provider, + "modelId": session.model_id, + "thinkingLevel": session.thinking_level, "primaryRootPath": primary_allowed_root_path(session), "allowedRoots": session.allowed_roots_snapshot, "toolPolicies": mnote_pi_tool_policies(session), @@ -2327,13 +2662,31 @@ fn write_pi_mnote_context_snapshot( WebError::internal(format!("创建 Pi Rust MNote context 目录失败: {error}")) })?; } - let payload = pi_mnote_context_payload(session, selected_context, context_refs); + let mut payload = pi_mnote_context_payload(session, selected_context, context_refs); + if let Some(object) = payload.as_object_mut() { + object.insert( + "bridgeToken".to_string(), + Value::String(session.bridge_token.clone()), + ); + } let bytes = serde_json::to_vec_pretty(&payload).map_err(|error| { WebError::internal(format!("序列化 Pi Rust MNote context 失败: {error}")) })?; let temp_path = context_path.with_extension("json.tmp"); fs::write(&temp_path, bytes) .map_err(|error| WebError::internal(format!("写入 Pi Rust MNote context 失败: {error}")))?; + #[cfg(unix)] + { + let mut permissions = fs::metadata(&temp_path) + .map_err(|error| { + WebError::internal(format!("读取 Pi Rust MNote context 权限失败: {error}")) + })? + .permissions(); + permissions.set_mode(0o600); + fs::set_permissions(&temp_path, permissions).map_err(|error| { + WebError::internal(format!("收紧 Pi Rust MNote context 权限失败: {error}")) + })?; + } fs::rename(&temp_path, &context_path) .map_err(|error| WebError::internal(format!("提交 Pi Rust MNote context 失败: {error}")))?; Ok(context_path) @@ -2507,6 +2860,10 @@ fn session_runtime_is_usable(session: &PiLabSession) -> bool { ) && (session.runtime_mode == "mock" || session_has_process(&session.session_id)) } +fn session_can_auto_resume(session: &PiLabSession) -> bool { + session.runtime_error.is_none() && session_runtime_is_usable(session) +} + fn session_runtime_config_matches(existing: &PiLabSession, requested: &PiLabSession) -> bool { existing.model_provider == requested.model_provider && existing.model_id == requested.model_id @@ -2642,6 +2999,14 @@ fn check_rate_limit(actor_id: &str, action: &str, limit: usize) -> Result<(), We Ok(()) } +fn pi_lab_start_rate_limit(state: &AppState) -> usize { + if state.config().allow_dev_fixtures { + 24 + } else { + PI_LAB_MAX_STARTS_PER_WINDOW + } +} + fn get_session_for_context( state: &AppState, context: &RequestContext, @@ -2698,7 +3063,8 @@ async fn start_runtime_for_session( ) -> Result { fs::create_dir_all(&session.pi_session_dir) .map_err(|error| WebError::internal(format!("创建 Pi Lab sessionDir 失败: {error}")))?; - let pi_config_dir = ensure_session_models_config(&session)?; + let model_supports_tools = ensure_session_model_supports_tools(&session).await?; + let pi_config_dir = ensure_session_models_config(&session, model_supports_tools)?; let mcp_config_path = ensure_session_mcp_config(&session, &pi_config_dir)?; let shared_mcp_cache_path = hydrate_session_mcp_cache(&session, &pi_config_dir)?; let permission_config_path = ensure_session_pi_permission_config(&session, &pi_config_dir)?; @@ -2716,6 +3082,7 @@ async fn start_runtime_for_session( "mnote-bridge", )?; let mnote_context_path = write_pi_mnote_context_snapshot(&session, None, None)?; + bind_mnote_bridge_context_path(&mnote_pi_extension_path, &mnote_context_path)?; let enabled_builtin_tools = pi_lab_enabled_builtin_tools(&session); let configured_extension_sources = pi_lab_configured_extension_sources(&session); let mut pi_extension_sources = @@ -2768,6 +3135,14 @@ async fn start_runtime_for_session( session.runtime_pid = None; upsert_session(session.clone()); persist_upsert_run(state, &session)?; + // 扫描 pi_session_file + if session.pi_session_file.is_none() { + if let Some(pi_file) = resolve_pi_session_file(&session) { + session.pi_session_file = Some(pi_file); + upsert_session(session.clone()); + let _ = persist_upsert_run(state, &session); + } + } persist_append_event( state, &session, @@ -2862,14 +3237,20 @@ async fn start_runtime_for_session( .as_deref() .filter(|value| !value.is_empty()) { - command.arg("--provider").arg(provider); + command + .arg("--provider") + .arg(provider) + .env("MNOTE_PI_MODEL_PROVIDER", provider); } if let Some(model) = session .model_id .as_deref() .filter(|value| !value.is_empty()) { - command.arg("--model").arg(model); + command + .arg("--model") + .arg(model) + .env("MNOTE_PI_MODEL_ID", model); } if let Some(thinking) = session .thinking_level @@ -2969,6 +3350,14 @@ async fn start_runtime_for_session( session.runtime_pid = pid; session.runtime_error = None; upsert_session(session.clone()); + // 扫描 pi_session_file + if session.pi_session_file.is_none() { + if let Some(pi_file) = resolve_pi_session_file(&session) { + session.pi_session_file = Some(pi_file); + upsert_session(session.clone()); + let _ = persist_upsert_run(state, &session); + } + } publish_event( &session.session_id, "runtime_started", @@ -3037,10 +3426,20 @@ async fn start_runtime_for_session( .unwrap_or_else(|_| json!({"raw": line})); if payload.get("type").and_then(Value::as_str) == Some("agent_end") { update_session(&session_id, |session| { - session.status = PiLabSessionStatus::RuntimeRunning; + session.status = status_after_agent_end(&session.status); }); } record_pending_approval_from_event(&session_id, &payload); + // Resolve pending RPC response if this line is a Pi RPC response + if payload.get("type").and_then(Value::as_str) == Some("response") { + if let Some(resp_id) = payload.get("id").and_then(Value::as_str) { + let resp_key = rpc_response_key(&session_id, resp_id); + let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; + if let Some(tx) = pending.remove(&resp_key) { + let _ = tx.send(payload.clone()); + } + } + } persist_event("pi_rpc_event", &payload); publish_event(&session_id, "pi_rpc_event", payload); } @@ -3154,6 +3553,14 @@ async fn start_runtime_for_session( }); persist_upsert_run(state, &session)?; + // 扫描 pi_session_file + if session.pi_session_file.is_none() { + if let Some(pi_file) = resolve_pi_session_file(&session) { + session.pi_session_file = Some(pi_file); + upsert_session(session.clone()); + let _ = persist_upsert_run(state, &session); + } + } persist_append_event( state, &session, @@ -3208,6 +3615,66 @@ async fn send_rpc_command(session_id: &str, command: Value) -> Result<(), WebErr Ok(()) } +async fn send_rpc_command_wait( + session_id: &str, + command: Value, + timeout: Duration, +) -> Result, WebError> { + let rpc_id = command + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + if rpc_id.is_empty() { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_missing_rpc_id", + "Pi RPC command 缺少 id 字段", + )); + } + + let key = rpc_response_key(session_id, &rpc_id); + let (tx, rx) = oneshot::channel(); + + // Register pending response before sending + { + let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; + pending.insert(key.clone(), tx); + } + + // Send command + send_rpc_command(session_id, command).await?; + + // Wait for response with timeout + let result = tokio::time::timeout(timeout, rx).await; + + // Clean up registry + { + let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; + pending.remove(&key); + } + + match result { + Ok(Ok(response)) => Ok(Some(response)), + Ok(Err(_)) | Err(_) => Ok(None), + } +} + +fn rpc_response_success(response: Option<&Value>) -> bool { + response + .and_then(|value| value.get("success")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn rpc_response_error_message(response: Option<&Value>, fallback: &str) -> String { + response + .and_then(|value| value.get("error")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(fallback) + .to_string() +} + fn apply_text_operations(current: &str, operations: &Value) -> Result { match operations { Value::Array(ops) => { @@ -3605,17 +4072,26 @@ impl PiLabToolFacade { fn allowed_roots_describe(&self) -> Result { let roots = active_allowed_roots(&self.state, &self.context)?; + let permission_mode = self + .session + .as_ref() + .and_then(|session| session_permission_mode(session)); let managed_builtin_tools = self .session .as_ref() .map(pi_lab_enabled_builtin_tools) .unwrap_or_default(); - let denied_builtin_tools = if managed_builtin_tools.is_empty() { - pi_lab_managed_builtin_tools() - } else { - Vec::new() - }; - let permission_provider = if managed_builtin_tools.is_empty() { + let denied_builtin_tools = pi_lab_managed_builtin_tools() + .into_iter() + .filter(|tool| !managed_builtin_tools.iter().any(|managed| managed == tool)) + .collect::>(); + let permission_provider = if permission_mode == Some("full_access") { + "pi-rust-full-access-builtins" + } else if permission_mode == Some("auto_edit") { + "pi-rust-auto-edit-builtins" + } else if permission_mode == Some("plan") { + "pi-rust-readonly-builtins" + } else if managed_builtin_tools.is_empty() { "mnote-bridge-rust-policy" } else if self .session @@ -3626,7 +4102,13 @@ impl PiLabToolFacade { } else { "@gotgenes/pi-permission-system" }; - let permission_note = if managed_builtin_tools.is_empty() { + let permission_note = if permission_mode == Some("full_access") { + "Full access exposes Pi Rust built-in read/write/edit/hashline_edit/bash/grep/find/ls as first-class tools. MNote bridge tools only add current page, allowed roots, URL/reference, and knowledge context." + } else if permission_mode == Some("auto_edit") { + "Auto edit exposes Pi Rust built-in read/write/edit/hashline_edit/grep/find/ls. Bash remains reserved for full_access; MNote bridge tools only add current page, allowed roots, URL/reference, and knowledge context." + } else if permission_mode == Some("plan") { + "Plan mode exposes only Pi Rust read/grep/find/ls built-ins. Write/edit/hashline_edit/bash remain disabled until the user switches to auto_edit or full_access." + } else if managed_builtin_tools.is_empty() { "Pi built-in read/write/edit/hashline_edit/bash/grep/find/ls are disabled by default for Pi Rust; MNote bridge tools enforce allowed roots in Rust." } else if self .session @@ -3668,7 +4150,7 @@ impl PiLabToolFacade { fn local_file_read(&self, params: Value) -> Result { let (target, root_uri, relative_path) = - resolve_file_path(&self.state, &self.context, ¶ms, false)?; + resolve_file_path(&self.state, &self.context, ¶ms, self.session.as_ref(), false)?; let content = fs::read_to_string(&target).map_err(|error| { WebError::bad_request_code( "page_ai_pi_lab_file_read_failed", @@ -3687,7 +4169,7 @@ impl PiLabToolFacade { fn local_file_patch(&self, params: Value) -> Result { let (target, root_uri, relative_path) = - resolve_file_path(&self.state, &self.context, ¶ms, true)?; + resolve_file_path(&self.state, &self.context, ¶ms, self.session.as_ref(), true)?; let before_version = file_version(&target); let current = fs::read_to_string(&target).unwrap_or_default(); let next = if let Some(content) = params.get("content").and_then(Value::as_str) { @@ -3802,7 +4284,9 @@ impl PiLabToolFacade { Json(body), ) .await?; - Ok(payload) + Ok(knowledge_rag_agent_output::compact_query_result_for_agent( + payload, + )) } async fn knowledge_rag_status(&self, params: Value) -> Result { @@ -3825,7 +4309,9 @@ impl PiLabToolFacade { Query(query), ) .await?; - Ok(payload) + Ok(knowledge_rag_agent_output::compact_status_result_for_agent( + payload, + )) } async fn knowledge_rag_section_context(&self, params: Value) -> Result { @@ -3892,7 +4378,7 @@ impl PiLabToolFacade { Json(body), ) .await?; - Ok(payload) + Ok(knowledge_rag_agent_output::compact_section_context_for_agent(payload)) } async fn reference_open(&self, params: Value) -> Result { @@ -4267,6 +4753,31 @@ async fn execute_tool( let receipt_payload = write_receipt(&facade.state, receipt, &payload, citation_count); let elapsed_ms = now_ms().saturating_sub(started) as u64; if let Some(session) = session.as_ref() { + let tool_event_id = receipt_payload + .get("toolEventId") + .or_else(|| receipt_payload.get("tool_event_id")) + .and_then(Value::as_str) + .map(str::to_string); + if allowed && tool_name == "mnote.local_file.patch" { + let artifact = json!({ + "schema": "mnote.page_ai_pi.artifact.file_patch.v1", + "sessionId": session.session_id, + "providerSessionId": session.provider_session_id, + "toolEventId": tool_event_id, + "rootUri": payload.get("rootUri").cloned().unwrap_or(Value::Null), + "relativePath": payload.get("relativePath").cloned().unwrap_or_else(|| { + normalized_file_path + .as_deref() + .map(|path| json!(path)) + .unwrap_or(Value::Null) + }), + "beforeFileVersion": before_file_version, + "afterFileVersion": after_file_version, + "diffSummary": diff_summary, + "receipt": receipt_payload, + }); + publish_event(&session.session_id, "artifact_file_patch", artifact); + } publish_event( &session.session_id, "tool_call", @@ -4352,6 +4863,16 @@ pub async fn status( .values() .filter(|session| session.mnote_user_id == actor_id) .filter(|session| !is_pi_lab_warmup_session_id(&session.session_id)) + .filter(|session| session_can_auto_resume(session)) + .max_by_key(|session| session.updated_at_ms) + .cloned() + }); + let warmup_session = sessions.as_ref().and_then(|sessions| { + sessions + .values() + .filter(|session| session.mnote_user_id == actor_id) + .filter(|session| is_pi_lab_warmup_session_id(&session.session_id)) + .filter(|session| session_can_auto_resume(session)) .max_by_key(|session| session.updated_at_ms) .cloned() }); @@ -4376,6 +4897,17 @@ pub async fn status( .collect::>() }) .unwrap_or_default(); + let owned_warmup_session_ids = sessions + .as_ref() + .map(|sessions| { + sessions + .values() + .filter(|session| session.mnote_user_id == actor_id) + .filter(|session| is_pi_lab_warmup_session_id(&session.session_id)) + .map(|session| session.session_id.clone()) + .collect::>() + }) + .unwrap_or_default(); let process_count = PI_LAB_PROCESSES .lock() .map(|processes| { @@ -4385,6 +4917,15 @@ pub async fn status( .count() }) .unwrap_or(0); + let warmup_process_count = PI_LAB_PROCESSES + .lock() + .map(|processes| { + owned_warmup_session_ids + .iter() + .filter(|session_id| processes.contains_key(*session_id)) + .count() + }) + .unwrap_or(0); let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) = pi_runtime_status_snapshot(); let runtime_error = current_session @@ -4435,6 +4976,11 @@ pub async fn status( "session": current_session, "activeSessionCount": active_session_count, "processCount": process_count, + "warmupRunning": warmup_process_count > 0 || warmup_session.as_ref().is_some_and(|session| session.status == PiLabSessionStatus::RuntimeRunning || session.status == PiLabSessionStatus::TurnRunning), + "warmupSessionId": warmup_session.as_ref().map(|session| session.session_id.clone()), + "warmupStatus": warmup_session.as_ref().map(|session| session_status_to_string(&session.status)), + "warmupSessionCount": owned_warmup_session_ids.len(), + "warmupProcessCount": warmup_process_count, "managedPiSessionDirPolicy": "/.mnote/ai/pi-sessions//", "managedPiBuiltinTools": current_session .as_ref() @@ -4454,7 +5000,7 @@ pub async fn bootstrap( ensure_enabled(&state)?; cleanup_expired_sessions(); let actor_id = ensure_authenticated(&state, &context)?; - check_rate_limit(&actor_id, "start", PI_LAB_MAX_STARTS_PER_WINDOW)?; + check_rate_limit(&actor_id, "start", pi_lab_start_rate_limit(&state))?; let start_request = PiLabStartRequest { session_id: None, root_uri: request.root_uri, @@ -4525,7 +5071,7 @@ pub async fn start( ensure_enabled(&state)?; cleanup_expired_sessions(); let actor_id = ensure_authenticated(&state, &context)?; - check_rate_limit(&actor_id, "start", PI_LAB_MAX_STARTS_PER_WINDOW)?; + check_rate_limit(&actor_id, "start", pi_lab_start_rate_limit(&state))?; let requested_session = session_from_request(&state, &context, &request)?; let mut requested_session = requested_session; requested_session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?; @@ -4551,6 +5097,165 @@ pub async fn start( Ok(Json(pi_lab_start_response(&session, false))) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabConfigureRequest { + pub session_id: String, + pub model_provider: Option, + pub model_id: Option, + pub thinking_level: Option, + pub permission_mode: Option, +} + +/// POST /api/page-ai/pi/configure +/// 配置 Pi Lab 会话的参数(model、thinking level、permission mode)。 +/// 只在 mock 模式下本地生效;real 模式会转发对应 RPC 命令。 +pub async fn configure( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result, WebError> { + ensure_enabled(&state)?; + cleanup_expired_sessions(); + ensure_authenticated(&state, &context)?; + let session = get_session_for_context(&state, &context, &request.session_id)?; + let mut applied = json!({}); + let mut rpc_response_pending = false; + let mut rpc_failures: Vec = Vec::new(); + + if request.model_provider.is_some() || request.model_id.is_some() { + let mut requested_session = session.clone(); + requested_session.model_provider = request + .model_provider + .clone() + .or_else(|| session.model_provider.clone()); + requested_session.model_id = request + .model_id + .clone() + .or_else(|| session.model_id.clone()); + ensure_session_model_supports_tools(&requested_session).await?; + } + + if session.runtime_mode != "mock" && session_runtime_is_usable(&session) { + if request.model_provider.is_some() || request.model_id.is_some() { + let provider = request + .model_provider + .clone() + .or_else(|| session.model_provider.clone()) + .unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string()); + let model_id = request + .model_id + .clone() + .or_else(|| session.model_id.clone()) + .unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string()); + let response = send_rpc_command_wait( + &request.session_id, + json!({ + "id": generate_id("pi_rpc_set_model"), + "type": "set_model", + "provider": provider, + "modelId": model_id, + }), + Duration::from_secs(5), + ) + .await?; + if rpc_response_success(response.as_ref()) { + applied["model"] = json!({ + "provider": provider, + "modelId": model_id, + }); + } else { + rpc_response_pending = true; + rpc_failures.push(rpc_response_error_message( + response.as_ref(), + "set_model timeout or no response", + )); + } + } + if let Some(level) = &request.thinking_level { + let normalized = normalize_thinking_level(Some(level)).map_err(|msg| { + WebError::bad_request_code("page_ai_pi_lab_invalid_thinking_level", &msg) + })?; + let response = send_rpc_command_wait( + &request.session_id, + json!({ + "id": generate_id("pi_rpc_set_thinking"), + "type": "set_thinking_level", + "level": normalized, + }), + Duration::from_secs(5), + ) + .await?; + if rpc_response_success(response.as_ref()) { + applied["thinkingLevel"] = json!(normalized); + } else { + rpc_response_pending = true; + rpc_failures.push(rpc_response_error_message( + response.as_ref(), + "set_thinking_level timeout or no response", + )); + } + } + } + + if let Some(provider) = &request.model_provider { + update_session(&request.session_id, |session| { + session.model_provider = Some(provider.clone()); + }); + } + if let Some(model_id) = &request.model_id { + update_session(&request.session_id, |session| { + session.model_id = Some(model_id.clone()); + }); + } + if let Some(level) = &request.thinking_level { + let normalized = normalize_thinking_level(Some(level)).map_err(|msg| { + WebError::bad_request_code("page_ai_pi_lab_invalid_thinking_level", &msg) + })?; + update_session(&request.session_id, |session| { + session.thinking_level = Some(normalized); + }); + } + if let Some(mode) = &request.permission_mode { + let normalized = normalize_permission_mode(Some(mode)).map_err(|msg| { + WebError::bad_request_code("page_ai_pi_lab_invalid_permission_mode", &msg) + })?; + if let Some(normalized) = normalized { + let runtime_policy = refresh_runtime_policy_permission_mode(&session, &normalized); + applied["permissionMode"] = json!(normalized); + update_session(&request.session_id, |session| { + session.runtime_policy_snapshot = Some(runtime_policy); + }); + } + } + + persist_append_event( + &state, + &session, + "runtime_configured", + &json!({ + "modelProvider": request.model_provider, + "modelId": request.model_id, + "thinkingLevel": request.thinking_level, + "permissionMode": request.permission_mode, + }), + )?; + + let current = get_session(&request.session_id).unwrap_or(session.clone()); + Ok(Json(json!({ + "ok": true, + "schema": "mnote.page_ai_pi.configure.v1", + "sessionId": request.session_id, + "providerSessionId": current.provider_session_id, + "runtimeMode": current.runtime_mode, + "stateSource": if current.runtime_mode == "mock" { "mock_runtime_snapshot" } else { "pi_rpc_with_response_tracking" }, + "rpcResponsePending": rpc_response_pending, + "applied": applied, + "failures": rpc_failures, + "session": current, + }))) +} + pub async fn send( State(state): State, Extension(context): Extension, @@ -4757,6 +5462,470 @@ pub async fn abort( }))) } +/// POST /api/page-ai/pi/state +/// 封装官方 Pi RPC `get_state`。 +/// mock 返回完整假数据;real 发送 get_state RPC 并等待响应,超时降级。 +pub async fn state( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result, WebError> { + ensure_enabled(&state)?; + cleanup_expired_sessions(); + ensure_authenticated(&state, &context)?; + let session = get_session_for_context(&state, &context, &request.session_id)?; + if !session_runtime_is_usable(&session) { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_runtime_not_started", + "Pi runtime 未启动;请先调用 /api/page-ai/pi/start", + )); + } + + let is_running = matches!( + session.status, + PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning + ); + let is_streaming = session.status == PiLabSessionStatus::TurnRunning; + + if session.runtime_mode == "mock" { + return Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_STATE, + "sessionId": session.session_id, + "providerSessionId": session.provider_session_id, + "stateSource": "mock_runtime_snapshot", + "rpcResponsePending": false, + "status": session.status, + "running": true, + "isStreaming": false, + "isCompacting": false, + "modelProvider": session.model_provider.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string()), + "modelId": session.model_id.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string()), + "thinkingLevel": session.thinking_level.clone().unwrap_or_else(default_thinking_level), + "contextUsage": { + "used": 0, + "limit": 0, + "ratio": 0.0, + }, + "pendingMessageCount": 0, + "queuedMessages": [], + "autoCompactionEnabled": false, + "autoRetryEnabled": false, + }))); + } + + // Real runtime: send get_state RPC command and wait for response + let state_response = send_rpc_command_wait( + &request.session_id, + json!({ + "id": generate_id("pi_rpc_state"), + "type": "get_state", + }), + Duration::from_secs(5), + ) + .await?; + + if let Some(response) = state_response { + if response + .get("success") + .and_then(Value::as_bool) + .unwrap_or(false) + { + if let Some(data) = response.get("data") { + let model_provider = data + .get("model") + .and_then(|m| m.get("provider")) + .and_then(Value::as_str) + .map(String::from); + let model_id = data + .get("model") + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .map(String::from); + let thinking_level = data + .get("thinkingLevel") + .and_then(Value::as_str) + .map(String::from); + let effective_thinking_level = session + .thinking_level + .clone() + .or(thinking_level) + .unwrap_or_else(default_thinking_level); + let steering_mode = data + .get("steeringMode") + .and_then(Value::as_str) + .map(String::from); + let follow_up_mode = data + .get("followUpMode") + .and_then(Value::as_str) + .map(String::from); + let auto_compaction_enabled = data + .get("autoCompactionEnabled") + .and_then(Value::as_bool) + .unwrap_or(false); + let auto_retry_enabled = data + .get("autoRetryEnabled") + .and_then(Value::as_bool) + .unwrap_or(false); + let pending_message_count = data + .get("pendingMessageCount") + .and_then(Value::as_u64) + .unwrap_or(0); + let rpc_is_streaming = data + .get("isStreaming") + .and_then(Value::as_bool) + .unwrap_or(false); + let rpc_is_compacting = data + .get("isCompacting") + .and_then(Value::as_bool) + .unwrap_or(false); + + return Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_STATE, + "sessionId": session.session_id, + "providerSessionId": session.provider_session_id, + "stateSource": "pi_rpc_get_state_response", + "rpcResponsePending": false, + "status": session.status, + "running": is_running, + "isStreaming": rpc_is_streaming, + "isCompacting": rpc_is_compacting, + "modelProvider": model_provider.unwrap_or_else(|| session.model_provider.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string())), + "modelId": model_id.unwrap_or_else(|| session.model_id.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string())), + "thinkingLevel": effective_thinking_level, + "steeringMode": steering_mode, + "followUpMode": follow_up_mode, + "contextUsage": { + "used": 0, + "limit": 0, + "ratio": 0.0, + }, + "pendingMessageCount": pending_message_count, + "queuedMessages": [], + "autoCompactionEnabled": auto_compaction_enabled, + "autoRetryEnabled": auto_retry_enabled, + }))); + } + } + } + + // Fallback: degraded response when RPC times out or returns error + Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_STATE, + "sessionId": session.session_id, + "providerSessionId": session.provider_session_id, + "stateSource": "mnote_session_snapshot_with_rpc_request_pending", + "rpcResponsePending": true, + "degradedReason": "Pi RPC get_state response not received within timeout or returned error", + "status": session.status, + "running": is_running, + "isStreaming": is_streaming, + "isCompacting": false, + "modelProvider": session.model_provider.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string()), + "modelId": session.model_id.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string()), + "thinkingLevel": session.thinking_level.clone().unwrap_or_else(default_thinking_level), + "contextUsage": { + "used": 0, + "limit": 0, + "ratio": 0.0, + }, + "pendingMessageCount": if session.status == PiLabSessionStatus::TurnRunning { session.message_count as u64 } else { 0u64 }, + "queuedMessages": [], + "autoCompactionEnabled": false, + "autoRetryEnabled": false, + }))) +} + +/// POST /api/page-ai/pi/compact +/// 封装官方 Pi RPC `compact`。 +/// mock 返回稳定 compact summary;real 发送 compact RPC 并等待响应,超时降级。 +pub async fn compact( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result, WebError> { + ensure_enabled(&state)?; + cleanup_expired_sessions(); + ensure_authenticated(&state, &context)?; + let session = get_session_for_context(&state, &context, &request.session_id)?; + if !session_runtime_is_usable(&session) { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_runtime_not_started", + "Pi runtime 未启动;请先调用 /api/page-ai/pi/start", + )); + } + + let compact_response = if session.runtime_mode != "mock" { + let mut command = json!({ + "id": generate_id("pi_rpc_compact"), + "type": "compact", + }); + if let Some(instructions) = &request.custom_instructions { + command["customInstructions"] = json!(instructions); + } + if let Some(tokens) = request.reserve_tokens { + command["reserveTokens"] = json!(tokens); + } + if let Some(tokens) = request.keep_recent_tokens { + command["keepRecentTokens"] = json!(tokens); + } + send_rpc_command_wait(&request.session_id, command, Duration::from_secs(60)).await? + } else { + None + }; + + let mock_summary = format!( + "Mock compact: consolidated {} previous messages into a compacted summary", + session.message_count.max(1) + ); + let mock_first_kept = generate_id("compact_entry"); + + // Try to extract real response data + let response_data: Option = compact_response.as_ref().and_then(|resp| { + if resp + .get("success") + .and_then(Value::as_bool) + .unwrap_or(false) + { + resp.get("data").cloned() + } else { + None + } + }); + + let (summary, first_kept_entry_id, tokens_before, compact_details, rpc_pending, state_source) = + if session.runtime_mode == "mock" { + ( + mock_summary, + Some(mock_first_kept), + Some(session.message_count.max(1) * 100), + json!({ + "customInstructions": request.custom_instructions, + "reserveTokens": request.reserve_tokens, + "keepRecentTokens": request.keep_recent_tokens, + }), + false, + "mock_runtime_snapshot", + ) + } else if let Some(data) = response_data { + let s = data + .get("summary") + .and_then(Value::as_str) + .unwrap_or("Compaction completed") + .to_string(); + let f = data + .get("firstKeptEntryId") + .and_then(Value::as_str) + .map(String::from); + let t = data.get("tokensBefore").and_then(Value::as_u64); + let d = data.get("details").cloned().unwrap_or(json!(null)); + (s, f, t, d, false, "pi_rpc_compact_response") + } else { + ( + "Compaction requested via Pi RPC".to_string(), + None, + None, + json!({ + "customInstructions": request.custom_instructions, + "reserveTokens": request.reserve_tokens, + "keepRecentTokens": request.keep_recent_tokens, + }), + true, + "pi_rpc_request_pending", + ) + }; + + persist_append_event( + &state, + &session, + "runtime_compacted", + &json!({ + "summary": &summary, + "details": &compact_details, + }), + )?; + publish_event( + &request.session_id, + "runtime_compacted", + json!({ + "summary": &summary, + "details": &compact_details, + }), + ); + + Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_COMPACT, + "sessionId": session.session_id, + "providerSessionId": session.provider_session_id, + "stateSource": state_source, + "rpcResponsePending": rpc_pending, + "summary": summary, + "firstKeptEntryId": first_kept_entry_id, + "tokensBefore": tokens_before, + "details": compact_details, + }))) +} + +/// POST /api/page-ai/pi/queue-config +/// 封装官方 Pi RPC `set_steering_mode`/`set_follow_up_mode`/`set_auto_compaction`。 +/// 只发送请求中出现的字段,不发送未提供的字段。 +/// 每个 setter 等待对应 RPC response,失败则标记 failed。 +pub async fn queue_config( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result, WebError> { + ensure_enabled(&state)?; + cleanup_expired_sessions(); + ensure_authenticated(&state, &context)?; + let session = get_session_for_context(&state, &context, &request.session_id)?; + if !session_runtime_is_usable(&session) { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_runtime_not_started", + "Pi runtime 未启动;请先调用 /api/page-ai/pi/start", + )); + } + + let mut applied = json!({}); + let mut payload_fields = json!({}); + let mut has_failed = false; + + if let Some(mode) = &request.steering_mode { + let normalized = normalize_queue_mode(mode, "steeringMode")?; + if session.runtime_mode != "mock" { + let resp = send_rpc_command_wait( + &request.session_id, + json!({ + "id": generate_id("pi_rpc_steer_mode"), + "type": "set_steering_mode", + "mode": &normalized, + }), + Duration::from_secs(5), + ) + .await?; + let ok = resp + .as_ref() + .and_then(|r| r.get("success")) + .and_then(Value::as_bool) + .unwrap_or(false); + if ok { + applied["steeringMode"] = json!(&normalized); + } else { + applied["steeringMode"] = json!({ + "value": &normalized, + "failed": true, + "error": resp.as_ref() + .and_then(|r| r.get("error")) + .and_then(Value::as_str) + .unwrap_or("timeout or no response"), + }); + has_failed = true; + } + } else { + applied["steeringMode"] = json!(&normalized); + } + payload_fields["steeringMode"] = json!(&normalized); + } + + if let Some(mode) = &request.follow_up_mode { + let normalized = normalize_queue_mode(mode, "followUpMode")?; + if session.runtime_mode != "mock" { + let resp = send_rpc_command_wait( + &request.session_id, + json!({ + "id": generate_id("pi_rpc_followup_mode"), + "type": "set_follow_up_mode", + "mode": &normalized, + }), + Duration::from_secs(5), + ) + .await?; + let ok = resp + .as_ref() + .and_then(|r| r.get("success")) + .and_then(Value::as_bool) + .unwrap_or(false); + if ok { + applied["followUpMode"] = json!(&normalized); + } else { + applied["followUpMode"] = json!({ + "value": &normalized, + "failed": true, + "error": resp.as_ref() + .and_then(|r| r.get("error")) + .and_then(Value::as_str) + .unwrap_or("timeout or no response"), + }); + has_failed = true; + } + } else { + applied["followUpMode"] = json!(&normalized); + } + payload_fields["followUpMode"] = json!(&normalized); + } + + if let Some(enabled) = request.auto_compaction { + if session.runtime_mode != "mock" { + let resp = send_rpc_command_wait( + &request.session_id, + json!({ + "id": generate_id("pi_rpc_auto_compact"), + "type": "set_auto_compaction", + "enabled": enabled, + }), + Duration::from_secs(5), + ) + .await?; + let ok = resp + .as_ref() + .and_then(|r| r.get("success")) + .and_then(Value::as_bool) + .unwrap_or(false); + if ok { + applied["autoCompaction"] = json!(enabled); + } else { + applied["autoCompaction"] = json!({ + "value": enabled, + "failed": true, + "error": resp.as_ref() + .and_then(|r| r.get("error")) + .and_then(Value::as_str) + .unwrap_or("timeout or no response"), + }); + has_failed = true; + } + } else { + applied["autoCompaction"] = json!(enabled); + } + payload_fields["autoCompaction"] = json!(enabled); + } + + persist_append_event( + &state, + &session, + "runtime_queue_config_applied", + &payload_fields, + )?; + publish_event( + &request.session_id, + "runtime_queue_config_applied", + payload_fields, + ); + + Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_QUEUE_CONFIG, + "sessionId": session.session_id, + "providerSessionId": session.provider_session_id, + "stateSource": if session.runtime_mode == "mock" { "mock_runtime_snapshot" } else { "pi_rpc_with_response_tracking" }, + "rpcResponsePending": session.runtime_mode != "mock" && has_failed, + "applied": applied, + }))) +} + pub async fn ui_response( State(state): State, Extension(context): Extension, @@ -5262,10 +6431,17 @@ fn session_status_to_string(status: &PiLabSessionStatus) -> &'static str { } } +// ── JSONL reader limits ────────────────────────────────────────── +const PI_LAB_JSONL_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB +const PI_LAB_JSONL_MAX_LINE_BYTES: u64 = 100 * 1024; // 100 KiB +const PI_LAB_JSONL_MAX_ENTRIES: usize = 5000; +const PI_LAB_JSONL_WINDOW_ENTRIES: usize = 2000; + fn build_run_runtime_json(session: &PiLabSession) -> String { serde_json::to_string(&json!({ "providerSessionId": session.provider_session_id, "piSessionDir": session.pi_session_dir, + "piSessionFile": session.pi_session_file, "runtimeMode": session.runtime_mode, "modelProvider": session.model_provider, "modelId": session.model_id, @@ -5342,6 +6518,1042 @@ fn persist_append_event( Ok(()) } +// ── B2: 受控 Pi JSONL 读取 helper ────────────────────────────────── +/// 仅读取当前 session 自己的 pi_session_file。 +/// 单行上限 PI_LAB_JSONL_MAX_LINE_BYTES,总大小上限 PI_LAB_JSONL_MAX_FILE_BYTES,entry 上限 PI_LAB_JSONL_MAX_ENTRIES。 +/// 返回解析后的 entry 列表。文件不存在或超出限制返回明确错误。 +fn read_pi_session_jsonl(session: &PiLabSession) -> Result, WebError> { + let pi_session_file = session.pi_session_file.as_deref().ok_or_else(|| { + WebError::new( + StatusCode::NOT_FOUND, + "page_ai_pi_lab_no_pi_session_file", + "当前 Pi session 还没有 pi_session_file 记录;请先启动 runtime 后再查询", + ) + })?; + + let path = Path::new(pi_session_file); + + // 安全验证:路径必须在 session pi_session_dir 内 + let session_dir = Path::new(&session.pi_session_dir); + let canonical_path = canonical_or_parent(path); + let canonical_dir = canonical_or_parent(session_dir); + if !canonical_path.starts_with(&canonical_dir) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "page_ai_pi_lab_jsonl_path_escape", + "JSONL 文件必须在 session 目录内", + )); + } + + if !path.exists() { + return Ok(Vec::new()); + } + + let metadata = fs::metadata(path) + .map_err(|e| WebError::internal(format!("读取 Pi JSONL metadata 失败: {e}")))?; + if metadata.len() > PI_LAB_JSONL_MAX_FILE_BYTES as u64 { + return Err(WebError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "page_ai_pi_lab_jsonl_too_large", + format!( + "Pi JSONL 文件过大 ({} bytes, 上限 {} bytes)", + metadata.len(), + PI_LAB_JSONL_MAX_FILE_BYTES + ), + )); + } + + let raw = fs::read_to_string(path) + .map_err(|e| WebError::internal(format!("读取 Pi JSONL 失败: {e}")))?; + + let mut entries: Vec = Vec::new(); + for line in raw.lines() { + if line.trim().is_empty() { + continue; + } + if line.len() as u64 > PI_LAB_JSONL_MAX_LINE_BYTES { + return Err(WebError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "page_ai_pi_lab_jsonl_line_too_long", + format!( + "Pi JSONL 单行过长 ({} bytes, 上限 {} bytes)", + line.len(), + PI_LAB_JSONL_MAX_LINE_BYTES + ), + )); + } + match serde_json::from_str::(line) { + Ok(entry) => entries.push(entry), + Err(e) => { + // 跳过无法解析的行,但记录日志 + entries.push(json!({ + "parseError": format!("无法解析 JSONL 行: {e}"), + "rawPreview": truncate_text(line, 200), + })); + } + } + if entries.len() > PI_LAB_JSONL_MAX_ENTRIES { + return Err(WebError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "page_ai_pi_lab_jsonl_too_many_entries", + format!( + "Pi JSONL entry 过多 (>{}, 上限 {})", + PI_LAB_JSONL_MAX_ENTRIES, PI_LAB_JSONL_MAX_ENTRIES + ), + )); + } + } + + Ok(entries) +} + +fn pi_lab_entry_parent_id(entry: &Value) -> Option<&str> { + entry + .get("parent_id") + .or_else(|| entry.get("parentId")) + .or_else(|| entry.get("parentEntryId")) + .and_then(Value::as_str) +} + +fn pi_lab_entry_message(entry: &Value) -> Option<&Value> { + entry + .get("message") + .filter(|message| message.is_object()) +} + +fn pi_lab_entry_role(entry: &Value) -> String { + entry + .get("role") + .and_then(Value::as_str) + .or_else(|| { + pi_lab_entry_message(entry) + .and_then(|message| message.get("role")) + .and_then(Value::as_str) + }) + .unwrap_or("") + .to_string() +} + +fn pi_lab_content_block_text(block: &Value) -> Option { + block + .get("text") + .and_then(Value::as_str) + .or_else(|| block.get("content").and_then(Value::as_str)) + .or_else(|| block.get("message").and_then(Value::as_str)) + .map(str::to_string) +} + +fn pi_lab_content_blocks_text(content: &Value, include_tool_result: bool) -> String { + if let Some(text) = content.as_str() { + return text.to_string(); + } + let Some(items) = content.as_array() else { + return String::new(); + }; + items + .iter() + .filter_map(|item| { + let kind = item.get("type").and_then(Value::as_str).unwrap_or(""); + if kind == "text" + || kind == "input_text" + || (include_tool_result + && matches!(kind, "tool_result" | "output_text" | "text_delta")) + { + pi_lab_content_block_text(item) + } else { + None + } + }) + .collect::>() + .join("\n") +} + +fn pi_lab_entry_text(entry: &Value) -> String { + if let Some(text) = entry + .get("text") + .or_else(|| entry.get("content")) + .and_then(Value::as_str) + { + return text.to_string(); + } + let Some(message) = pi_lab_entry_message(entry) else { + return entry + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + }; + let role = message.get("role").and_then(Value::as_str).unwrap_or(""); + let include_tool_result = role == "toolResult" || role == "tool_result"; + message + .get("content") + .map(|content| pi_lab_content_blocks_text(content, include_tool_result)) + .unwrap_or_default() +} + +fn pi_lab_message_content_text(message: &Value, include_tool_result: bool) -> String { + message + .get("content") + .map(|content| pi_lab_content_blocks_text(content, include_tool_result)) + .unwrap_or_default() +} + +fn pi_lab_entry_tool_calls(entry: &Value) -> Vec { + let Some(message) = pi_lab_entry_message(entry) else { + return Vec::new(); + }; + let Some(items) = message.get("content").and_then(Value::as_array) else { + return Vec::new(); + }; + items + .iter() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("toolCall")) + .map(|item| { + json!({ + "id": item.get("id"), + "name": item.get("name"), + "arguments": item.get("arguments"), + }) + }) + .collect() +} + +fn pi_lab_message_tool_calls(message: &Value) -> Vec { + let Some(items) = message.get("content").and_then(Value::as_array) else { + return Vec::new(); + }; + items + .iter() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("toolCall")) + .map(|item| { + json!({ + "id": item.get("id"), + "toolCallId": item.get("toolCallId").or_else(|| item.get("tool_call_id")).or_else(|| item.get("id")), + "toolName": item.get("name").or_else(|| item.get("toolName")).or_else(|| item.get("tool_name")), + "name": item.get("name").or_else(|| item.get("toolName")).or_else(|| item.get("tool_name")), + "args": item.get("args").or_else(|| item.get("arguments")).cloned().unwrap_or_else(|| json!({})), + "status": "running", + }) + }) + .collect() +} + +fn pi_lab_tool_result_call_id(message: &Value) -> String { + message + .get("toolCallId") + .or_else(|| message.get("tool_call_id")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn pi_lab_tool_result_name(message: &Value) -> String { + message + .get("toolName") + .or_else(|| message.get("tool_name")) + .and_then(Value::as_str) + .unwrap_or("tool") + .to_string() +} + +fn pi_lab_entry_id(entry: &Value) -> String { + entry + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn pi_lab_entry_seq(entry: &Value) -> i64 { + entry + .get("seq") + .or_else(|| entry.get("entry_seq")) + .and_then(Value::as_i64) + .unwrap_or(0) +} + +fn pi_lab_entry_created_at(entry: &Value) -> Value { + entry + .get("created_at") + .or_else(|| entry.get("timestamp")) + .or_else(|| entry.get("createdAt")) + .cloned() + .unwrap_or(Value::Null) +} + +fn pi_lab_active_path_entries(entries: &[Value]) -> Vec<&Value> { + if entries.is_empty() { + return Vec::new(); + } + let mut by_id: std::collections::HashMap = std::collections::HashMap::new(); + for (index, entry) in entries.iter().enumerate() { + let id = pi_lab_entry_id(entry); + if !id.is_empty() { + by_id.insert(id, index); + } + } + let Some(leaf_id) = entries + .iter() + .max_by_key(|entry| pi_lab_entry_seq(entry)) + .map(pi_lab_entry_id) + .filter(|id| !id.is_empty()) + else { + return entries.iter().collect(); + }; + let mut path = Vec::new(); + let mut visited = std::collections::HashSet::new(); + let mut current = Some(leaf_id); + while let Some(id) = current { + if !visited.insert(id.clone()) { + break; + } + let Some(index) = by_id.get(&id).copied() else { + break; + }; + let entry = &entries[index]; + path.push(entry); + current = pi_lab_entry_parent_id(entry).map(str::to_string); + } + path.reverse(); + if path.is_empty() || (path.len() <= 1 && entries.len() > 1) { + entries.iter().collect() + } else { + path + } +} + +fn build_pi_replay_messages(entries: &[Value]) -> Vec { + let mut messages: Vec = Vec::new(); + for entry in pi_lab_active_path_entries(entries) { + let Some(message) = pi_lab_entry_message(entry) else { + continue; + }; + let role = message.get("role").and_then(Value::as_str).unwrap_or(""); + let entry_id = pi_lab_entry_id(entry); + let base = json!({ + "entryId": entry_id, + "parentId": pi_lab_entry_parent_id(entry), + "seq": pi_lab_entry_seq(entry), + "createdAt": pi_lab_entry_created_at(entry), + }); + match role { + "user" => { + let text = pi_lab_message_content_text(message, false); + if !text.trim().is_empty() { + messages.push(json!({ + "entryId": base["entryId"], + "parentId": base["parentId"], + "seq": base["seq"], + "createdAt": base["createdAt"], + "role": "user", + "text": text, + "meta": "", + })); + } + } + "assistant" => { + let text = pi_lab_message_content_text(message, false); + let tool_calls = pi_lab_message_tool_calls(message); + if !text.trim().is_empty() || !tool_calls.is_empty() { + messages.push(json!({ + "entryId": base["entryId"], + "parentId": base["parentId"], + "seq": base["seq"], + "createdAt": base["createdAt"], + "role": "assistant", + "text": text, + "meta": pi_lab_entry_meta(entry), + "toolCalls": tool_calls, + })); + } + } + "toolResult" | "tool_result" => { + let text = pi_lab_message_content_text(message, true); + let tool_name = pi_lab_tool_result_name(message); + let tool_call_id = pi_lab_tool_result_call_id(message); + let is_error = message + .get("isError") + .or_else(|| message.get("is_error")) + .and_then(Value::as_bool) + .unwrap_or(false); + let tool_call = json!({ + "id": tool_call_id, + "toolCallId": tool_call_id, + "toolName": tool_name, + "name": tool_name, + "status": if is_error { "error" } else { "done" }, + "result": { + "content": message.get("content").cloned().unwrap_or_else(|| json!([])), + "details": message.get("details").cloned().unwrap_or(Value::Null), + }, + "isError": is_error, + }); + messages.push(json!({ + "entryId": base["entryId"], + "parentId": base["parentId"], + "seq": base["seq"], + "createdAt": base["createdAt"], + "role": "assistant", + "text": "", + "meta": if text.trim().is_empty() { "" } else { "tool result" }, + "toolCalls": [tool_call], + })); + } + "custom" => { + let display = message + .get("display") + .and_then(Value::as_bool) + .unwrap_or(false); + let text = message + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(); + if display && !text.trim().is_empty() { + messages.push(json!({ + "entryId": base["entryId"], + "parentId": base["parentId"], + "seq": base["seq"], + "createdAt": base["createdAt"], + "role": "system", + "text": text, + "meta": message.get("customType").or_else(|| message.get("custom_type")).and_then(Value::as_str).unwrap_or("custom"), + })); + } + } + "bashExecution" | "bash_execution" => { + messages.push(json!({ + "entryId": base["entryId"], + "parentId": base["parentId"], + "seq": base["seq"], + "createdAt": base["createdAt"], + "role": "assistant", + "text": "", + "meta": "bash execution", + "toolCalls": [{ + "id": base["entryId"], + "toolCallId": base["entryId"], + "toolName": "bash", + "name": "bash", + "status": if message.get("exitCode").or_else(|| message.get("exit_code")).and_then(Value::as_i64).unwrap_or(0) == 0 { "done" } else { "error" }, + "args": {"command": message.get("command").cloned().unwrap_or(Value::Null)}, + "result": {"content": [{"type": "text", "text": message.get("output").and_then(Value::as_str).unwrap_or_default()}]}, + }], + })); + } + _ => {} + } + } + messages +} + +fn pi_lab_entry_meta(entry: &Value) -> String { + pi_lab_entry_message(entry) + .and_then(|message| message.get("stopReason")) + .and_then(Value::as_str) + .map(|reason| format!("stopReason={reason}")) + .unwrap_or_default() +} + +/// 从解析后的 JSONL entry 列表中提取 entry tree、active leaf、compaction/branch 摘要。 +fn build_pi_entry_tree(entries: &[Value]) -> Value { + let mut nodes = json!({}); + let mut edges: Vec = Vec::new(); + let mut compaction_summaries: Vec = Vec::new(); + let mut branch_summaries: Vec = Vec::new(); + let mut _active_leaf_id: Option = None; + let mut max_seq: i64 = -1; + + for entry in entries { + let entry_id = entry.get("id").and_then(Value::as_str).unwrap_or(""); + let entry_type = entry + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let parent_id = pi_lab_entry_parent_id(entry); + let role = pi_lab_entry_role(entry); + let seq = entry + .get("seq") + .or_else(|| entry.get("entry_seq")) + .and_then(Value::as_i64) + .unwrap_or(0); + + if seq > max_seq { + max_seq = seq; + _active_leaf_id = (!entry_id.is_empty()).then(|| entry_id.to_string()); + } + + let preview = truncate_text(&pi_lab_entry_text(entry), 200); + + nodes[entry_id] = json!({ + "id": entry_id, + "type": entry_type, + "role": role, + "preview": preview, + "seq": seq, + "parentId": parent_id, + }); + + if let Some(pid) = parent_id { + if !pid.is_empty() { + edges.push(json!({ + "from": pid, + "to": entry_id, + })); + } + } + + if entry_type == "compaction" { + let summary = entry + .get("summary") + .or_else(|| entry.get("details")) + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(); + compaction_summaries.push(json!({ + "entryId": entry_id, + "summary": summary, + "tokensBefore": entry.get("tokensBefore").or_else(|| entry.get("tokens_before")), + "firstKeptEntryId": entry.get("firstKeptEntryId").or_else(|| entry.get("first_kept_entry_id")), + })); + } + + if entry_type == "branch_summary" { + branch_summaries.push(json!({ + "entryId": entry_id, + "summary": entry.get("summary").and_then(Value::as_str).unwrap_or(""), + "branchPointEntryId": entry.get("branchPointEntryId").or_else(|| entry.get("branch_point_entry_id")), + })); + } + } + + json!({ + "nodes": nodes, + "edges": edges, + }) +} + +// ── B1: 扫描并记录 pi_session_file ────────────────────────────────── +/// 从 session dir 中扫描 Pi JSONL session 文件并设置 pi_session_file。 +/// Pi 的命名模式:YYYY-MM-DDTHH-MM-SS.sssZ_id.jsonl +fn resolve_pi_session_file(session: &PiLabSession) -> Option { + let session_dir = Path::new(&session.pi_session_dir); + if !session_dir.exists() { + return None; + } + + // Pi 创建 session 时可能在 session_dir 下创建子目录,或直接在 session_dir 中创建 .jsonl + // 先在 session_dir 中找 .jsonl 文件 + let mut candidates: Vec = Vec::new(); + + if let Ok(entries) = fs::read_dir(session_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + // 检查命名模式 + if let Some(name) = path.file_stem().and_then(|n| n.to_str()) { + // Pi pattern: YYYY-MM-DDTHH-MM-SS.sssZ_id 或包含 timestamp + if name.contains('T') || name.len() > 20 { + candidates.push(path); + } + } + } + } + } + + // 也检查 session_dir 的子目录(Pi 可能创建 session-index.sqlite 等,但 .jsonl 通常在 session_dir 下) + // 按修改时间排序,取最新的 + candidates.sort_by_key(|p| fs::metadata(p).ok().and_then(|m| m.modified().ok())); + candidates + .last() + .and_then(|p| p.to_str().map(str::to_string)) +} + +// ── B3: GET /api/page-ai/pi/sessions/{sessionId}/tree ────────────── +#[derive(Debug, Deserialize)] +pub struct PiLabSessionTreeQuery { + pub window_entries: Option, +} + +pub async fn session_tree( + State(state): State, + Extension(context): Extension, + axum::extract::Path(path): axum::extract::Path, + Query(query): Query, +) -> Result, WebError> { + ensure_enabled(&state)?; + cleanup_expired_sessions(); + let user_id = ensure_authenticated(&state, &context)?; + + // 先检查内存 session + let session = get_session(&path.session_id) + .or_else(|| { + // fallback: 从 control-plane 恢复 session 元数据 + state + .control_plane() + .find_ai_runtime_run(&user_id, &pi_run_id(&path.session_id)) + .ok() + .flatten() + .map(|run| { + let runtime: Value = + serde_json::from_str(&run.runtime_json).unwrap_or(json!({})); + PiLabSession { + session_id: run.session_id, + mnote_user_id: run.user_id, + bridge_token: String::new(), + status: PiLabSessionStatus::Idle, + provider_session_id: runtime + .get("providerSessionId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + pi_session_dir: runtime + .get("piSessionDir") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + pi_session_file: runtime + .get("piSessionFile") + .and_then(Value::as_str) + .map(str::to_string), + root_uri: runtime + .get("rootUri") + .and_then(Value::as_str) + .map(str::to_string), + workspace_id: run.workspace_id, + page_path: run.document_id, + page_title: run.title, + model_provider: runtime + .get("modelProvider") + .and_then(Value::as_str) + .map(str::to_string), + model_id: runtime + .get("modelId") + .and_then(Value::as_str) + .map(str::to_string), + thinking_level: runtime + .get("thinkingLevel") + .and_then(Value::as_str) + .map(str::to_string), + allowed_roots_snapshot: runtime.get("allowedRootsSnapshot").cloned(), + runtime_policy_snapshot: runtime.get("runtimePolicy").cloned(), + runtime_pid: None, + runtime_mode: runtime + .get("runtimeMode") + .and_then(Value::as_str) + .unwrap_or("mock") + .to_string(), + runtime_error: None, + created_at_ms: 0, + updated_at_ms: 0, + message_count: 0, + } + }) + }) + .ok_or_else(|| { + WebError::new( + StatusCode::NOT_FOUND, + "page_ai_pi_lab_session_not_found", + "Pi Lab session 不存在或不属于当前用户", + ) + })?; + + // Verify ownership + if session.mnote_user_id != user_id { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "page_ai_pi_lab_session_owner_mismatch", + "Pi Lab session 不属于当前登录主体", + )); + } + + // B2: Read JSONL + let entries = match read_pi_session_jsonl(&session) { + Ok(entries) => entries, + Err(e) => { + return Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_SESSION_TREE, + "sessionId": session.session_id, + "providerSessionId": session.provider_session_id, + "piSessionFile": session.pi_session_file, + "status": session.status, + "entries": [], + "entryTree": json!({}), + "activeLeafId": Value::Null, + "compactionSummaries": [], + "branchSummaries": [], + "totalEntries": 0, + "degradedReason": format!("Failed to read Pi JSONL: {}", e.message()), + }))); + } + }; + + let total_entries = entries.len(); + let replay_messages = build_pi_replay_messages(&entries); + let window_size = query + .window_entries + .unwrap_or(PI_LAB_JSONL_WINDOW_ENTRIES) + .min(total_entries); + + // Window entries (last N for preview, for memory safety) + let window_start = if window_size >= total_entries { + 0 + } else { + total_entries - window_size + }; + let window_entries: Vec = entries[window_start..] + .iter() + .map(|entry| { + json!({ + "id": entry.get("id"), + "parentId": pi_lab_entry_parent_id(entry).map(|parent| Value::String(parent.to_string())), + "type": entry.get("type").unwrap_or(&json!("unknown")), + "role": pi_lab_entry_role(entry), + "text": pi_lab_entry_text(entry), + "preview": truncate_text(&pi_lab_entry_text(entry), 200), + "meta": pi_lab_entry_meta(entry), + "toolCalls": pi_lab_entry_tool_calls(entry), + "seq": entry.get("seq").or_else(|| entry.get("entry_seq")), + "createdAt": entry.get("created_at").or_else(|| entry.get("timestamp")).or_else(|| entry.get("createdAt")), + }) + }) + .collect(); + + // Build tree from full entries + let entry_tree = build_pi_entry_tree(&entries); + + // Extract summaries + let compaction_summaries: Vec = entries.iter() + .filter(|e| e.get("type").and_then(Value::as_str) == Some("compaction")) + .map(|e| json!({ + "entryId": e.get("id"), + "summary": e.get("summary").or_else(|| e.get("details")).and_then(Value::as_str).unwrap_or(""), + "tokensBefore": e.get("tokensBefore").or_else(|| e.get("tokens_before")), + "firstKeptEntryId": e.get("firstKeptEntryId").or_else(|| e.get("first_kept_entry_id")), + })) + .collect(); + + let branch_summaries: Vec = entries.iter() + .filter(|e| e.get("type").and_then(Value::as_str) == Some("branch_summary")) + .map(|e| json!({ + "entryId": e.get("id"), + "summary": e.get("summary").and_then(Value::as_str).unwrap_or(""), + "branchPointEntryId": e.get("branchPointEntryId").or_else(|| e.get("branch_point_entry_id")), + })) + .collect(); + + // Determine active leaf (last entry with max seq or last entry in window) + let active_leaf_id = entries + .iter() + .max_by_key(|e| { + e.get("seq") + .or_else(|| e.get("entry_seq")) + .and_then(Value::as_i64) + .unwrap_or(0) + }) + .and_then(|e| e.get("id").and_then(Value::as_str).map(str::to_string)); + + Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_SESSION_TREE, + "sessionId": session.session_id, + "providerSessionId": session.provider_session_id, + "piSessionFile": session.pi_session_file, + "status": session.status, + "entries": window_entries, + "messages": replay_messages, + "entryTree": entry_tree, + "activeLeafId": active_leaf_id, + "compactionSummaries": compaction_summaries, + "branchSummaries": branch_summaries, + "totalEntries": total_entries, + "windowStart": window_start, + "windowSize": window_size, + "degradedReason": Value::Null, + }))) +} + +// ── B5: POST /api/page-ai/pi/fork ────────────────────────────────── +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabForkRequest { + pub session_id: String, + pub entry_id: Option, +} + +pub async fn fork_pi_session( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result, WebError> { + ensure_enabled(&state)?; + cleanup_expired_sessions(); + ensure_authenticated(&state, &context)?; + let source_session = get_session_for_context(&state, &context, &request.session_id)?; + + let rpc_response_pending: bool; + let degraded_reason: Option; + let new_session: PiLabSession; + + if source_session.runtime_mode == "mock" { + // Mock fork: generate a stable mock fork session + let entry_id = request + .entry_id + .clone() + .unwrap_or_else(|| "mock_root_entry".to_string()); + new_session = PiLabSession { + session_id: generate_id("pi_fork"), + mnote_user_id: source_session.mnote_user_id.clone(), + bridge_token: generate_bridge_token(), + status: PiLabSessionStatus::RuntimeRunning, + provider_session_id: generate_id("pi_provider_fork"), + pi_session_dir: source_session.pi_session_dir.clone(), + pi_session_file: source_session.pi_session_file.clone(), + root_uri: source_session.root_uri.clone(), + workspace_id: source_session.workspace_id.clone(), + page_path: source_session.page_path.clone(), + page_title: Some(format!( + "{} (fork)", + source_session.page_title.as_deref().unwrap_or("Pi Session") + )), + model_provider: source_session.model_provider.clone(), + model_id: source_session.model_id.clone(), + thinking_level: source_session.thinking_level.clone(), + allowed_roots_snapshot: source_session.allowed_roots_snapshot.clone(), + runtime_policy_snapshot: source_session.runtime_policy_snapshot.clone(), + runtime_pid: None, + runtime_mode: "mock".to_string(), + runtime_error: None, + created_at_ms: now_ms(), + updated_at_ms: now_ms(), + message_count: 0, + }; + upsert_session(new_session.clone()); + let _ = persist_upsert_run(&state, &new_session); + rpc_response_pending = false; + degraded_reason = None; + + publish_event( + &new_session.session_id, + "runtime_started", + json!({ + "mode": "mock_fork", + "sourceSessionId": source_session.session_id, + "forkEntryId": entry_id, + "providerSessionId": new_session.provider_session_id, + "sessionId": new_session.session_id, + }), + ); + } else if session_runtime_is_usable(&source_session) { + // Real runtime: send fork RPC command + let fork_response = send_rpc_command_wait( + &request.session_id, + json!({ + "id": generate_id("pi_rpc_fork"), + "type": "fork", + "entryId": request.entry_id, + }), + Duration::from_secs(10), + ) + .await?; + + if let Some(ref response) = fork_response { + if response + .get("success") + .and_then(Value::as_bool) + .unwrap_or(false) + { + if let Some(data) = response.get("data") { + let forked_session_id = + data.get("sessionId").and_then(Value::as_str).unwrap_or(""); + new_session = PiLabSession { + session_id: generate_id("pi_fork"), + mnote_user_id: source_session.mnote_user_id.clone(), + bridge_token: generate_bridge_token(), + status: PiLabSessionStatus::RuntimeRunning, + provider_session_id: data + .get("providerSessionId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + pi_session_dir: data + .get("sessionDir") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| source_session.pi_session_dir.clone()), + pi_session_file: None, + root_uri: source_session.root_uri.clone(), + workspace_id: source_session.workspace_id.clone(), + page_path: source_session.page_path.clone(), + page_title: Some(format!( + "{} (fork)", + source_session.page_title.as_deref().unwrap_or("Pi Session") + )), + model_provider: source_session.model_provider.clone(), + model_id: source_session.model_id.clone(), + thinking_level: source_session.thinking_level.clone(), + allowed_roots_snapshot: source_session.allowed_roots_snapshot.clone(), + runtime_policy_snapshot: source_session.runtime_policy_snapshot.clone(), + runtime_pid: None, + runtime_mode: source_session.runtime_mode.clone(), + runtime_error: None, + created_at_ms: now_ms(), + updated_at_ms: now_ms(), + message_count: 0, + }; + upsert_session(new_session.clone()); + let _ = persist_upsert_run(&state, &new_session); + rpc_response_pending = false; + degraded_reason = None; + + publish_event( + &new_session.session_id, + "runtime_started", + json!({ + "mode": "fork", + "sourceSessionId": source_session.session_id, + "forkedSessionId": forked_session_id, + "providerSessionId": new_session.provider_session_id, + "sessionId": new_session.session_id, + }), + ); + } else { + return Err(WebError::bad_gateway_code( + "page_ai_pi_lab_fork_response_no_data", + "Pi fork RPC 返回成功但没有 data", + )); + } + } else { + rpc_response_pending = true; + degraded_reason = Some(rpc_response_error_message( + fork_response.as_ref(), + "Pi fork RPC 返回错误", + )); + // Return degraded response with source session info + return Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_FORK, + "sourceSessionId": source_session.session_id, + "sessionId": Value::Null, + "session": Value::Null, + "rpcResponsePending": rpc_response_pending, + "degradedReason": degraded_reason, + }))); + } + } else { + rpc_response_pending = true; + degraded_reason = Some("Pi fork RPC 超时或未收到响应".to_string()); + return Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_FORK, + "sourceSessionId": source_session.session_id, + "sessionId": Value::Null, + "session": Value::Null, + "rpcResponsePending": rpc_response_pending, + "degradedReason": degraded_reason, + }))); + } + } else { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_runtime_not_started", + "源 session Pi runtime 未启动;请先调用 /api/page-ai/pi/start", + )); + } + + Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_FORK, + "sourceSessionId": source_session.session_id, + "sessionId": new_session.session_id, + "session": new_session, + "rpcResponsePending": rpc_response_pending, + "degradedReason": degraded_reason, + }))) +} + +// ── D2: GET /api/page-ai/pi/artifacts/{toolEventId}/diff ────────── +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabArtifactDiffQuery { + pub session_id: Option, +} + +pub async fn artifact_diff( + State(state): State, + Extension(context): Extension, + axum::extract::Path(tool_event_id): axum::extract::Path, + Query(query): Query, +) -> Result, WebError> { + ensure_enabled(&state)?; + cleanup_expired_sessions(); + let user_id = ensure_authenticated(&state, &context)?; + let session_id = query.session_id.as_deref().ok_or_else(|| { + WebError::bad_request_code( + "page_ai_pi_lab_session_id_required", + "artifact diff 需要 sessionId 查询参数", + ) + })?; + + // Verify session ownership + let run = state + .control_plane() + .find_ai_runtime_run(user_id.trim(), &pi_run_id(session_id)) + .map_err(|e| WebError::internal(format!("查询 session 详情失败: {e}")))? + .ok_or_else(|| { + WebError::new( + StatusCode::NOT_FOUND, + "page_ai_pi_lab_session_not_found", + "Pi Lab session 不存在或不属于当前用户", + ) + })?; + + if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME { + return Err(WebError::new( + StatusCode::NOT_FOUND, + "page_ai_pi_lab_session_not_found", + "Pi Lab session 不存在或不属于当前用户", + )); + } + + // Query control-plane ai_file_patches by session_id, filter in-memory by tool_event_id + let patches = state + .control_plane() + .list_ai_file_patches(user_id.trim(), Some(session_id), 100) + .map_err(|e| WebError::internal(format!("查询 file patches 失败: {e}")))?; + + let matching = patches + .into_iter() + .find(|p| p.tool_event_id == tool_event_id); + + match matching { + Some(patch) => { + let patch_summary: Value = + serde_json::from_str(&patch.patch_summary_json).unwrap_or(json!({})); + Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_ARTIFACT_DIFF, + "toolEventId": tool_event_id, + "sessionId": session_id, + "diffSummary": patch_summary.get("diffSummary").and_then(Value::as_str).unwrap_or(""), + "patchSummary": patch_summary, + "rootUri": patch.root_uri, + "relativePath": patch.relative_path, + "beforeFileVersion": patch.before_file_version, + "afterFileVersion": patch.after_file_version, + "raw": Value::Null, + }))) + } + None => { + // 404: no matching patch found + Err(WebError::new( + StatusCode::NOT_FOUND, + "page_ai_pi_lab_artifact_not_found", + format!("toolEventId {tool_event_id} 没有对应的 file patch 记录"), + )) + } + } +} + // --------------------------------------------------------------------------- // Session history handlers // --------------------------------------------------------------------------- @@ -6018,24 +8230,112 @@ mod tests { } #[test] - fn skill_prompt_keeps_slash_command_first_with_dynamic_context() { + fn command_prompt_routes_hidden_context_through_extension_input_hook() { let root = temp_root("mnote-pi-skill-prompt-context"); let full_access_session = permission_mode_test_session(&root, "full_access"); let context = "[[MNOTE_PI_CONTEXT_V1:7b7d]]\n"; + let normal_prompt = + pi_lab_command_message_for_session(&full_access_session, "请只回复 ok", context); + assert_eq!(normal_prompt, format!("{context}请只回复 ok")); + assert!(!normal_prompt.contains("bridgeToken")); + let prompt = pi_lab_command_message_for_session( &full_access_session, "/skill:vpn 请返回 skill 中定义的端口", context, ); - assert!(prompt.starts_with("/skill:vpn\n[[MNOTE_PI_CONTEXT_V1:")); + assert!(prompt.starts_with("/skill:vpn\n")); + assert!(prompt.contains(context)); + assert!(!prompt.contains("bridgeToken")); assert!(prompt.ends_with("请返回 skill 中定义的端口")); let plan_session = permission_mode_test_session(&root, "plan"); let plan_prompt = pi_lab_command_message_for_session(&plan_session, "/skill:vpn 检查代理配置", context); - assert!(plan_prompt.starts_with("/skill:vpn\n[[MNOTE_PI_CONTEXT_V1:")); + assert!(plan_prompt.starts_with("/skill:vpn\n")); + assert!(plan_prompt.contains(context)); + assert!(!plan_prompt.contains("bridgeToken")); assert!(plan_prompt.contains("当前是 MNote Pi 计划模式")); - assert!(plan_prompt.contains("用户原始请求:\n检查代理配置")); + assert!(plan_prompt.contains("用户原始请求")); + assert!(plan_prompt.ends_with("检查代理配置")); + } + + #[test] + fn pi_rust_jsonl_message_entries_preserve_tail_text_and_tools() { + let entries = vec![ + json!({ + "id": "u1", + "type": "message", + "seq": 1, + "message": { + "role": "user", + "content": "请查看当前页" + } + }), + json!({ + "id": "a1", + "type": "message", + "seq": 2, + "parentId": "u1", + "message": { + "role": "assistant", + "content": [{ + "type": "toolCall", + "id": "tool_1", + "name": "mnote_current_page_read", + "arguments": {"path": "note.md"} + }] + } + }), + json!({ + "id": "t1", + "type": "message", + "seq": 3, + "parentId": "a1", + "message": { + "role": "toolResult", + "toolCallId": "tool_1", + "content": [{"type": "text", "text": "当前页内容"}] + } + }), + json!({ + "id": "a2", + "type": "message", + "seq": 4, + "parentId": "t1", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "FINAL_TAIL_REPLY"}], + "stopReason": "stop" + } + }), + ]; + + assert_eq!(pi_lab_entry_text(&entries[0]), "请查看当前页"); + assert_eq!(pi_lab_entry_parent_id(&entries[1]), Some("u1")); + assert_eq!(pi_lab_entry_role(&entries[1]), "assistant"); + let tool_calls = pi_lab_entry_tool_calls(&entries[1]); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0]["name"], "mnote_current_page_read"); + assert_eq!(pi_lab_entry_text(&entries[2]), "当前页内容"); + assert_eq!(pi_lab_entry_text(&entries[3]), "FINAL_TAIL_REPLY"); + assert_eq!(pi_lab_entry_meta(&entries[3]), "stopReason=stop"); + + let tree = build_pi_entry_tree(&entries); + assert_eq!(tree["nodes"]["a2"]["preview"], "FINAL_TAIL_REPLY"); + assert_eq!(tree["edges"][2]["from"], "t1"); + assert_eq!(tree["edges"][2]["to"], "a2"); + + let replay = build_pi_replay_messages(&entries); + assert_eq!(replay.len(), 4); + assert_eq!(replay[0]["role"], "user"); + assert_eq!(replay[0]["text"], "请查看当前页"); + assert_eq!(replay[1]["role"], "assistant"); + assert_eq!(replay[1]["toolCalls"][0]["toolName"], "mnote_current_page_read"); + assert_eq!(replay[2]["role"], "assistant"); + assert_eq!(replay[2]["toolCalls"][0]["status"], "done"); + assert_eq!(replay[2]["toolCalls"][0]["result"]["content"][0]["text"], "当前页内容"); + assert_eq!(replay[3]["text"], "FINAL_TAIL_REPLY"); } #[test] @@ -6065,6 +8365,205 @@ mod tests { ); } + #[test] + fn agent_end_does_not_revive_terminal_session_status() { + assert_eq!( + status_after_agent_end(&PiLabSessionStatus::Aborted), + PiLabSessionStatus::Aborted + ); + assert_eq!( + status_after_agent_end(&PiLabSessionStatus::Error), + PiLabSessionStatus::Error + ); + assert_eq!( + status_after_agent_end(&PiLabSessionStatus::TurnRunning), + PiLabSessionStatus::RuntimeRunning + ); + } + + #[test] + fn mnote_context_snapshot_keeps_bridge_token_out_of_prompt_payload() { + let root = temp_root("mnote-pi-context-bridge-token"); + let session = permission_mode_test_session(&root, "full_access"); + let prompt_payload = pi_mnote_context_payload(&session, None, None); + assert!(prompt_payload.get("bridgeToken").is_none()); + assert_eq!( + prompt_payload["modelProvider"], + session + .model_provider + .clone() + .map(Value::String) + .unwrap_or(Value::Null) + ); + assert_eq!( + prompt_payload["modelId"], + session + .model_id + .clone() + .map(Value::String) + .unwrap_or(Value::Null) + ); + + let context_path = + write_pi_mnote_context_snapshot(&session, None, None).expect("write context snapshot"); + let persisted: Value = + serde_json::from_slice(&fs::read(&context_path).expect("read context snapshot")) + .expect("parse context snapshot"); + assert_eq!( + persisted["bridgeToken"], + Value::String(session.bridge_token.clone()) + ); + assert_eq!( + persisted["sessionId"], + Value::String(session.session_id.clone()) + ); + assert_eq!( + persisted["bridgeBaseUrl"], + Value::String(pi_lab_public_base_url()) + ); + #[cfg(unix)] + { + let mode = fs::metadata(context_path) + .expect("context metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + + #[test] + fn permission_modes_expose_pi_builtins_by_mode() { + let root = temp_root("mnote-pi-full-access-builtins"); + let mut session = permission_mode_test_session(&root, "full_access"); + session.runtime_policy_snapshot = Some(json!({ + "permissionMode": "full_access", + "allowExternalPiExtensions": false, + "enabledPiExtensions": ["pi-rust-official-permission-gate"], + "enabledPiExtensionSources": ["pi-rust-official:permission-gate"], + })); + assert!(!pi_lab_permission_system_enabled(&session)); + assert_eq!( + pi_lab_enabled_builtin_tools(&session), + pi_lab_managed_builtin_tools() + ); + + session.runtime_policy_snapshot = Some(json!({ + "permissionMode": "auto_edit", + "allowExternalPiExtensions": false, + "enabledPiExtensions": ["pi-rust-official-permission-gate"], + "enabledPiExtensionSources": ["pi-rust-official:permission-gate"], + })); + assert_eq!( + pi_lab_enabled_builtin_tools(&session), + vec![ + "read".to_string(), + "write".to_string(), + "edit".to_string(), + "grep".to_string(), + "find".to_string(), + "ls".to_string(), + "hashline_edit".to_string() + ] + ); + + session.runtime_policy_snapshot = Some(json!({ + "permissionMode": "plan", + "allowExternalPiExtensions": false, + "enabledPiExtensions": ["pi-rust-official-permission-gate"], + "enabledPiExtensionSources": ["pi-rust-official:permission-gate"], + })); + assert_eq!( + pi_lab_enabled_builtin_tools(&session), + vec![ + "read".to_string(), + "grep".to_string(), + "find".to_string(), + "ls".to_string() + ] + ); + } + + #[test] + fn omniroute_models_config_preserves_tools_and_stream_usage() { + let root = temp_root("mnote-pi-omniroute-models-config"); + let session = PiLabSession { + session_id: "pi_lab_models_config".into(), + mnote_user_id: "user_test".into(), + bridge_token: "bridge".into(), + status: PiLabSessionStatus::Idle, + provider_session_id: "prov_models_config".into(), + pi_session_dir: root.join("session").to_string_lossy().to_string(), + pi_session_file: None, + root_uri: Some(file_uri(&root)), + workspace_id: Some("ws_test".into()), + page_path: Some("doc.md".into()), + page_title: Some("Test Page".into()), + model_provider: Some("omniroute".into()), + model_id: Some("freefirst".into()), + thinking_level: Some("off".into()), + allowed_roots_snapshot: None, + runtime_policy_snapshot: None, + runtime_pid: None, + runtime_mode: "rpc".into(), + runtime_error: None, + created_at_ms: 1000, + updated_at_ms: 2000, + message_count: 0, + }; + + let config_dir = ensure_session_models_config(&session, true).expect("models config"); + let models: Value = + serde_json::from_slice(&fs::read(config_dir.join("models.json")).expect("models file")) + .expect("models json"); + let compat = &models["providers"]["omniroute"]["compat"]; + assert_eq!(compat["supportsDeveloperRole"], false); + assert_eq!(compat["supportsReasoningEffort"], false); + assert_eq!(compat["supportsTools"], true); + assert_eq!(compat["supportsUsageInStreaming"], true); + assert_eq!( + models["providers"]["omniroute"]["apiKey"], "OPENAI_API_KEY", + "Pi Rust resolves bare *_API_KEY env var names; shell-style $OPENAI_API_KEY is sent literally" + ); + assert_eq!( + models["providers"]["omniroute"]["baseUrl"], + omniroute_base_url() + ); + } + + #[test] + fn omniroute_model_capability_requires_explicit_tool_calling() { + let catalog = json!({ + "data": [ + { + "id": "gpt-5.4-mini", + "capabilities": { + "tool_calling": true, + "reasoning": true + } + }, + { + "id": "freefirst", + "capabilities": { + "reasoning": true + } + } + ] + }); + assert_eq!( + omniroute_model_tool_calling_capability(&catalog, "gpt-5.4-mini"), + Some(true) + ); + assert_eq!( + omniroute_model_tool_calling_capability(&catalog, "freefirst"), + Some(false) + ); + assert_eq!( + omniroute_model_tool_calling_capability(&catalog, "missing"), + None + ); + } + #[test] fn build_upsert_run_input_creates_correct_input() { let session = PiLabSession { @@ -6356,7 +8855,10 @@ mod tests { .await; assert_eq!(status, StatusCode::OK); assert_eq!(payload["mnoteToolOnly"], false); - assert_eq!(payload["managedPiBuiltinTools"], json!([])); + assert_eq!( + payload["managedPiBuiltinTools"], + json!(pi_lab_managed_builtin_tools()) + ); assert!(payload["configuredPiExtensionSources"] .as_array() .unwrap() @@ -6389,7 +8891,10 @@ mod tests { ); let policy = &payload["session"]["runtimePolicySnapshot"]; assert_eq!(policy["defaultModel"], "omniroute/pi-fast"); - assert_eq!(policy["allowedModels"], json!(["omniroute/pi-fast"])); + assert_eq!( + policy["allowedModels"], + json!(["omniroute/freefirst", "omniroute/gpt-5.4-mini", "omniroute/pi-fast"]) + ); assert!(policy["enabledSkills"] .as_array() .unwrap() @@ -6806,6 +9311,69 @@ mod tests { ); } + #[tokio::test] + async fn configure_permission_mode_refreshes_mnote_tool_policy() { + std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock"); + let state = test_state(); + let actor_id = "pi_configure_permission_user"; + let root = temp_root("mnote-pi-configure-permission-root"); + let root_uri = grant_directory(&state, actor_id, &root, "write"); + fs::write(root.join("note.md"), "before").expect("write file"); + let app = build_app(state); + + let (start_status, _) = request_json( + app.clone(), + "/api/page-ai/pi/start", + actor_id, + json!({ + "sessionId": "pi_lab_configure_permission", + "rootUri": root_uri, + "permissionMode": "confirm" + }), + ) + .await; + assert_eq!(start_status, StatusCode::OK); + + let (configure_status, configure_payload) = request_json( + app.clone(), + "/api/page-ai/pi/configure", + actor_id, + json!({ + "sessionId": "pi_lab_configure_permission", + "permissionMode": "auto_edit" + }), + ) + .await; + assert_eq!(configure_status, StatusCode::OK); + assert_eq!(configure_payload["applied"]["permissionMode"], "auto_edit"); + assert_eq!( + configure_payload["session"]["runtimePolicySnapshot"]["permissionMode"], + "auto_edit" + ); + + let (tool_status, tool_payload) = request_json( + app, + "/api/page-ai/pi/tool-call", + actor_id, + json!({ + "sessionId": "pi_lab_configure_permission", + "toolName": "mnote.local_file.patch", + "params": { + "rootUri": root_uri, + "path": "note.md", + "operations": [{"op": "replace", "old": "before", "new": "after"}] + } + }), + ) + .await; + assert_eq!(tool_status, StatusCode::OK); + assert_eq!(tool_payload["ok"], true); + assert_eq!( + fs::read_to_string(root.join("note.md")).expect("read file"), + "after" + ); + } + #[tokio::test] async fn get_session_returns_not_found_for_nonexistent_session() { let app = test_app(); @@ -6853,4 +9421,104 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["code"], "page_ai_pi_lab_session_not_found"); } + + // ── A7: Pi RPC request-response correlation ────────────────────── + + #[test] + fn rpc_response_key_joins_session_id_and_rpc_id() { + let key = rpc_response_key("session_abc", "pi_rpc_state_123"); + assert_eq!(key, "session_abc:pi_rpc_state_123"); + } + + #[test] + fn rpc_response_key_handles_empty_ids() { + let key = rpc_response_key("", ""); + assert_eq!(key, ":"); + } + + #[test] + fn queue_mode_normalization_matches_pi_rpc_contract() { + assert_eq!(normalize_queue_mode("all", "steeringMode").unwrap(), "all"); + assert_eq!( + normalize_queue_mode("one-at-a-time", "followUpMode").unwrap(), + "one-at-a-time" + ); + assert_eq!( + normalize_queue_mode("oneAtATime", "steeringMode").unwrap(), + "one-at-a-time" + ); + assert_eq!( + normalize_queue_mode("one_at_a_time", "followUpMode").unwrap(), + "one-at-a-time" + ); + assert!(normalize_queue_mode("skip", "steeringMode").is_err()); + assert!(normalize_queue_mode("queue", "followUpMode").is_err()); + assert!(normalize_queue_mode("parallel", "steeringMode").is_err()); + assert!(normalize_queue_mode("single", "followUpMode").is_err()); + } + + #[tokio::test] + async fn pending_rpc_registry_insert_remove_and_resolve() { + let (tx, rx) = oneshot::channel(); + let key = "test_sess:test_id".to_string(); + + // Insert + { + let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; + pending.insert(key.clone(), tx); + assert!(pending.contains_key(&key)); + } + + // Resolve via registry lookup + let response = json!({"type": "response", "success": true, "data": {"key": "val"}}); + { + let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; + if let Some(sender) = pending.remove(&key) { + let _ = sender.send(response.clone()); + } + assert!(!pending.contains_key(&key)); + } + + // Verify receiver got the message + let received = rx.await.expect("should receive via oneshot"); + assert_eq!(received["type"], "response"); + assert_eq!(received["success"], true); + assert_eq!(received["data"]["key"], "val"); + } + + #[tokio::test] + async fn pending_rpc_registry_cleanup_on_remove() { + let (tx, _rx) = oneshot::channel(); + let key = "cleanup_test:id".to_string(); + + // Insert + { + let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; + pending.insert(key.clone(), tx); + assert!(pending.contains_key(&key)); + } + + // Remove without sending + { + let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; + pending.remove(&key); + assert!(!pending.contains_key(&key)); + } + } + + #[tokio::test] + async fn mock_state_handler_unchanged() { + let app = test_app(); + let (status, body) = request_json( + app, + "/api/page-ai/pi/state", + "user_a7_mock_state", + json!({ + "sessionId": "nonexistent_mock_state", + }), + ) + .await; + assert_eq!(status, 400); + assert_eq!(body["code"], "page_ai_pi_lab_session_not_found"); + } } diff --git a/scripts/task-pi-lab-full-access-builtin-delete-smoke.js b/scripts/task-pi-lab-full-access-builtin-delete-smoke.js index ad5bda5b..d5a652a0 100644 --- a/scripts/task-pi-lab-full-access-builtin-delete-smoke.js +++ b/scripts/task-pi-lab-full-access-builtin-delete-smoke.js @@ -6,19 +6,25 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { chromium } = require("playwright"); +const { + setupWorkspaceAccess, + seedAiPolicy, +} = require("./lib/control-plane-dev-seed"); const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const STAMP = Date.now(); -const OUT = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_OUT || path.join(os.tmpdir(), `mnote-pi-full-access-builtin-disabled-${STAMP}`); +const OUT = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_OUT || path.join(os.tmpdir(), `mnote-pi-full-access-builtin-tools-${STAMP}`); const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "360000", 10); const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e"; -const WORKSPACE_ID = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_WORKSPACE_ID || "local-ws:mnote-e2e:my-space"; -const ROOT_PATH = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; +const WORKSPACE_ID = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_WORKSPACE_ID || `local-ws:${ACTOR_ID}:pi-full-access-builtins-${STAMP}`; +const ROOT_PATH = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_ROOT_PATH || path.join(OUT, "workspace"); const ROOT_URI = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_ROOT_URI || `file://${ROOT_PATH}`; const MODEL_PROVIDER = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_MODEL_PROVIDER || "omniroute"; -const MODEL_ID = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_MODEL_ID || "freefirst"; -const MARKER = `PI_FULL_ACCESS_BUILTIN_DISABLED_OK_${STAMP}`; -const PAGE_PATH = `pi-full-access-builtin-disabled-${STAMP}.md`; +const MODEL_ID = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_MODEL_ID || "gpt-5.4-mini"; +const MARKER = `PI_FULL_ACCESS_CONTROLLED_TOOLS_OK_${STAMP}`; +const PAGE_PATH = `pi-full-access-builtin-tools-${STAMP}.md`; +const SCRATCH_PATH = `pi-full-access-builtin-tools-${STAMP}.txt`; +const BUILTIN_TOOLS = ["read", "write", "edit", "bash", "grep", "find", "ls", "hashline_edit"]; const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "") || (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "") @@ -63,7 +69,7 @@ function piExtensionConfig(name, description, source, toolNames, riskLevel, requ return { name, description, source, toolNames, riskLevel, requiredScopes, enabled: true }; } -function policyForFullAccessBuiltinDisabled() { +function policyForFullAccessBuiltinTools() { return { defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`, allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`], @@ -84,39 +90,34 @@ function policyForFullAccessBuiltinDisabled() { async function seedWorkspace(page) { mkdirp(ROOT_PATH); - fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi full access builtin disabled smoke\n", "utf8"); - const grantResponse = await page.request.fetch(`${BASE}/api/admin/access-policy/grants`, { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - }, - data: { - userId: ACTOR_ID, - rootUri: ROOT_URI, - rootPath: ROOT_PATH, - permission: "write", - recursive: true, - capabilities: ["ai"], - }, - timeout: TIMEOUT, + fs.writeFileSync( + path.join(ROOT_PATH, PAGE_PATH), + "# Pi full access builtin tools smoke\n\nBUILTIN_READ_MARKER\n", + "utf8", + ); + fs.rmSync(path.join(ROOT_PATH, SCRATCH_PATH), { force: true }); + await setupWorkspaceAccess(page.request, BASE, { + actorId: ACTOR_ID, + email: "mnote.e2e@example.com", + username: ACTOR_ID, + displayName: ACTOR_ID, + role: "admin", + workspaceId: WORKSPACE_ID, + workspaceName: "Pi full access builtin tools smoke", + rootPath: ROOT_PATH, + rootUri: ROOT_URI, + permission: "write", + capabilities: ["ai", "read", "write"], + timeoutMs: TIMEOUT, }); - const grantText = await grantResponse.text(); - let grantBody = {}; - try { - grantBody = grantText ? JSON.parse(grantText) : {}; - } catch { - grantBody = { raw: grantText }; - } - if (!grantResponse.ok() && grantBody.code !== "local_access_policy_grant_duplicate") { - throw new Error(`POST /api/admin/access-policy/grants failed: ${grantResponse.status()} ${grantText.slice(0, 800)}`); - } - await requestJson(page, "/api/ai-admin/settings", { - method: "PUT", - data: { - ...policyForFullAccessBuiltinDisabled(), - quota: { daily: 200 }, - }, + await seedAiPolicy(page.request, BASE, { + id: `pi-full-access-builtins-${ACTOR_ID}-${WORKSPACE_ID}`, + userId: ACTOR_ID, + workspaceId: WORKSPACE_ID, + allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }], + modelPolicyJson: policyForFullAccessBuiltinTools(), + quotaJson: { daily: 200 }, + timeoutMs: TIMEOUT, }); await requestJson(page, `/api/ai-settings/effective?workspaceId=${encodeURIComponent(WORKSPACE_ID)}`); } @@ -130,7 +131,7 @@ async function abortExistingSession(page) { } async function startRealPi(page) { - const sessionId = `pi-full-access-builtin-disabled-${STAMP}`; + const sessionId = `pi-full-access-builtin-tools-${STAMP}`; const start = await requestJson(page, "/api/page-ai/pi/start", { method: "POST", data: { @@ -138,7 +139,7 @@ async function startRealPi(page) { rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, pagePath: PAGE_PATH, - pageTitle: "Pi full access builtin disabled smoke", + pageTitle: "Pi full access builtin tools smoke", modelProvider: MODEL_PROVIDER, modelId: MODEL_ID, thinkingLevel: "medium", @@ -150,6 +151,7 @@ async function startRealPi(page) { assert.equal(start.session.runtimePolicySnapshot.permissionMode, "full_access", "runtime policy should persist full_access"); assert.equal(start.session.runtimeMode, "rpc", `Pi 必须以 rpc 模式启动,实际=${start.session.runtimeMode}`); assert(start.session.runtimePid, "真实 Pi RPC 启动后应有 runtimePid"); + start.session.managedPiBuiltinTools = start.managedPiBuiltinTools || []; return start.session; } @@ -181,6 +183,41 @@ function readSessionJsonl(sessionDir) { return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") }; } +function readBuiltinToolResults(raw) { + return raw + .split("\n") + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return undefined; + } + }) + .filter((entry) => entry?.type === "message" + && entry?.message?.role === "toolResult" + && BUILTIN_TOOLS.includes(entry.message.toolName)) + .map((entry) => entry.message); +} + +async function waitForCompleteSessionJsonl(sessionDir) { + const deadline = Date.now() + Math.min(TIMEOUT, 15000); + let snapshot = readSessionJsonl(sessionDir); + while (Date.now() < deadline) { + const calledTools = BUILTIN_TOOLS.filter((tool) => snapshot.raw.includes(`"name":"${tool}"`)); + if (calledTools.includes("ls") + && calledTools.includes("read") + && calledTools.includes("bash") + && !snapshot.raw.includes('"name":"mnote_local_file_read"') + && snapshot.raw.includes(MARKER)) { + return snapshot; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + snapshot = readSessionJsonl(sessionDir); + } + return snapshot; +} + async function main() { mkdirp(OUT); const browser = await chromium.launch({ @@ -223,20 +260,26 @@ async function main() { result.checks.noLegacyNpmAskUser = !enabledSources.includes("npm:pi-ask-user"); result.checks.noExternalPermissionSystem = !enabledSources.includes("npm:@gotgenes/pi-permission-system"); result.checks.officialPermissionGateConfigured = enabledSources.includes("pi-rust-official:permission-gate"); - result.checks.managedBuiltinToolsDisabledAtStart = (session.runtimePolicySnapshot.managedBuiltinTools || []).length === 0; + result.checks.managedBuiltinToolsAtStart = session.managedPiBuiltinTools || []; assert.equal(result.checks.permissionSystemConfigAbsent, true, "full_access smoke should not generate legacy pi-permission-system config"); assert.equal(result.checks.noLegacyNpmAskUser, true, "full_access smoke should not use unavailable npm:pi-ask-user"); assert.equal(result.checks.noExternalPermissionSystem, true, "full_access smoke should not load incompatible pi-permission-system"); assert.equal(result.checks.officialPermissionGateConfigured, true, "policy should include Pi Rust official permission-gate"); + assert.deepEqual( + [...result.checks.managedBuiltinToolsAtStart].sort(), + [...BUILTIN_TOOLS].sort(), + "full_access should expose Pi Rust official built-in tools instead of replacing them with MNote file tools", + ); await openPiUi(page); await page.screenshot({ path: path.join(OUT, "01-full-access-started.png"), fullPage: false }); result.screenshots.started = path.join(OUT, "01-full-access-started.png"); const prompt = [ - "请调用 mnote_allowed_roots_describe 工具,读取 MNote 返回的 allowedRoots、deniedPiBuiltinTools、managedPiBuiltinTools、permissionProvider。", - "不要调用 bash/read/write/edit/hashline_edit/grep/find/ls 这些 Pi 内置工具。", - "用一句话说明:full_access 下 MNote 当前仍默认禁用 Pi Rust 内置文件/命令工具,文件权限由 MNote bridge 管控。", + "当前是 full_access 验收。必须真实调用 Pi Rust 内置工具,不能只描述。", + "先调用 ls 列出当前工作目录,再调用 read 读取当前页文件。", + "然后调用 bash 执行 pwd。确认当前页内容包含 BUILTIN_READ_MARKER。", + "不要调用 mnote_local_file_read 或 mnote_local_file_patch;MNote 工具只用于上下文和知识库,不应替代 Pi 原生文件工具。", `最终单独输出一行:${MARKER}`, ].join("\n"); await page.locator("[data-page-ai-pi-lab-input]").fill(prompt); @@ -254,21 +297,36 @@ async function main() { await page.waitForTimeout(500); } await markerLocator.waitFor({ state: "visible", timeout: 1000 }); - await page.screenshot({ path: path.join(OUT, "02-full-access-builtin-disabled-answer.png"), fullPage: false }); - result.screenshots.answer = path.join(OUT, "02-full-access-builtin-disabled-answer.png"); + await page.screenshot({ path: path.join(OUT, "02-full-access-builtin-tools-answer.png"), fullPage: false }); + result.screenshots.answer = path.join(OUT, "02-full-access-builtin-tools-answer.png"); - const sessionJsonl = readSessionJsonl(session.piSessionDir); + const sessionJsonl = await waitForCompleteSessionJsonl(session.piSessionDir); result.session.sessionFile = sessionJsonl.sessionFile; result.answerText = ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim(); - result.checks.allowedRootsToolCalled = /mnote_allowed_roots_describe/.test(sessionJsonl.raw); - result.checks.deniedBuiltinsRecorded = /deniedPiBuiltinTools/.test(sessionJsonl.raw) && /hashline_edit/.test(sessionJsonl.raw); - result.checks.managedBuiltinsEmptyRecorded = /managedPiBuiltinTools/.test(sessionJsonl.raw); - result.checks.noRawBuiltinCalled = !/"name":"(bash|read|write|edit|hashline_edit|grep|find|ls)"/.test(sessionJsonl.raw); + result.checks.calledBuiltinTools = BUILTIN_TOOLS.filter((tool) => sessionJsonl.raw.includes(`"name":"${tool}"`)); + result.checks.calledMnoteLocalFileRead = sessionJsonl.raw.includes('"name":"mnote_local_file_read"'); + result.checks.calledMnoteLocalFilePatch = sessionJsonl.raw.includes('"name":"mnote_local_file_patch"'); + const builtinToolResults = readBuiltinToolResults(sessionJsonl.raw); + result.checks.builtinToolResultCount = builtinToolResults.length; + result.checks.failedBuiltinTools = builtinToolResults + .filter((message) => message.isError === true) + .map((message) => message.toolName); + result.checks.readContainsMarker = sessionJsonl.raw.includes("BUILTIN_READ_MARKER"); + result.checks.lsSawPage = sessionJsonl.raw.includes(PAGE_PATH); + result.checks.noBridgeSessionFailure = !/page_ai_pi_lab_session_not_found|page_ai_pi_lab_bridge_token_invalid|mnote_pi_rust_service_bridge_unavailable|mnote_pi_bridge_session_id_missing/i.test(sessionJsonl.raw); + result.checks.scratchContent = fs.existsSync(path.join(ROOT_PATH, SCRATCH_PATH)) + ? fs.readFileSync(path.join(ROOT_PATH, SCRATCH_PATH), "utf8") + : ""; result.checks.noPermissionRequiredPrompt = await page.locator("text=Permission Required").count() === 0; - assert(result.checks.allowedRootsToolCalled, "Pi session JSONL should record mnote_allowed_roots_describe call"); - assert(result.checks.deniedBuiltinsRecorded, "Pi session JSONL should include deniedPiBuiltinTools"); - assert(result.checks.managedBuiltinsEmptyRecorded, "Pi session JSONL should include managedPiBuiltinTools"); - assert(result.checks.noRawBuiltinCalled, "Pi raw builtin tools should remain disabled by default"); + assert(result.checks.calledBuiltinTools.includes("ls"), "full_access should allow Pi Rust builtin ls"); + assert(result.checks.calledBuiltinTools.includes("read"), "full_access should allow Pi Rust builtin read"); + assert(result.checks.calledBuiltinTools.includes("bash"), "full_access should allow Pi Rust builtin bash"); + assert.equal(result.checks.calledMnoteLocalFileRead, false, "Pi Rust builtin read must not be replaced by mnote_local_file_read"); + assert.equal(result.checks.calledMnoteLocalFilePatch, false, "This smoke must not use mnote_local_file_patch"); + assert.equal(result.checks.readContainsMarker, true, "Pi Rust builtin read result should contain the seeded page marker"); + assert.equal(result.checks.lsSawPage, true, "Pi Rust builtin ls should list the seeded page file"); + assert.equal(result.checks.noBridgeSessionFailure, true, "MNote bridge context must not fail while Pi builtins are available"); + assert.equal(result.checks.scratchContent, "", "negative full_access smoke should not create scratch files via raw builtins"); assert(result.checks.noPermissionRequiredPrompt, "official permission-gate smoke should not show legacy Permission Required prompt"); await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: session.sessionId } }).catch(() => ({})); diff --git a/scripts/task-pi-lab-rpc-api-smoke.js b/scripts/task-pi-lab-rpc-api-smoke.js index 6b1cb7de..3993761b 100644 --- a/scripts/task-pi-lab-rpc-api-smoke.js +++ b/scripts/task-pi-lab-rpc-api-smoke.js @@ -80,7 +80,9 @@ async function seedWorkspaceAccess(rootUri, rootPath) { }), }); if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { - return { ok: false, skipped: true, reason: "dev_seed_disabled" }; + throw new Error( + "Pi RPC smoke 需要 /api/dev/seed;请使用 `npm run dev:hot` 启动并重试。dev:hot 默认开启 MNOTE_WEB_ALLOW_DEV_FIXTURES=1,修改启动环境后必须重启 Node 主进程。", + ); } assert(seed.status === 200 && seed.body.ok === true, `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`); return { ok: true, skipped: false }; @@ -215,7 +217,7 @@ async function main() { assert(status.body.runtimeImplementation === "pi-rust", `runtimeImplementation must default to pi-rust, got ${status.body.runtimeImplementation}`); assert(status.body.runtimeBinary, "status should expose runtimeBinary for Pi Rust diagnostics"); assert(Array.isArray(status.body.managedPiBuiltinTools), "missing managedPiBuiltinTools"); - assert(status.body.managedPiBuiltinTools.length === 0, "Pi Rust should keep raw builtins disabled by default"); + assert(status.body.managedPiBuiltinTools.length === 0, "status without an active full_access session should not claim raw builtins are enabled"); assert(status.body.uiMode === "independent_mnote_native_drawer", "Pi Lab should report independent native drawer UI mode"); pass("status enabled/rpc with Rust bridge policy and independent Pi Lab UI mode"); } catch (err) { @@ -245,7 +247,11 @@ async function main() { assert(start.body.session.runtimePid > 0, "runtimePid must be positive"); assert(start.body.session.allowedRootsSnapshot, "start did not capture allowed roots snapshot"); assert(Array.isArray(start.body.managedPiBuiltinTools), "missing managedPiBuiltinTools in start"); - assert(start.body.managedPiBuiltinTools.length === 0, "start should not expose raw Pi builtins by default"); + assert.deepEqual( + [...start.body.managedPiBuiltinTools].sort(), + ["edit", "find", "grep", "hashline_edit", "ls", "read", "write"].sort(), + "auto_edit should expose Pi Rust read/write/edit builtins but keep bash for full_access", + ); assert(start.body.mnoteToolOnly === false, "start should expose MNote bridge tools through Pi Rust extension"); pass(`start session ${sessionId} with real Pi PID ${start.body.session.runtimePid}`); } catch (err) { @@ -288,7 +294,11 @@ async function main() { assert(payload.mode === "rpc", `mode must be rpc, got ${payload.mode}`); assert(typeof payload.pid === "number", "PID must be a number in event"); assert(Array.isArray(payload.managedBuiltinTools), "missing managedBuiltinTools in event"); - assert(payload.managedBuiltinTools.length === 0, "runtime event should keep raw Pi builtins disabled by default"); + assert.deepEqual( + [...payload.managedBuiltinTools].sort(), + ["edit", "find", "grep", "hashline_edit", "ls", "read", "write"].sort(), + "runtime event should expose auto_edit Pi Rust builtins", + ); pass(`runtime_started event observed (mode=rpc, pid=${payload.pid})`); } else { const statusAfterStart = await fetchJson(`${BASE}/api/page-ai/pi/status`); @@ -490,15 +500,18 @@ async function main() { fail(`abort: ${err.message}`); } - // ── 14. Verify session status changed to Aborted ──────────────── + // ── 14. Verify aborted session leaves active status and persists in history ─ try { const status2 = await fetchJson(`${BASE}/api/page-ai/pi/status`); - assert(status2.body.session, "status should return current session"); + assert(status2.status === 200, `status returned ${status2.status}`); + assert(status2.body.session == null, "aborted session should not remain auto-resumable"); + const history = await fetchJson(`${BASE}/api/page-ai/pi/sessions/${encodeURIComponent(sessionId)}`); + assert(history.status === 200, `session history returned ${history.status}`); assert( - status2.body.session.status === "aborted", - `expected session status aborted, got ${status2.body.session.status}` + history.body.session?.status === "aborted", + `expected persisted session status aborted, got ${history.body.session?.status}` ); - pass("session status transitions to aborted"); + pass("aborted session leaves active status and persists as aborted"); } catch (err) { fail(`session status aborted: ${err.message}`); } diff --git a/scripts/task-pi-lab-static-smoke.js b/scripts/task-pi-lab-static-smoke.js index b4f8f553..6ae01eef 100644 --- a/scripts/task-pi-lab-static-smoke.js +++ b/scripts/task-pi-lab-static-smoke.js @@ -2,7 +2,7 @@ // Pi Lab static code smoke // 验证 Pi Lab 相关源码结构正确,不依赖后端运行 // 确认:新端点、状态机、无轮询、SSE、Pi builtin 禁用、allowed roots、receipt -// 确认:默认模型 omniroute/freefirst 在前端 UI 和 header 中明确体现 +// 确认:默认模型 omniroute/gpt-5.4-mini 在前端 UI 和 header 中明确体现 const fs = require('fs'); const path = require('path'); @@ -37,6 +37,26 @@ const mnotePiPackage = readFile(files.mnotePiPackage); const mnotePiExtension = readFile(files.mnotePiExtension); const mnotePiMcpExtension = readFile(files.mnotePiMcpExtension); const mnotePiMcpClient = readFile(files.mnotePiMcpClient); +const devHot = readFile(path.join(repoRoot, 'scripts/dev-hot.js')); + +function functionBody(source, name) { + const start = source.indexOf(`function ${name}(`); + if (start < 0) return ''; + const brace = source.indexOf('{', start); + if (brace < 0) return ''; + let depth = 0; + for (let i = brace; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) return source.slice(start, i + 1); + } + } + return source.slice(start); +} + +const showPiLabBody = functionBody(runtime, 'showPiLab'); const checks = [ // === Runtime JS: existence === @@ -106,23 +126,27 @@ const checks = [ ['runtime has OpenHub-style left history drawer markers', runtime.includes('data-page-ai-pi-lab-history-layer') && runtime.includes('wolai-page-ai-pi-lab-history-scrim') && runtime.includes('历史对话')], ['runtime history drawer supports refresh/delete/export/clear', runtime.includes('data-page-ai-pi-lab-history-refresh') && runtime.includes('data-page-ai-pi-lab-history-delete') && runtime.includes('data-page-ai-pi-lab-history-export') && runtime.includes('data-page-ai-pi-lab-history-clear')], ['runtime does not expose manual Pi runtime start button', !runtime.includes('data-page-ai-pi-lab-btn-start') && !runtime.includes('启动 Pi runtime') && !runtime.includes('预启动 Pi 会话')], - ['runtime auto starts Pi session when drawer opens', runtime.includes('function showPiLab') && runtime.includes('checkStatus().then(function ()') && runtime.includes('return startRuntime();')], + ['runtime auto starts current page Pi session when drawer opens', showPiLabBody.includes('checkStatus().then(function ()') && showPiLabBody.includes('startRuntime().then')], ['runtime has native model controls', runtime.includes('data-page-ai-pi-lab-model-provider') && runtime.includes('data-page-ai-pi-lab-model-id') && runtime.includes('data-page-ai-pi-lab-model-custom')], + ['runtime applies model controls through Pi RPC configure endpoint', runtime.includes("CONFIGURE: '/api/page-ai/pi/configure'") && runtime.includes('applyModelConfigToRuntime') && runtime.includes('pendingModelConfigApply')], ['runtime collapses secondary right rail sections', runtime.includes('
0], ['route has status endpoint', route.includes('pub async fn status')], ['route has start endpoint', route.includes('pub async fn start')], + ['route has configure endpoint for Pi RPC model/thinking changes', route.includes('pub async fn configure') && route.includes('"type": "set_model"') && route.includes('"type": "set_thinking_level"')], ['route has send endpoint', route.includes('pub async fn send')], ['route has abort endpoint', route.includes('pub async fn abort')], ['route has events SSE endpoint', route.includes('pub async fn events')], @@ -153,7 +178,14 @@ const checks = [ ['route enforces allowed roots', route.includes('active_allowed_roots') || route.includes('allowed_roots')], ['route blocks path escape', route.includes('path_escape') || route.includes('path_is_inside')], ['route has default model provider constant PI_LAB_DEFAULT_MODEL_PROVIDER=omniroute', route.includes('PI_LAB_DEFAULT_MODEL_PROVIDER') && route.includes('omniroute')], - ['route has default model id constant PI_LAB_DEFAULT_MODEL_ID=freefirst', route.includes('PI_LAB_DEFAULT_MODEL_ID') && route.includes('freefirst')], + ['route has default model id constant PI_LAB_DEFAULT_MODEL_ID=gpt-5.4-mini', route.includes('PI_LAB_DEFAULT_MODEL_ID') && route.includes('gpt-5.4-mini')], + ['route checks OmniRoute tool_calling capability before real runtime start', route.includes('ensure_session_model_supports_tools') && route.includes('page_ai_pi_model_tools_unsupported') && route.includes('tool_calling')], + ['runtime replays deferred model config after streaming', runtime.includes('maybeApplyPendingModelConfig') && runtime.includes('pendingModelConfigApply')], + ['runtime preserves queued permission mode across in-flight mode changes', runtime.includes('pendingPermissionMode') && runtime.includes('piLabState.pendingPermissionMode = piLabState.permissionMode')], + ['route writes Pi Rust models apiKey as bare env var name, not shell literal', route.includes('"apiKey": "OPENAI_API_KEY"') && !route.includes('"apiKey": "$OPENAI_API_KEY"')], + ['route derives OmniRoute tool support from verified model capability', route.includes('"supportsTools": supports_tools') && route.includes('omniroute_model_tool_calling_capability')], + ['route keeps OmniRoute streaming usage enabled', route.includes('"supportsUsageInStreaming": true') && !route.includes('"supportsUsageInStreaming": false')], + ['route sends Pi Rust directly to configured OmniRoute base URL', route.includes('"baseUrl": omniroute_base_url()') && !route.includes('omniroute_proxy_chat_completions') && !routesMod.includes('/api/page-ai/pi/omniroute-proxy/')], ['route defaults to Pi Rust runtime implementation', route.includes('PI_LAB_RUNTIME_IMPL_RUST') && route.includes('"pi-rust"') && route.includes('MNOTE_PAGE_AI_PI_RUST_BIN')], ['route keeps TS Pi as explicit fallback only', route.includes('PI_LAB_RUNTIME_IMPL_TS') && route.includes('MNOTE_PAGE_AI_PI_TS_BIN')], ['route rejects arbitrary cookie_header auth bypass', route.includes('page_ai_pi_lab_invalid_session_cookie') && !route.includes('context.auth.cookie_header.is_some() {\n return Ok') ], @@ -167,14 +199,25 @@ const checks = [ ['route never writes bridge token into generated extension source', !route.includes('const BRIDGE_TOKEN = {bridge_token}')], ['route rejects bridge calls for non-running sessions', route.includes('page_ai_pi_lab_bridge_session_not_running') && route.includes('PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning')], ['route loads official MNote Pi package extension', route.includes('mnote_pi_extension_path') && route.includes('packages/pi-mnote/extensions/mnote-bridge.ts')], + ['MNote Pi bridge reads session and base URL from private context fallback', mnotePiExtension.includes('bridgeSessionId') && mnotePiExtension.includes('bridgeBaseUrl') && mnotePiExtension.includes('readContextFileSnapshot')], + ['MNote Pi bridge does not trust generic runtime session id env', mnotePiExtension.includes('PI_MNOTE_BRIDGE_SESSION_ID') && mnotePiExtension.includes('MNOTE_PI_BRIDGE_SESSION_ID') && !mnotePiExtension.includes('MNOTE_PI_LAB_SESSION_ID')], + ['MNote Pi bridge allows native tools when context file proves Pi Rust', mnotePiExtension.includes('isPiRustNativeRuntime') && mnotePiExtension.includes('Boolean(CONTEXT_FILE)')], ['route starts Pi with explicit MNote extension bridge', route.includes('--extension') && route.includes('mnotePiExtension')], ['route starts Pi with MNote tool allowlist', route.includes('--tools') && route.includes('mnoteToolNames') && route.includes('pi_lab_extension_tool_names')], ['route passes MNote tool manifest to extension env', route.includes('MNOTE_PI_BRIDGE_TOOLS') && route.includes('PI_MNOTE_BRIDGE_TOOLS') && route.includes('mnote_pi_tool_manifest')], ['route writes dynamic Pi Rust MNote context file', route.includes('mnote.pi.context.v1') && route.includes('PI_MNOTE_CONTEXT_FILE') && route.includes('write_pi_mnote_context_snapshot')], - ['route injects dynamic Pi Rust input context', route.includes('MNOTE_PI_CONTEXT_V1') && route.includes('pi_mnote_input_context_prefix')], - ['route disables Pi builtins by default unless external permission extension is opt-in', route.includes('pi_lab_enabled_builtin_tools') && route.includes('MNOTE_PAGE_AI_PI_ALLOW_EXTERNAL_EXTENSIONS') && route.includes('configuredPiExtensionSources') && route.includes('pi_lab_runtime_extension_sources')], + ['route binds staged MNote bridge to absolute context path and private embedded snapshot', route.includes('bind_mnote_bridge_context_path') && route.includes('DEFAULT_CONTEXT_FILE') && route.includes('EMBEDDED_CONTEXT')], + ['route sends per-prompt Pi Rust context through extension input hook', route.includes('mnote.pi.context.v1') && route.includes('write_pi_mnote_context_snapshot') && route.includes('fn pi_mnote_input_context_prefix') && route.includes('format!(\"{input_context_prefix}{message}\")')], + ['route resolves local file tools against session rootUri when params omit rootUri', route.includes('session: Option<&PiLabSession>') && route.includes('session.and_then(|session| session.root_uri.clone())')], + ['route exposes Pi JSONL replay messages from session tree', route.includes('"messages": replay_messages') && route.includes('build_pi_replay_messages(&entries)') && route.includes('pi_lab_active_path_entries')], + ['route exposes Pi Rust builtins in full_access instead of replacing them with MNote file tools', route.includes('fn pi_lab_enabled_builtin_tools') && route.includes('Some("full_access")') && route.includes('pi_lab_managed_builtin_tools()') && route.includes('Some("auto_edit")') && route.includes('Some("plan")') && route.includes('PI_LAB_MANAGED_BUILTIN_TOOLS')], ['route exposes Rust Pi runtime diagnostics', route.includes('hashline_edit') && route.includes('runtimeImplementation') && route.includes('runtimeBinary') && route.includes('runtimeAvailable') && route.includes('runtimeInstallHint') && route.includes('runtimeError')], + ['route reports warmup runtime without exposing it as current page session', route.includes('"warmupRunning"') && route.includes('"warmupSessionId"') && route.includes('"warmupProcessCount"') && route.includes('is_pi_lab_warmup_session_id(&session.session_id)')], + ['runtime send path distinguishes warm runtime binding from cold start', runtime.includes('正在绑定当前页 Pi 会话,完成后自动发送') && runtime.includes('Pi Rust 正在启动,启动后自动发送') && runtime.includes('piLabState.warmupRunning')], + ['dev:hot warmup defaults to no real model prompt', devHot.includes('MNOTE_PAGE_AI_PI_WARMUP_SEND') && devHot.includes('?? "0"')], + ['runtime opens Pi drawer by prestarting current page session', showPiLabBody.includes('startRuntime().then') && showPiLabBody.includes('syncRuntimeState()')], ['runtime send path auto-starts Pi Rust instead of dead-end not-ready toast', runtime.includes('Pi Rust 正在启动,启动后自动发送') && runtime.includes('piLabStartPromise') && !runtime.includes('Pi 会话尚未就绪,请重新打开 Pi 面板或稍后重试')], + ['runtime keeps selected model before start/configure', runtime.includes('ensurePiToolCapableModel') && !runtime.includes('isPiToolUnsupportedModel') && runtime.includes('gpt-5.4-mini') && runtime.includes('freefirst')], ['route applies TTL and rate limits', route.includes('PI_LAB_SESSION_TTL_MS') && route.includes('PI_LAB_RATE_LIMITS') && route.includes('check_rate_limit')], ['route cleans expired session dirs and rate buckets', route.includes('fs::remove_dir_all') && route.includes('buckets.retain')], @@ -185,8 +228,10 @@ const checks = [ ['@mnote/pi extension registers MNote tools', mnotePiExtension.includes('pi.registerTool') && mnotePiExtension.includes('mnote_current_page_read')], ['@mnote/pi extension keeps legacy MNote bridge API fallback', mnotePiExtension.includes('/api/page-ai/pi/tool-call-bridge') && mnotePiExtension.includes('x-mnote-pi-lab-bridge-token')], ['@mnote/pi extension uses Pi Rust native current-page read', mnotePiExtension.includes('pi-rust-native-fs') && mnotePiExtension.includes('PI_MNOTE_CONTEXT_FILE') && mnotePiExtension.includes('fs.readFileSync')], + ['@mnote/pi extension uses Pi Rust native local file read/patch', mnotePiExtension.includes('executeNativeLocalFileRead') && mnotePiExtension.includes('executeNativeLocalFilePatch') && !mnotePiExtension.includes('仍依赖旧 HTTP bridge')], + ['@mnote/pi extension resolves local file tools relative to selected folder', mnotePiExtension.includes('joinRelativePath') && mnotePiExtension.includes('preferFolder: true') && mnotePiExtension.includes('folderPath')], ['@mnote/pi extension consumes Pi Rust input context anywhere after skill expansion', mnotePiExtension.includes('pi.on?.("input"') && mnotePiExtension.includes('MNOTE_PI_CONTEXT_V1') && mnotePiExtension.includes('text.indexOf(CONTEXT_PREFIX)') && mnotePiExtension.includes('action: "transform"')], - ['route preserves slash skill command before hidden input context', route.includes('pi_lab_command_message_for_session') && route.includes('message.starts_with("/skill:")') && route.includes('format!("{command}\\n{input_context_prefix}{effective_args}")')], + ['route preserves slash skill command while passing hidden context to extension hook', route.includes('pi_lab_command_message_for_session') && route.includes('message.starts_with("/skill:")') && route.includes('format!("{command}\\n{effective_args}")') && route.includes('format!("{input_context_prefix}{skill_args}")')], ['@mnote/pi extension supports LightRAG tools', mnotePiExtension.includes('mnote_knowledge_rag_query') && mnotePiExtension.includes('mnote_knowledge_rag_section_context')], ['@mnote/pi MCP extension registers mcp tool', mnotePiMcpExtension.includes('registerTool') && mnotePiMcpExtension.includes('name: "mcp"')], ['@mnote/pi MCP extension uses official synchronous child_process bridge', mnotePiMcpExtension.includes('execFileSync') && mnotePiMcpExtension.includes('client.mjs') && mnotePiMcpExtension.includes('transport: "pi-rust-sync-client"') && !mnotePiMcpExtension.includes('/api/page-ai/pi/mcp-call-bridge') && !mnotePiMcpExtension.includes('fetch(')], @@ -225,11 +270,105 @@ const checks = [ ['gateway.rs does NOT stamp body hidden gate', !gateway.includes('data-page-ai-pi-lab-hidden')], ['app.rs defaults Pi Lab config on', app.includes('enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true)')], + + // === Runtime JS: new API endpoints (STATE/COMPACT/QUEUE_CONFIG) === + ['runtime uses /api/page-ai/pi/state', runtime.includes('/api/page-ai/pi/state')], + ['runtime uses /api/page-ai/pi/compact', runtime.includes('/api/page-ai/pi/compact')], + ['runtime uses /api/page-ai/pi/queue-config', runtime.includes('/api/page-ai/pi/queue-config')], + ['runtime has API.STATE constant', runtime.includes("STATE: '/api/page-ai/pi/state'")], + ['runtime has API.COMPACT constant', runtime.includes("COMPACT: '/api/page-ai/pi/compact'")], + ['runtime has API.QUEUE_CONFIG constant', runtime.includes("QUEUE_CONFIG: '/api/page-ai/pi/queue-config'")], + ['runtime maps session tree preview into history messages', runtime.includes('entry.text || entry.content || entry.preview')], + ['runtime fetches artifact diff with sessionId query', runtime.includes("'?sessionId=' + encodeURIComponent(sid)")], + + // === Runtime JS: state sync function (no setInterval) === + ['runtime has syncRuntimeState function', runtime.includes('function syncRuntimeState')], + ['syncRuntimeState calls API.STATE', runtime.includes("API.STATE") && runtime.includes('sessionId: piLabState.sessionId')], + ['syncRuntimeState syncs queuedMessages', runtime.includes('queuedMessages') && runtime.includes('piLabState.queuedMessages')], + ['syncRuntimeState syncs pendingMessageCount', runtime.includes('pendingMessageCount')], + ['syncRuntimeState syncs isCompacting', runtime.includes('isCompacting') && runtime.includes('piLabState.isCompacting')], + ['syncRuntimeState syncs contextUsage', runtime.includes('contextUsage') && runtime.includes('piLabState.contextUsage')], + ['syncRuntimeState syncs model/thinking from API.STATE', runtime.includes('data.modelProvider') && runtime.includes('data.thinkingLevel')], + ['syncRuntimeState called after showPiLab drawer open', runtime.indexOf('showPiLab') < runtime.indexOf('syncRuntimeState()') || runtime.includes('showPiLab') && runtime.includes('syncRuntimeState')], + ['syncRuntimeState called after startRuntime success (in success .then)', runtime.includes('syncRuntimeState();') && runtime.includes('Pi runtime ready') && runtime.includes('return data;')], + ['syncRuntimeState called after sendPrompt accepted (in sendPrompt .then)', runtime.includes('syncRuntimeState();') && runtime.includes('data.accepted') && runtime.includes('Pi Lab rejected prompt')], + ['syncRuntimeState called after abortPrompt (in abortPrompt function)', runtime.includes('syncRuntimeState();') && runtime.includes('abortPrompt') && runtime.includes('setState(STATE_ABORTED)')], + ['syncRuntimeState called in SSE connected event', runtime.includes("'connected'") && runtime.includes('syncRuntimeState()')], + ['syncRuntimeState called in runtime_started event', runtime.includes("runtime_started") && runtime.includes('syncRuntimeState()')], + ['syncRuntimeState called in runtime_aborted event', runtime.includes("runtime_aborted") && runtime.includes('syncRuntimeState')], + ['syncRuntimeState called from checkStatus when session running', runtime.includes("connectEventSource(data.session.sessionId)") && runtime.includes("syncRuntimeState()")], + ['syncRuntimeState does NOT use setInterval', !runtime.includes('setInterval(syncRuntimeState') && !runtime.includes("setInterval(syncRuntimeState")], + + // === Runtime JS: compact function === + ['runtime has triggerCompact function', runtime.includes('function triggerCompact')], + ['triggerCompact calls API.COMPACT', runtime.includes("API.COMPACT")], + ['triggerCompact sets isCompacting=true', runtime.includes('piLabState.isCompacting = true')], + ['triggerCompact guards against streaming in function body', runtime.includes('if (piLabState.status === STATE_STREAMING) return;')], + ['triggerCompact guards against replay in function body', runtime.includes('if (piLabState.viewingHistorySessionId) return;')], + ['triggerCompact shows compaction summary as diagnostic', runtime.includes('updateDiagnostics') && runtime.includes('Compaction:')], + ['triggerCompact inserts compaction result card', runtime.includes("type: 'compaction'") && runtime.includes('data-page-ai-pi-lab-compaction-result')], + ['triggerCompact shows toast on done', runtime.includes('showPiToast') && runtime.includes('会话压缩完成')], + ['triggerCompact shows toast on failure', runtime.includes('showPiToast') && runtime.includes('压缩失败')], + + // === Runtime JS: queue config function === + ['runtime has fetchQueueConfig function', runtime.includes('function fetchQueueConfig')], + ['fetchQueueConfig calls API.QUEUE_CONFIG', runtime.includes("API.QUEUE_CONFIG")], + ['fetchQueueConfig posts sessionId JSON body', runtime.includes("method: 'POST'") && runtime.includes('sessionId: piLabState.sessionId')], + + // === Runtime JS: compact button in UI === + ['runtime has compact button in commandbar', runtime.includes('data-page-ai-pi-lab-btn-compact')], + ['compact button disabled in updateButtons', runtime.includes('compactBtn.disabled')], + ['compact button wired in wireEvents', runtime.includes('compactBtn.addEventListener') && runtime.includes('triggerCompact')], + ['compact button uses compact icon', runtime.includes("compact: '")], + + // === Runtime JS: new state fields === + ['runtime has queuedMessages state field', runtime.includes('queuedMessages: []')], + ['runtime has pendingMessageCount state field', runtime.includes('pendingMessageCount: 0')], + ['runtime has isCompacting state field', runtime.includes('isCompacting: false')], + ['runtime has contextUsage state field', runtime.includes('contextUsage: {}')], // === Key architectural constraints === ['no modification of sidebar-page-ai-runtime', !runtime.includes('sidebar-page-ai-runtime')], ['no sidebar-page-ai-runtime default behavior change', !runtime.includes('sidebarPageAiRuntime')], ['no import of main page AI runtime', !runtime.includes('import.*sidebar-page-ai')], + + // === B2: JSONL reading constants in page_ai_pi.rs === + ["route has PI_LAB_JSONL_MAX_FILE_BYTES constant", route.includes("PI_LAB_JSONL_MAX_FILE_BYTES")], + ["route has PI_LAB_JSONL_MAX_LINE_BYTES constant", route.includes("PI_LAB_JSONL_MAX_LINE_BYTES")], + ["route has PI_LAB_JSONL_MAX_ENTRIES constant", route.includes("PI_LAB_JSONL_MAX_ENTRIES")], + ["route has PI_LAB_JSONL_WINDOW_ENTRIES constant", route.includes("PI_LAB_JSONL_WINDOW_ENTRIES")], + + // === B2: JSONL helper functions === + ["route has read_pi_session_jsonl function", route.includes("fn read_pi_session_jsonl")], + ["route has build_pi_entry_tree function", route.includes("fn build_pi_entry_tree")], + + // === B1: pi_session_file field === + ["route serializes piSessionFile in build_run_runtime_json", route.includes("piSessionFile")], + + // === B3: session_tree endpoint === + ["route has PI_LAB_SCHEMA_SESSION_TREE constant", route.includes("PI_LAB_SCHEMA_SESSION_TREE")], + ["route has session_tree endpoint function", route.includes("pub async fn session_tree")], + + // === B5: fork endpoint === + ["route has PI_LAB_SCHEMA_FORK constant", route.includes("PI_LAB_SCHEMA_FORK")], + ["route has fork_pi_session endpoint function", route.includes("pub async fn fork_pi_session")], + + // === D2: artifact_diff endpoint === + ["route has PI_LAB_SCHEMA_ARTIFACT_DIFF constant", route.includes("PI_LAB_SCHEMA_ARTIFACT_DIFF")], + ["route has artifact_diff endpoint function", route.includes("pub async fn artifact_diff")], + ["artifact_diff query accepts camelCase sessionId", route.includes('serde(rename_all = "camelCase")') && route.includes("pub struct PiLabArtifactDiffQuery")], + ["route publishes file patch artifact event", route.includes('"artifact_file_patch"') && route.includes('mnote.page_ai_pi.artifact.file_patch.v1')], + + // === mod.rs: new B3/B5/D2 routes === + ["mod.rs mounts pi session_tree route", routesMod.includes("/api/page-ai/pi/sessions/{session_id}/tree")], + ["mod.rs mounts pi fork route", routesMod.includes("/api/page-ai/pi/fork")], + ["mod.rs mounts pi artifact_diff route", routesMod.includes("/api/page-ai/pi/artifacts/{tool_event_id}/diff")], + + // === mod.rs: state/compact/queue_config/configure routes === + ["mod.rs mounts pi state route", routesMod.includes("/api/page-ai/pi/state")], + ["mod.rs mounts pi compact route", routesMod.includes("/api/page-ai/pi/compact")], + ["mod.rs mounts pi queue-config route", routesMod.includes("/api/page-ai/pi/queue-config")], + ["mod.rs mounts pi configure route", routesMod.includes("/api/page-ai/pi/configure")], ]; const failed = checks.filter(([, ok]) => !ok);