mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-06 13:44:21 +08:00
Merge remote-tracking branch 'origin/main' into pr-385-merge
# Conflicts: # server/tests/interrupt.rs
This commit is contained in:
@@ -27,6 +27,7 @@ async fn summarize_replaces_model_history_and_preserves_cursor_history() {
|
||||
.create_model(&ModelConfigInput {
|
||||
sort_order: 0,
|
||||
display_name: "Test Model".into(),
|
||||
group_name: None,
|
||||
model_type: ModelType::OpenAi,
|
||||
base_url: "https://example.com/v1/chat/completions".into(),
|
||||
use_full_url: true,
|
||||
|
||||
@@ -622,6 +622,68 @@ async fn runtime_user_message_reports_delivered_and_appended() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_call_with_empty_arguments_does_not_fail_the_run() {
|
||||
// A tool call that carries no arguments streams no argument text. Parsing it
|
||||
// as JSON must yield an empty object (as the model cycle already does), not
|
||||
// fail the run with `EOF while parsing a value`.
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
let provider = fake_provider::FakeProvider::default();
|
||||
provider.push(tool_response("call-1", "UpdateCurrentStep", ""));
|
||||
provider.push(text_response("done after empty-argument tool"));
|
||||
let assets = PromptAssets::load(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("prompt/cursor")
|
||||
.as_path(),
|
||||
)
|
||||
.unwrap();
|
||||
let registry = TransportRegistry::new(
|
||||
store,
|
||||
Arc::new(provider.clone()),
|
||||
PromptCompiler::new(assets),
|
||||
);
|
||||
let handle = registry.get_or_create("empty-args-request").await.unwrap();
|
||||
let mut output = handle.subscribe();
|
||||
handle
|
||||
.command(TransportCommand::Append {
|
||||
seqno: 0,
|
||||
message: Box::new(client_run_for(
|
||||
"empty-args-request",
|
||||
"empty-args-conversation",
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut append_seqno = 1;
|
||||
let mut saw_done = false;
|
||||
loop {
|
||||
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("RunSSE closed before successful EndStream");
|
||||
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
|
||||
if flags & connect::END_STREAM_FLAG != 0 {
|
||||
assert_eq!(
|
||||
payload.as_ref(),
|
||||
b"{}",
|
||||
"run failed: {}",
|
||||
String::from_utf8_lossy(&payload)
|
||||
);
|
||||
break;
|
||||
}
|
||||
let server = pb::AgentServerMessage::decode(payload).unwrap();
|
||||
if let Some(pb::agent_server_message::Message::InteractionUpdate(update)) = server.message {
|
||||
if let Some(pb::interaction_update::Message::TextDelta(delta)) = update.message {
|
||||
saw_done |= delta.text.contains("done after empty-argument tool");
|
||||
}
|
||||
}
|
||||
acknowledge_kv(&handle, &mut append_seqno, &frame).await;
|
||||
}
|
||||
assert!(saw_done);
|
||||
assert_eq!(provider.requests().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn injected_user_context_restarts_only_the_active_model_cycle() {
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
@@ -944,6 +1006,7 @@ async fn injected_user_context_interrupts_automatic_compaction() {
|
||||
.create_model(&ModelConfigInput {
|
||||
sort_order: 0,
|
||||
display_name: "Test Model".into(),
|
||||
group_name: None,
|
||||
model_type: ModelType::OpenAi,
|
||||
base_url: "https://example.com/v1/chat/completions".into(),
|
||||
use_full_url: true,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Verifies KnowledgeBase rules CRUD falls back to local markdown storage
|
||||
//! when the Cursor upstream is unreachable or rejects the request.
|
||||
#[path = "support/fixtures.rs"]
|
||||
mod fixtures;
|
||||
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
extract::Extension,
|
||||
http::{header, Request, Response},
|
||||
};
|
||||
use cursor_server::{
|
||||
api::cursor::proxy::CursorProxy,
|
||||
cursor::services::knowledge::{self, KnowledgeService},
|
||||
};
|
||||
use prost::Message;
|
||||
|
||||
// 测试侧的镜像消息定义,同时充当 wire 兼容性检查。
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct AddRequest {
|
||||
#[prost(string, tag = "1")]
|
||||
knowledge: String,
|
||||
#[prost(string, tag = "2")]
|
||||
title: String,
|
||||
#[prost(string, tag = "3")]
|
||||
git_origin: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct AddResponse {
|
||||
#[prost(bool, tag = "1")]
|
||||
success: bool,
|
||||
#[prost(string, tag = "2")]
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct ListRequest {
|
||||
#[prost(int32, optional, tag = "1")]
|
||||
limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct ListResponse {
|
||||
#[prost(bool, tag = "1")]
|
||||
success: bool,
|
||||
#[prost(message, repeated, tag = "2")]
|
||||
all_results: Vec<ListItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct ListItem {
|
||||
#[prost(string, tag = "1")]
|
||||
id: String,
|
||||
#[prost(string, tag = "2")]
|
||||
knowledge: String,
|
||||
#[prost(string, tag = "3")]
|
||||
title: String,
|
||||
#[prost(string, tag = "4")]
|
||||
created_at: String,
|
||||
#[prost(bool, tag = "5")]
|
||||
is_generated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct UpdateRequest {
|
||||
#[prost(string, tag = "1")]
|
||||
id: String,
|
||||
#[prost(string, tag = "2")]
|
||||
knowledge: String,
|
||||
#[prost(string, tag = "3")]
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct UpdateResponse {
|
||||
#[prost(bool, tag = "1")]
|
||||
success: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct RemoveRequest {
|
||||
#[prost(string, tag = "1")]
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct RemoveResponse {
|
||||
#[prost(bool, tag = "1")]
|
||||
success: bool,
|
||||
}
|
||||
|
||||
fn proto_request(message: &impl Message) -> Request<Body> {
|
||||
Request::post("/test")
|
||||
.header(header::CONTENT_TYPE, "application/proto")
|
||||
.body(Body::from(message.encode_to_vec()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn decode<M: Message + Default>(response: Response<Body>) -> M {
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
M::decode(body.as_ref()).unwrap()
|
||||
}
|
||||
|
||||
/// 无凭据请求上游必然失败(网络错误或 401),四个接口全部走本地降级,
|
||||
/// 覆盖 md 持久化、离线日志压缩与增删改查闭环。
|
||||
#[tokio::test]
|
||||
async fn offline_crud_round_trip_persists_markdown() {
|
||||
let (_store_dir, store) = fixtures::temp_store().await;
|
||||
let upstream = CursorProxy::cursor(store).unwrap();
|
||||
let rules_dir = tempfile::tempdir().unwrap();
|
||||
let rules_root = rules_dir.path().join("rules");
|
||||
let service = KnowledgeService::with_root(rules_root.clone()).unwrap();
|
||||
|
||||
// Add:得到本地临时 id,md 文件落盘。
|
||||
let response = knowledge::add(
|
||||
Extension(upstream.clone()),
|
||||
Extension(service.clone()),
|
||||
proto_request(&AddRequest {
|
||||
knowledge: "always answer in haiku".into(),
|
||||
title: "haiku rule".into(),
|
||||
git_origin: String::new(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let added: AddResponse = decode(response).await;
|
||||
assert!(added.success);
|
||||
assert!(added.id.starts_with("local-"), "offline add uses a local id");
|
||||
let markdown = rules_root.join(format!("{}.md", added.id));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&markdown).unwrap(),
|
||||
"always answer in haiku"
|
||||
);
|
||||
|
||||
// List:本地缓存返回刚写入的规则。
|
||||
let response = knowledge::list(
|
||||
Extension(upstream.clone()),
|
||||
Extension(service.clone()),
|
||||
proto_request(&ListRequest { limit: Some(100) }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let listed: ListResponse = decode(response).await;
|
||||
assert!(listed.success);
|
||||
assert_eq!(listed.all_results.len(), 1);
|
||||
assert_eq!(listed.all_results[0].id, added.id);
|
||||
assert_eq!(listed.all_results[0].title, "haiku rule");
|
||||
|
||||
// Update:内容与标题都更新到 md 与元数据。
|
||||
let response = knowledge::update(
|
||||
Extension(upstream.clone()),
|
||||
Extension(service.clone()),
|
||||
proto_request(&UpdateRequest {
|
||||
id: added.id.clone(),
|
||||
knowledge: "always answer in sonnets".into(),
|
||||
title: "sonnet rule".into(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let updated: UpdateResponse = decode(response).await;
|
||||
assert!(updated.success);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&markdown).unwrap(),
|
||||
"always answer in sonnets"
|
||||
);
|
||||
|
||||
// Remove:文件删除,列表为空。
|
||||
let response = knowledge::remove(
|
||||
Extension(upstream.clone()),
|
||||
Extension(service.clone()),
|
||||
proto_request(&RemoveRequest {
|
||||
id: added.id.clone(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let removed: RemoveResponse = decode(response).await;
|
||||
assert!(removed.success);
|
||||
assert!(!markdown.exists());
|
||||
|
||||
let response = knowledge::list(
|
||||
Extension(upstream),
|
||||
Extension(service),
|
||||
proto_request(&ListRequest { limit: Some(100) }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let listed: ListResponse = decode(response).await;
|
||||
assert!(listed.all_results.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updating_missing_rule_reports_failure() {
|
||||
let (_store_dir, store) = fixtures::temp_store().await;
|
||||
let upstream = CursorProxy::cursor(store).unwrap();
|
||||
let rules_dir = tempfile::tempdir().unwrap();
|
||||
let service = KnowledgeService::with_root(rules_dir.path().join("rules")).unwrap();
|
||||
|
||||
let response = knowledge::update(
|
||||
Extension(upstream),
|
||||
Extension(service),
|
||||
proto_request(&UpdateRequest {
|
||||
id: "17353272".into(),
|
||||
knowledge: "anything".into(),
|
||||
title: "anything".into(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let updated: UpdateResponse = decode(response).await;
|
||||
assert!(!updated.success);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Verifies local markdown rules are merged into the request-context message.
|
||||
#[path = "support/fake_provider.rs"]
|
||||
mod fake_provider;
|
||||
#[path = "support/fixtures.rs"]
|
||||
mod fixtures;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cursor_server::{
|
||||
cursor::{
|
||||
prompting::{PromptAssets, PromptCompiler},
|
||||
protocol::connect,
|
||||
protocol::proto::agent::v1 as pb,
|
||||
TransportCommand, TransportRegistry,
|
||||
},
|
||||
model::{ContentPart, ProjectedContent},
|
||||
provider::{FinishReason, ModelEvent},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_markdown_rules_land_in_the_request_context_message() {
|
||||
let (_store_dir, store) = fixtures::temp_store().await;
|
||||
let provider = fake_provider::FakeProvider::default();
|
||||
provider.push(vec![
|
||||
ModelEvent::Start {
|
||||
model_call_id: "call-1".into(),
|
||||
},
|
||||
ModelEvent::TextStart,
|
||||
ModelEvent::TextDelta("ok".into()),
|
||||
ModelEvent::TextEnd,
|
||||
ModelEvent::Done(FinishReason::Stop),
|
||||
]);
|
||||
let assets = PromptAssets::load(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("prompt/cursor")
|
||||
.as_path(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let rules_dir = tempfile::tempdir().unwrap();
|
||||
let rules_root = rules_dir.path().join("rules");
|
||||
std::fs::create_dir_all(&rules_root).unwrap();
|
||||
std::fs::write(rules_root.join("17353272.md"), "Always answer in haiku.").unwrap();
|
||||
|
||||
let registry = TransportRegistry::with_local_rules(
|
||||
store,
|
||||
Arc::new(provider.clone()),
|
||||
PromptCompiler::new(assets),
|
||||
rules_root,
|
||||
);
|
||||
let handle = registry.get_or_create("rules-request").await.unwrap();
|
||||
let mut output = handle.subscribe();
|
||||
handle
|
||||
.command(TransportCommand::Append {
|
||||
seqno: 0,
|
||||
message: Box::new(user_run()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
loop {
|
||||
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
|
||||
.await
|
||||
.expect("run finishes within timeout")
|
||||
.expect("output stays open until EndStream");
|
||||
let ended = connect::decode_frames(&frame)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|(flags, _)| flags & connect::END_STREAM_FLAG != 0);
|
||||
if ended {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let requests = provider.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let context_texts = requests[0]
|
||||
.history
|
||||
.iter()
|
||||
.filter(|message| message.message_id.starts_with("request-context:"))
|
||||
.map(|message| {
|
||||
let ProjectedContent::Parts(parts) = &message.content else {
|
||||
panic!("request context message must be parts")
|
||||
};
|
||||
let [ContentPart::Text { text }] = parts.as_slice() else {
|
||||
panic!("request context message must be one text part")
|
||||
};
|
||||
text.clone()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
context_texts.len(),
|
||||
1,
|
||||
"exactly one request-context message is projected"
|
||||
);
|
||||
assert!(
|
||||
context_texts[0].contains("<user_rule>\nAlways answer in haiku.\n</user_rule>"),
|
||||
"local markdown rule must appear as a user rule: {}",
|
||||
context_texts[0]
|
||||
);
|
||||
|
||||
registry.shutdown().await;
|
||||
}
|
||||
|
||||
fn user_run() -> pb::AgentClientMessage {
|
||||
pb::AgentClientMessage {
|
||||
message: Some(pb::agent_client_message::Message::RunRequest(
|
||||
pb::AgentRunRequest {
|
||||
action: Some(pb::ConversationAction {
|
||||
action: Some(pb::conversation_action::Action::UserMessageAction(
|
||||
pb::UserMessageAction {
|
||||
user_message: Some(pb::UserMessage {
|
||||
text: "hello".into(),
|
||||
message_id: "rules-user".into(),
|
||||
mode: pb::AgentMode::Agent as i32,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
..Default::default()
|
||||
}),
|
||||
conversation_id: Some("rules-conversation".into()),
|
||||
run_id: Some("rules-request".into()),
|
||||
requested_model: Some(pb::RequestedModel {
|
||||
model_id: "test-model".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user