feat(page-ai): align ACP session runtime

This commit is contained in:
lix-2026
2026-06-10 07:09:04 +08:00
parent f698aad964
commit 2cfeb97763
13 changed files with 3377 additions and 48 deletions
@@ -1159,6 +1159,158 @@ rl.on('line', (line) => {
Arc::new(client)
}
/// Creates a Hermes-like ACP mock where session/load succeeds and replays
/// history as session/update before the load response completes.
async fn spawn_mock_hermes_load_replay_acp() -> Arc<AcpClient> {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (!msg.id) return;
if (msg.method === 'initialize') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: {
protocolVersion: 1,
agentCapabilities: { loadSession: true },
agentInfo: { name: 'hermes-replay-mock', version: '1.0' },
authMethods: []
}
}) + '\n');
} else if (msg.method === 'session/load') {
const sessionId = msg.params?.sessionId || 'loaded_session_1';
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'REPLAYED_HISTORY_BEFORE_LOAD_RETURN' }
}
}
}) + '\n');
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { sessionId }
}) + '\n');
} else if (msg.method === 'session/new') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { sessionId: 'new_session_should_not_be_used' }
}) + '\n');
} else if (msg.method === 'session/prompt') {
const sessionId = msg.params?.sessionId || 'loaded_session_1';
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'CURRENT_PROMPT_AFTER_LOAD' }
}
}
}) + '\n');
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { stopReason: 'end_turn' }
}) + '\n');
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_session_hermes_replay_mock.mjs");
std::fs::write(&script_path, script).expect("write hermes replay mock");
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn hermes replay mock");
Arc::new(client)
}
/// Creates a Reasonix-like ACP mock: no session/load support, context lives
/// in the live in-process session map and is visible on the next prompt.
async fn spawn_mock_reasonix_live_acp() -> Arc<AcpClient> {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
const sessions = new Map();
let nextId = 1;
function textFromPrompt(prompt) {
return (Array.isArray(prompt) ? prompt : [])
.map((block) => block && block.type === 'text' ? String(block.text || '') : '')
.join('\n')
.trim();
}
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (!msg.id) return;
if (msg.method === 'initialize') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: {
protocolVersion: 1,
agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } },
agentInfo: { name: 'reasonix-live-mock', version: '1.0' },
authMethods: []
}
}) + '\n');
} else if (msg.method === 'session/new') {
const sessionId = `reasonix_session_${nextId++}`;
sessions.set(sessionId, []);
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { sessionId }
}) + '\n');
} else if (msg.method === 'session/load') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
error: { code: -32601, message: 'session/load not supported' }
}) + '\n');
} else if (msg.method === 'session/prompt') {
const sessionId = msg.params?.sessionId || '';
const history = sessions.get(sessionId);
if (!history) {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
error: { code: -32602, message: `unknown session ${sessionId}` }
}) + '\n');
return;
}
const text = textFromPrompt(msg.params?.prompt);
const previous = history.length ? history[history.length - 1] : '';
history.push(text);
const answer = previous ? `previous=${previous}; current=${text}` : `current=${text}`;
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: answer }
}
}
}) + '\n');
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { stopReason: 'end_turn' }
}) + '\n');
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_session_reasonix_live_mock.mjs");
std::fs::write(&script_path, script).expect("write reasonix mock");
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn reasonix mock");
Arc::new(client)
}
#[tokio::test]
async fn test_load_session_success() {
let client = spawn_mock_acp_with_load().await;
@@ -1271,6 +1423,74 @@ rl.on('line', (line) => {
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
}
#[tokio::test]
async fn test_reasonix_live_session_keeps_short_reply_context() {
let client = spawn_mock_reasonix_live_acp().await;
let mgr = AcpSessionManager::new(client);
let chunks = Arc::new(Mutex::new(Vec::<String>::new()));
let chunks_for_handler = chunks.clone();
mgr.on_event(move |event| {
if let AcpSessionEvent::TextDelta { text, .. } = event {
chunks_for_handler.lock().unwrap().push(text);
}
});
let session_id = mgr
.create_session(Some("/test"), None)
.await
.expect("create reasonix session");
assert_eq!(session_id, "reasonix_session_1");
mgr.run_prompt(vec![ContentBlock::Text {
text: "上一轮计划:检查 LightRAG 服务".into(),
}])
.await
.expect("first prompt");
mgr.run_prompt(vec![ContentBlock::Text {
text: "可以".into(),
}])
.await
.expect("second prompt");
let output = chunks.lock().unwrap().join("\n");
assert!(
output.contains("previous=上一轮计划:检查 LightRAG 服务; current=可以"),
"Reasonix live mock should remember previous prompt, got: {output}"
);
}
#[tokio::test]
async fn test_hermes_load_replay_events_before_prompt() {
let client = spawn_mock_hermes_load_replay_acp().await;
let mgr = AcpSessionManager::new(client);
let chunks = Arc::new(Mutex::new(Vec::<String>::new()));
let chunks_for_handler = chunks.clone();
mgr.on_event(move |event| {
if let AcpSessionEvent::TextDelta { text, .. } = event {
chunks_for_handler.lock().unwrap().push(text);
}
});
let loaded = mgr
.ensure_session(Some("/test"), Some("stored_hermes_session"))
.await
.expect("load stored Hermes session");
assert_eq!(loaded, "stored_hermes_session");
sleep(Duration::from_millis(100)).await;
let replayed = chunks.lock().unwrap().join("\n");
assert!(
replayed.contains("REPLAYED_HISTORY_BEFORE_LOAD_RETURN"),
"Hermes load should replay history before prompt, got: {replayed}"
);
mgr.run_prompt(vec![ContentBlock::Text {
text: "current prompt".into(),
}])
.await
.expect("prompt after load");
let output = chunks.lock().unwrap().join("\n");
assert!(output.contains("CURRENT_PROMPT_AFTER_LOAD"), "prompt output missing: {output}");
}
#[test]
fn test_thought_chunk_maps_to_thought_delta() {
let accumulated = Arc::new(Mutex::new(String::new()));
File diff suppressed because it is too large Load Diff
+4
View File
@@ -679,6 +679,10 @@ pub fn build_router(state: AppState) -> Router {
"/client/sessions/{session_id}/rename",
post(hermes_client::rename_session),
)
.route(
"/client/sessions/{session_id}/export",
get(hermes_client::export_session),
)
.route(
"/client/sessions/{session_id}/auto-title",
post(hermes_client::auto_title_session),
+8 -7
View File
@@ -5248,7 +5248,7 @@ html[data-mnote-page-ai-resizing="true"] {
.wolai-page-ai-message--history {
position: relative;
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
grid-template-columns: 24px minmax(0, 1fr) auto;
width: 100%;
min-height: 58px;
align-items: flex-start;
@@ -5452,14 +5452,15 @@ button.wolai-page-ai-history-main span {
}
.wolai-page-ai-history-actions {
position: absolute;
top: 6px;
right: 6px;
grid-column: 3;
grid-row: 1;
position: static;
z-index: 2;
flex-wrap: nowrap;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
background: inherit;
opacity: 1;
pointer-events: auto;
background: transparent;
}
.wolai-page-ai-message--history:hover .wolai-page-ai-history-actions,