use std::fs;
use std::path::Path;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use serde_json::json;
use tempfile::tempdir;
use tokio::time::{Duration, timeout};
use super::request_payloads::build_thread_list_request_payload;
use crate::config::Config;
use crate::state::BridgeState;
#[test]
fn thread_list_request_payload_omits_absent_optional_strings() {
let payload = build_thread_list_request_payload(None, 50, false, None);
assert_eq!(
payload,
json!({
"limit": 50,
"archived": false,
}),
);
}
#[test]
fn thread_list_request_payload_keeps_present_optional_strings() {
let payload = build_thread_list_request_payload(Some("cursor-1"), 20, true, Some("abc"));
assert_eq!(
payload,
json!({
"cursor": "cursor-1",
"limit": 20,
"archived": true,
"searchTerm": "abc",
}),
);
}
#[tokio::test]
async fn start_thread_falls_back_when_empty_thread_cannot_include_turns() {
let temp_dir = tempdir().expect("创建临时目录失败");
let workspace = temp_dir.path().join("workspace");
let db_path = temp_dir.path().join("bridge.db");
let log_path = temp_dir.path().join("requests.json");
let script_path = temp_dir.path().join("fake-codex");
fs::create_dir_all(&workspace).expect("创建工作目录失败");
fs::write(
&script_path,
format!(
r#"#!/usr/bin/env python3
import json
import pathlib
import sys
log_path = pathlib.Path({log_path:?})
messages = []
thread_name = ""
thread_cwd = ""
def write_log():
log_path.write_text(json.dumps(messages))
def thread_payload():
return {{
"id": "thread-empty",
"name": thread_name or None,
"preview": "",
"cwd": thread_cwd,
"status": {{"type": "notLoaded"}},
"modelProvider": "openai",
"source": "mobile",
"createdAt": 1,
"updatedAt": 2
}}
for raw_line in sys.stdin:
line = raw_line.strip()
if not line:
continue
message = json.loads(line)
messages.append(message)
write_log()
method = message.get("method")
if method == "initialize":
print(json.dumps({{
"jsonrpc": "2.0",
"id": message["id"],
"result": {{
"userAgent": "codex-test",
"codexHome": "/tmp/codex-home",
"platformFamily": "unix",
"platformOs": "linux"
}}
}}), flush=True)
elif method == "thread/start":
thread_cwd = message["params"]["cwd"]
print(json.dumps({{
"jsonrpc": "2.0",
"id": message["id"],
"result": {{
"thread": thread_payload()
}}
}}), flush=True)
elif method == "thread/name/set":
thread_name = message["params"].get("name") or ""
print(json.dumps({{
"jsonrpc": "2.0",
"id": message["id"],
"result": {{}}
}}), flush=True)
elif method == "thread/read":
if message["params"].get("includeTurns"):
print(json.dumps({{
"jsonrpc": "2.0",
"id": message["id"],
"error": {{
"code": -32600,
"message": "thread thread-empty is not materialized yet; includeTurns is unavailable before first user message"
}}
}}), flush=True)
else:
print(json.dumps({{
"jsonrpc": "2.0",
"id": message["id"],
"result": {{
"thread": thread_payload()
}}
}}), flush=True)
"#,
log_path = log_path.display().to_string(),
),
)
.expect("写入 fake codex 脚本失败");
#[cfg(unix)]
{
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))
.expect("设置脚本权限失败");
}
let state = BridgeState::bootstrap(Config {
listen_addr: "127.0.0.1:0".to_string(),
token: "test-token".to_string(),
runtime_limit: 4,
db_path,
codex_home: None,
codex_binary: script_path.display().to_string(),
directory_bookmarks: Vec::new(),
})
.await
.expect("bootstrap 测试 BridgeState 失败");
let response = timeout(
Duration::from_secs(5),
state.handle_request(
"start_thread",
json!({
"cwd": workspace.display().to_string(),
"name": "空线程",
}),
),
)
.await
.expect("start_thread 超时")
.expect("start_thread 返回错误");
assert_eq!(response["thread"]["id"], json!("thread-empty"));
assert_eq!(response["thread"]["name"], json!("空线程"));
assert_eq!(response["entries"], json!([]));
assert_eq!(
response["renderSnapshot"]["threadId"],
json!("thread-empty")
);
let messages = wait_for_recorded_messages(&log_path, 6).await;
let reads = messages
.iter()
.filter(|message| message["method"] == "thread/read")
.collect::<Vec<_>>();
assert_eq!(reads.len(), 2);
assert_eq!(reads[0]["params"]["includeTurns"], json!(true));
assert_eq!(reads[1]["params"]["includeTurns"], json!(false));
}
async fn wait_for_recorded_messages(
log_path: &Path,
minimum_messages: usize,
) -> Vec<serde_json::Value> {
timeout(Duration::from_secs(5), async {
loop {
let messages = fs::read_to_string(log_path)
.ok()
.and_then(|content| serde_json::from_str::<Vec<serde_json::Value>>(&content).ok());
if let Some(messages) = messages.filter(|messages| messages.len() >= minimum_messages) {
return messages;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.expect("等待 fake codex 记录请求超时")
}