use serde_json::json;
use tokio::time::{Duration, timeout};
use crate::app_server::AppServerInbound;
use crate::storage::PRIMARY_RUNTIME_ID;
use super::super::events::handle_app_server_message;
use super::support::{bootstrap_test_state, primary_runtime};
#[tokio::test]
async fn app_server_starting_message_updates_status_without_deadlock() {
let state = bootstrap_test_state().await;
timeout(
Duration::from_secs(2),
handle_app_server_message(
&state,
AppServerInbound::Starting {
runtime_id: PRIMARY_RUNTIME_ID.to_string(),
},
),
)
.await
.expect("处理 Starting 消息超时")
.expect("处理 Starting 消息失败");
let runtime = primary_runtime(&state).await;
let status = runtime.status.read().await.clone();
assert_eq!(status.status, "starting");
assert_eq!(status.app_server_handshake.state, "starting");
}
#[tokio::test]
async fn permissions_server_request_is_persisted_for_clients() {
let state = bootstrap_test_state().await;
timeout(
Duration::from_secs(2),
handle_app_server_message(
&state,
AppServerInbound::ServerRequest {
runtime_id: PRIMARY_RUNTIME_ID.to_string(),
id: json!("req-permissions-1"),
method: "item/permissions/requestApproval".to_string(),
params: json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "item-1",
"reason": "需要额外权限",
"permissions": {
"network": { "enabled": true },
"fileSystem": {
"read": ["/srv/workspace/docs"],
"write": ["/srv/workspace/tmp"]
}
}
}),
},
),
)
.await
.expect("处理 permissions request 超时")
.expect("处理 permissions request 失败");
let pending = state
.storage
.list_pending_requests()
.expect("读取 pending requests 失败");
assert_eq!(1, pending.len());
assert_eq!("item/permissions/requestApproval", pending[0].request_type);
assert_eq!(Some("thread-1"), pending[0].thread_id.as_deref());
assert!(pending[0].permissions.is_some());
}
#[tokio::test]
async fn legacy_server_request_is_normalized_into_pending_request() {
let state = bootstrap_test_state().await;
timeout(
Duration::from_secs(2),
handle_app_server_message(
&state,
AppServerInbound::ServerRequest {
runtime_id: PRIMARY_RUNTIME_ID.to_string(),
id: json!("req-exec-1"),
method: "execCommandApproval".to_string(),
params: json!({
"conversationId": "thread-legacy",
"callId": "call-exec-1",
"approvalId": "approval-1",
"command": ["git", "status"],
"cwd": "/srv/workspace",
"reason": "需要执行命令",
"parsedCmd": []
}),
},
),
)
.await
.expect("处理 legacy request 超时")
.expect("处理 legacy request 失败");
let pending = state
.storage
.list_pending_requests()
.expect("读取 pending requests 失败");
assert_eq!(1, pending.len());
assert_eq!("execCommandApproval", pending[0].request_type);
assert_eq!(Some("thread-legacy"), pending[0].thread_id.as_deref());
assert_eq!(Some("git status"), pending[0].command.as_deref());
assert_eq!(
vec!["approved", "approved_for_session", "denied", "abort"],
pending[0].available_decisions,
);
}
#[tokio::test]
async fn command_execution_render_snapshot_preserves_exec_kind() {
let state = bootstrap_test_state().await;
timeout(
Duration::from_secs(2),
handle_app_server_message(
&state,
AppServerInbound::Notification {
runtime_id: PRIMARY_RUNTIME_ID.to_string(),
method: "item/completed".to_string(),
params: json!({
"threadId": "thread-render-1",
"turnId": "turn-1",
"item": {
"id": "item-explored",
"type": "commandExecution",
"status": "completed",
"command": "ls -la",
"aggregatedOutput": "file-a\nfile-b\n",
"exitCode": 0,
"commandActions": [
{
"type": "listFiles",
"command": "ls -la",
"path": "."
}
]
}
}),
},
),
)
.await
.expect("处理 commandExecution item/completed 超时")
.expect("处理 commandExecution item/completed 失败");
let snapshots = state
.thread_render_snapshots
.lock()
.expect("thread render snapshots poisoned");
let snapshot = snapshots
.get("thread-render-1")
.expect("应缓存 thread render snapshot");
let exec_node = snapshot
.nodes
.iter()
.find_map(|node| match node {
crate::bridge_protocol::ThreadRenderNode::ExecGroup {
kind,
output_text,
..
} => Some((kind.clone(), output_text.clone())),
_ => None,
})
.expect("应存在 exec group 节点");
assert_eq!("explored", exec_node.0);
assert_eq!(Some("file-a\nfile-b\n".to_string()), exec_node.1);
}
#[tokio::test]
async fn file_change_render_snapshot_preserves_per_file_diff() {
let state = bootstrap_test_state().await;
timeout(
Duration::from_secs(2),
handle_app_server_message(
&state,
AppServerInbound::Notification {
runtime_id: PRIMARY_RUNTIME_ID.to_string(),
method: "item/completed".to_string(),
params: json!({
"threadId": "thread-render-2",
"turnId": "turn-2",
"item": {
"id": "item-file-change",
"type": "fileChange",
"status": "completed",
"changes": [
{
"path": "/srv/workspace/src/main.rs",
"kind": {
"update": {}
},
"diff": "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\n"
}
]
}
}),
},
),
)
.await
.expect("处理 fileChange item/completed 超时")
.expect("处理 fileChange item/completed 失败");
let snapshots = state
.thread_render_snapshots
.lock()
.expect("thread render snapshots poisoned");
let snapshot = snapshots
.get("thread-render-2")
.expect("应缓存 thread render snapshot");
let change = snapshot
.nodes
.iter()
.find_map(|node| match node {
crate::bridge_protocol::ThreadRenderNode::FileChange { changes, .. } => {
changes.first().cloned()
}
_ => None,
})
.expect("应存在 file change 节点");
assert_eq!("/srv/workspace/src/main.rs", change.path);
assert_eq!(
Some(
"diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\n"
.to_string()
),
change.diff,
);
}