diff --git a/cursor.md b/cursor.md index 2bbd7f6..40a1d6c 100644 --- a/cursor.md +++ b/cursor.md @@ -1,9 +1,4 @@ -以下是重构后完整目标版本 -实现时,先创建所有目录和文件固化,每个文件头部都写好注释再实现 -旧服务已被备份为server_backup,/Users/leokun/Documents/cursor-byok/server 目录已创建 -行数均为目标估算,使用 `≈` 标记;不包含测试、生成代码和空行。 -实现时可做略微调整,测试要求相对于目标文件旁边的独立文件,禁止码内测试 -本文档目录 /Users/leokun/Documents/cursor-byok/cursor.md + ## 完整目录 ```text @@ -656,54 +651,7 @@ store ─X→ cursor model ─X→ cursor ``` -## 当前代码迁移 -```text -当前 目标 - -cursor/bidi_append.rs → api/cursor/bidi.rs -cursor/run_sse.rs → api/cursor/run_sse.rs -cursor/handlers.rs → api/cursor/handlers.rs -cursor/proxy.rs → api/cursor/proxy.rs - -cursor/sessions.rs → cursor/transport/registry.rs - + cursor/transport/handle.rs - + cursor/transport/output.rs - -cursor/inbox.rs → cursor/transport/inbox.rs - -cursor/actor.rs → cursor/transport/ - + cursor/conversation/runtime.rs - + cursor/conversation/delivery.rs - -cursor/session.rs → cursor/conversation/runtime.rs - + cursor/conversation/output.rs - + cursor/checkpoint/ - + cursor/tools/ - -cursor/request/prepare.rs → cursor/compile/run.rs -cursor/request/context.rs → cursor/compile/context.rs -cursor/request/background.rs → cursor/compile/insert_messages.rs -cursor/request/runtime.rs → cursor/compile/break_messages.rs -cursor/request/images.rs → cursor/compile/images.rs -cursor/request/model.rs → cursor/compile/model.rs - -cursor/interaction/mod.rs → cursor/protocol/events.rs -cursor/interaction/query.rs → cursor/tools/codec/query.rs -cursor/interaction/render.rs → cursor/tools/codec/render.rs - -cursor/projection/decode.rs → cursor/checkpoint/messages/decode.rs -cursor/projection/encode.rs → cursor/checkpoint/messages/encode.rs -cursor/projection/tests.rs → cursor/checkpoint/messages/tests.rs - -cursor/presentation.rs → cursor/checkpoint/steps.rs - -run/runtime.rs RunRegistry → cursor/conversation/registry.rs -run/runtime.rs RunActor → run/engine.rs + run/handle.rs -run/port.rs → run/command.rs + run/event.rs + run/port.rs - -store/revisions.rs → store/checkpoints.rs -``` ## 最终核心 @@ -719,4 +667,4 @@ Bidi → Checkpoint → Transport → RunSSE -``` \ No newline at end of file +``` diff --git a/server/src/cursor/conversation/output.rs b/server/src/cursor/conversation/output.rs index 54c4126..7fe905f 100644 --- a/server/src/cursor/conversation/output.rs +++ b/server/src/cursor/conversation/output.rs @@ -343,7 +343,14 @@ impl ConversationOutput { let call = calls.get_mut(&index).ok_or_else(|| { Error::Protocol(format!("unknown completed tool index: {index}")) })?; - call.arguments = serde_json::from_str(&call.arguments_text)?; + // A tool call with no arguments streams no argument text. + // Treat empty text as an empty object, matching the model + // cycle, instead of failing the run on `from_str("")`. + call.arguments = if call.arguments_text.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&call.arguments_text)? + }; } RunEvent::Usage(usage) => { if !self.context.compacting { diff --git a/server/src/store/tool_rounds.rs b/server/src/store/tool_rounds.rs index 4418fb2..a7f3fc5 100644 --- a/server/src/store/tool_rounds.rs +++ b/server/src/store/tool_rounds.rs @@ -101,7 +101,14 @@ impl Store { .bind(&call.call_id) .bind(&call.model_call_id) .bind(&call.name) - .bind(&call.arguments_text) + // A no-argument tool call streams no argument text; persist it as an + // empty object so the `arguments_json` column always holds valid JSON + // and can be re-parsed on load. + .bind(if call.arguments_text.trim().is_empty() { + "{}" + } else { + call.arguments_text.as_str() + }) .execute(&mut *tx) .await?; } diff --git a/server/tests/interrupt.rs b/server/tests/interrupt.rs index d64dc11..b1260f8 100644 --- a/server/tests/interrupt.rs +++ b/server/tests/interrupt.rs @@ -506,6 +506,68 @@ async fn runtime_user_message_action_interrupts_and_continues_with_new_message() assert!(history.contains("queued follow-up")); } +#[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;