diff --git a/.agents/skills/cursor-prefix-stability/SKILL.md b/.agents/skills/cursor-prefix-stability/SKILL.md new file mode 100644 index 0000000..1823495 --- /dev/null +++ b/.agents/skills/cursor-prefix-stability/SKILL.md @@ -0,0 +1,82 @@ +--- +name: cursor-prefix-stability +description: Implement and review Cursor BYOK conversation projection with append-only provider history and prefix-cache stability. Use when changing runtime prompts, request context, canonical messages, checkpoint hydration, compaction, message identity, or provider history serialization under server. +--- + +# Cursor prefix stability + +Preserve the longest valid provider prefix across turns. Treat provider-visible history as an append-only log unless an explicit compaction operation replaces it. + +## Architecture + +Keep the invariant in the provider-independent conversation layers: + +```text +server/ +├── prompt/cursor/*/runtime.md Per-turn runtime content +├── src/cursor/request/ Request context and runtime compilation +├── src/cursor/projection/ Canonical ↔ Cursor checkpoint projection +├── src/cursor/checkpoint/ Stable roots, turns, and hydration +├── src/run/ Provider-independent model history +└── src/provider/ Provider-specific serialization only +``` + +Do not solve prefix instability independently in each provider adapter. Produce one stable canonical history before dispatching to OpenAI Responses, OpenAI Chat, Anthropic, or another provider. + +## Required invariants + +- When no compaction occurs, the complete provider-visible history from turn N must be an exact structural prefix of turn N+1. Never edit, remove, merge, reorder, renormalize, or regenerate an earlier message. +- Keep `PromptSpec.instructions` and the stable tool prefix byte-stable when their inputs have not changed. Deterministic ordering is required; do not use unordered iteration in provider-visible output. +- Separate conversation/request context from the per-turn runtime message. The runtime message contains current-turn material such as the user query, selected context, open files, action context, mode reminders, and timestamp. +- Project rules, skills, subagents, environment/Git context, and MCP metadata as a stable `request-context:*` message: + - append it on the first applicable turn; + - do not append it again when its compiled content is identical to the latest projected request context; + - when its content changes, append a new request-context message immediately before the current runtime message; + - never represent a context change by rewriting the system prompt, replacing an earlier context message, or mutating a checkpoint root. +- Give every appended context update a unique event identity. Compare the latest context by content, not only by identifier, so `A → B → A` appends the final `A` again while retries of the same event remain idempotent. +- Preserve `request-context:*` wire identity through checkpoint encoding and hydration. Deduplication must still work after process restart or conversation resume. +- Automatic compaction is an explicit prefix reset. Compact obsolete history, retain exactly the latest request-context message, then place the summary and current initial messages in deterministic order. Manual compaction may reproject current context on the next user turn. +- Background completions and injected runtime events must not manufacture duplicate request context unless they actually start a user turn whose context changed. + +## Change workflow + +Before editing, trace the whole path that applies: + +```text +AgentRunRequest +→ request context hydration/compilation +→ CanonicalMessage identity and persistence +→ checkpoint encode/decode +→ projected ModelRequest history +→ provider serialization +``` + +Determine which data is conversation-level and which is turn-level. If a proposed change moves or rewrites an earlier provider-visible value, redesign it as a new append-only event unless the operation is explicitly compaction. + +Use TDD for changes in this path. Start with a failing behavioral test, then implement the smallest provider-independent change. + +## Verification + +Cover the affected behavior with structural assertions, not token-count estimates alone: + +- Two turns with identical request context: the first request history is an exact prefix of the second, the system instructions are identical, and only one `request-context:*` message exists. +- Changed context: one new context message appears at the tail before the new runtime query; all earlier messages remain unchanged. +- Context reversion `A → B → A`: three distinct context events are retained in order. +- Retry of one runtime event: no duplicate or conflicting context message is persisted. +- Checkpoint round-trip: request-context identity and content survive encode/hydrate. +- Automatic compaction: only the latest context is retained outside the summary. +- Runtime templates render without embedding conversation-level rules or MCP metadata in every user query. + +Run focused tests first, then the relevant server suites: + +```bash +cargo test --lib +cargo test --test runtime_modes +cargo test --test prefix_stability +cargo test --test checkpoint_recovery +cargo test --test compaction +cargo clippy --lib -- -D warnings +cargo fmt --all -- --check +``` + +Do not repair unrelated dirty-worktree failures while validating. Report them separately. diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 0000000..cb42b72 --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,77 @@ +--- +name: release +description: Prepare, authorize, publish, troubleshoot, and verify Cursor BYOK desktop GitHub Releases. Use for version bumps, release tags, GitHub Actions release runs, updater manifests, signing, or release-readiness checks. +--- + +# Desktop release + +Release through `.github/workflows/release.yml`. Preserve both updater formats: Tauri uses `latest.json`; legacy `v0.0.49` clients use `update.json`. + +## Publication authority + +- Only the repository author, GitHub user `leookun`, may authorize a live release. +- Before any live mutation, require an explicit release instruction from the author in the current task and verify `gh api user --jq .login` returns `leookun`. +- Treat all of these as publication actions: pushing a `v*` tag, rerunning the release workflow, and publishing or editing a GitHub Release. Pushing a release commit to `main` only prepares the release and must never trigger publication by itself. +- Without that authorization, restrict work to inspection, local edits, validation, and a release-ready commit or branch. Do not infer publication permission from requests such as “prepare”, “check”, or “ready to release”. +- Never print, commit, or upload `.tauri/cursor-byok.key` anywhere except the repository's `TAURI_SIGNING_PRIVATE_KEY` Actions Secret when the author explicitly requests that secret configuration. +- Never delete, replace, or move an existing tag or published Release without separate explicit authorization. + +## Version and GitHub Release policy + +- Do not use GitHub prereleases. Keep `prerelease: false` for every release and publish the completed release as Latest. +- Use `vMAJOR.MINOR.PATCH` for a stable tag, for example `v0.1.0`. +- Use standard SemVer `vMAJOR.MINOR.PATCH-beta.N` for a test tag, for example `v0.1.0-beta.1`. A beta is still a normal GitHub Release, not a GitHub prerelease. Make its title or body visibly say Beta. +- This normal-Release rule is required because both installed update clients resolve assets through GitHub's `/releases/latest/download/` path, which excludes GitHub prereleases. +- Windows beta builds must use the NSIS bundle. WiX/MSI rejects nonnumeric prerelease identifiers such as `beta.1`; do not weaken the SemVer tag to accommodate MSI. +- Release only from a `v*` tag whose commit is contained in `origin/main`. The tag must equal `v` from the desktop manifests. +- Keep ordinary `main` pushes and manual workflow dispatch disabled as release triggers. The author pushes the matching tag only after the release commit is present on `origin/main`. +- Never republish an already published version. Select a new version instead. + +## Release sources + +Keep the desktop version identical in the manifests and their locks: + +```text +cursor-byok/ +├── Cargo.lock +├── apps/desktop/ +│ ├── package.json +│ ├── package-lock.json +│ └── src-tauri/ +│ ├── Cargo.toml +│ └── tauri.conf.json +├── scripts/cursor-proto/proto/ +│ ├── agent_v1.proto +│ └── aiserver_v1.proto +└── .github/workflows/release.yml +``` + +The two listed Proto files are required build inputs and must be committed. Keep the other locally extracted Proto files ignored unless the build starts depending on them. + +## Prepare and validate + +1. Inspect `git status`, fetch `origin/main`, and preserve unrelated user changes. Confirm the release commit is based on the current remote head. +2. Choose stable or beta numbering explicitly. Update the desktop version in both manifests and lockfiles; do not change the independent `cursor-server` version merely to release the desktop app. +3. Confirm the updater public key in `tauri.conf.json` matches `.tauri/cursor-byok.key.pub` without exposing the private key. +4. Confirm `TAURI_SIGNING_PRIVATE_KEY` exists in GitHub Actions. `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` must be absent when the local key has no password. +5. Confirm neither the intended tag nor Release already exists. +6. From `apps/desktop`, run: + + ```bash + npm run check + npm run tauri:build -- --debug --no-bundle + ``` + +7. Validate the workflow YAML and inspect the staged diff. Ensure `.tauri/`, unrelated local files, and unrelated user changes are not staged. +8. Use the `tauri-action@v1` input `uploadUpdaterJson: true`; `includeUpdaterJson` is not a valid v1 input. + +## Publish and verify + +After the author explicitly authorizes publication: + +1. Commit only the reviewed release set and push it to `main`. Confirm the release commit is present in `origin/main`; this push must not start the release workflow. +2. Create the matching tag on that commit, for example `v0.1.0-beta.1`, and push only that tag. This tag push is the publication trigger. +3. Follow the triggered `Release desktop app` run through completion. Report the run URL and stop on failure; diagnose locally before asking the author to authorize another live attempt. +4. Verify `v` exists, is published rather than draft, has `prerelease: false`, and is the repository's Latest release. +5. Verify the Release contains signed Tauri updater artifacts plus `latest.json`, and the legacy platform archives plus `update.json`. +6. For a beta, report clearly that it is a test version even though GitHub represents it as a normal Latest Release. diff --git a/.agents/skills/release/agents/openai.yaml b/.agents/skills/release/agents/openai.yaml new file mode 100644 index 0000000..daebe98 --- /dev/null +++ b/.agents/skills/release/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Desktop Release" + short_description: "Prepare, authorize, publish, and verify desktop releases" + default_prompt: "Use $release to prepare and verify a Cursor BYOK desktop release." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce3bd11..f0bf863 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,11 +2,8 @@ name: Release desktop app on: push: - branches: - - main tags: - "v*" - workflow_dispatch: permissions: contents: write @@ -24,6 +21,18 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify release author + env: + REPOSITORY_OWNER: ${{ github.repository_owner }} + shell: bash + run: | + if [[ "${GITHUB_ACTOR}" != "${REPOSITORY_OWNER}" ]]; then + echo "Only ${REPOSITORY_OWNER} may publish a release" >&2 + exit 1 + fi - name: Verify updater signing key env: @@ -44,8 +53,12 @@ jobs: cargo_version=$(sed -n '/^version = / { s/version = "\([^"]*\)"/\1/p; q; }' apps/desktop/src-tauri/Cargo.toml) test "${version}" = "${package_version}" test "${version}" = "${cargo_version}" - if [[ "${GITHUB_REF_TYPE}" = "tag" ]]; then - test "${GITHUB_REF_NAME}" = "v${version}" + test "${GITHUB_REF_TYPE}" = "tag" + test "${GITHUB_REF_NAME}" = "v${version}" + git fetch origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then + echo "Release tag must point to a commit contained in origin/main" >&2 + exit 1 fi echo "version=${version}" >> "${GITHUB_OUTPUT}" @@ -78,7 +91,7 @@ jobs: target: "" - platform: windows-x86_64 os: windows-latest - args: "" + args: "--bundles nsis" target: "" - platform: macos-aarch64 os: macos-15 @@ -130,10 +143,10 @@ jobs: projectPath: apps/desktop tagName: v__VERSION__ releaseName: Cursor BYOK v__VERSION__ - releaseBody: Beta release. Download the installer for your platform from the assets below. + releaseBody: ${{ contains(needs.prepare.outputs.version, '-') && 'Beta release. Download the installer for your platform from the assets below.' || 'Download the installer for your platform from the assets below.' }} releaseDraft: true prerelease: false - includeUpdaterJson: true + uploadUpdaterJson: true args: ${{ matrix.args }} - name: Package legacy Linux updater asset diff --git a/server/prompt/cursor/agent/runtime.md b/server/prompt/cursor/agent/runtime.md index be30359..a44ce4a 100644 --- a/server/prompt/cursor/agent/runtime.md +++ b/server/prompt/cursor/agent/runtime.md @@ -1,4 +1,4 @@ -{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} +{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} You are now in Agent mode. You have EXITED your previous mode. Continue with the task in the new mode. diff --git a/server/prompt/cursor/ask/runtime.md b/server/prompt/cursor/ask/runtime.md index 837c7a1..a5bfb7b 100644 --- a/server/prompt/cursor/ask/runtime.md +++ b/server/prompt/cursor/ask/runtime.md @@ -1,4 +1,4 @@ -{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} +{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} You are now in Ask mode. You have EXITED your previous mode. Continue with the task in the new mode. diff --git a/server/prompt/cursor/compaction/runtime.md b/server/prompt/cursor/compaction/runtime.md index 03cb002..1f79552 100644 --- a/server/prompt/cursor/compaction/runtime.md +++ b/server/prompt/cursor/compaction/runtime.md @@ -1,4 +1,4 @@ -{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}{{TIMESTAMP}} +{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}{{TIMESTAMP}} {{USER_QUERY}} diff --git a/server/prompt/cursor/debug/runtime.md b/server/prompt/cursor/debug/runtime.md index 4bf0268..023a95d 100644 --- a/server/prompt/cursor/debug/runtime.md +++ b/server/prompt/cursor/debug/runtime.md @@ -1,4 +1,4 @@ -{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} +{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} You are now in Debug mode. You have EXITED your previous mode. Continue with the task in the new mode. diff --git a/server/prompt/cursor/multitask/runtime.md b/server/prompt/cursor/multitask/runtime.md index 04e6b31..bc18584 100644 --- a/server/prompt/cursor/multitask/runtime.md +++ b/server/prompt/cursor/multitask/runtime.md @@ -1,4 +1,4 @@ -{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} +{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} You are now in Multitask mode. You have EXITED your previous mode. Continue with the task in the new mode. diff --git a/server/prompt/cursor/plan/runtime.md b/server/prompt/cursor/plan/runtime.md index bf805c5..474c1fb 100644 --- a/server/prompt/cursor/plan/runtime.md +++ b/server/prompt/cursor/plan/runtime.md @@ -1,4 +1,4 @@ -{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} +{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} You are now in Plan mode. You have EXITED your previous mode. Continue with the task in the new mode. diff --git a/server/prompt/cursor/subagent/runtime.md b/server/prompt/cursor/subagent/runtime.md index 3480140..2f66207 100644 --- a/server/prompt/cursor/subagent/runtime.md +++ b/server/prompt/cursor/subagent/runtime.md @@ -1,4 +1,4 @@ -{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} +{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}} You are currently working inside a Task subagent. Your parent agent has delegated a clearly bounded assignment to you. Complete that assignment directly with the tools available in this session. The Task tool is unavailable inside subagents, so delegation cannot be nested. {{TIMESTAMP}} diff --git a/server/src/cursor/projection/decode.rs b/server/src/cursor/projection/decode.rs index 4bacbd4..4052f20 100644 --- a/server/src/cursor/projection/decode.rs +++ b/server/src/cursor/projection/decode.rs @@ -30,17 +30,15 @@ pub fn decode(data: &[u8], internal_id: String) -> Result { .and_then(Value::as_str) .unwrap_or_default() .to_string(); + let is_request_context = role == Role::User && wire_id.starts_with("request-context:"); + let is_prompt_context = + is_request_context || role == Role::User && wire_id.starts_with("selected-context:"); let origin = match role { Role::System => Origin::Prompt, Role::Assistant => Origin::Assistant, Role::Tool => Origin::Tool, Role::User if wire_id.starts_with("runtime:") => Origin::Runtime, - Role::User - if wire_id.starts_with("request-context:") - || wire_id.starts_with("selected-context:") => - { - Origin::Prompt - } + Role::User if is_prompt_context => Origin::Prompt, Role::User => Origin::User, }; let runtime_event_id = wire_id.strip_prefix("runtime:").map(str::to_string); @@ -49,7 +47,7 @@ pub fn decode(data: &[u8], internal_id: String) -> Result { Role::Tool => MessageContent::ToolResult(decode_tool_result(&value)?), _ => decode_text(&value)?, }; - let message_id = if runtime_event_id.is_some() { + let message_id = if runtime_event_id.is_some() || is_request_context { wire_id } else { internal_id diff --git a/server/src/cursor/projection/tests.rs b/server/src/cursor/projection/tests.rs index ad2174b..077c57d 100644 --- a/server/src/cursor/projection/tests.rs +++ b/server/src/cursor/projection/tests.rs @@ -131,6 +131,23 @@ fn runtime_wire_identity_survives_checkpoint_hydration() { ); } +#[test] +fn request_context_identity_survives_checkpoint_hydration() { + let wire = json!({ + "role": "user", + "id": "request-context:digest", + "content": "current rules", + }); + let message = decode( + serde_json::to_vec(&wire).unwrap().as_slice(), + "cursor-root:blob-id:20".into(), + ) + .unwrap(); + + assert_eq!(message.message_id, "request-context:digest"); + assert_eq!(message.origin, crate::model::Origin::Prompt); +} + #[test] fn cursor_user_image_uses_image_field() { let wire = json!({ diff --git a/server/src/cursor/prompting/assets.rs b/server/src/cursor/prompting/assets.rs index 5764a81..c6396c7 100644 --- a/server/src/cursor/prompting/assets.rs +++ b/server/src/cursor/prompting/assets.rs @@ -134,7 +134,6 @@ impl PromptAssets { } const RUNTIME_VARIABLES: &[&str] = &[ - "REQUEST_CONTEXT", "OPEN_FILES", "SELECTED_CONTEXT", "ACTION_CONTEXT", diff --git a/server/src/cursor/request/prepare.rs b/server/src/cursor/request/prepare.rs index d00b8c7..0b94b31 100644 --- a/server/src/cursor/request/prepare.rs +++ b/server/src/cursor/request/prepare.rs @@ -192,6 +192,20 @@ pub(crate) async fn prepare( } _ => None, }; + let request_context_message = match event_id.as_deref() { + Some(event_id) if !compacting && !background_completion => { + let message_id = format!("request-context:{event_id}"); + match store.message(&conversation_id, &message_id).await? { + Some(message) => Some(message), + None => runtime::compile_request_context( + event_id, + &request_context, + base_messages.as_deref().unwrap_or_default(), + )?, + } + } + _ => None, + }; let initial_messages = if compacting { Vec::new() } else { @@ -209,21 +223,27 @@ pub(crate) async fn prepare( turn_user = Some(user); vec![message] } - (Some(user), Some(event_id)) => match existing_runtime { - Some(message) => vec![message], - None => vec![ - runtime::compile( - event_id, - checkpoint_mode, - &user, - &request_context, - &action_context, - compiler, - blob_sync, - ) - .await?, - ], - }, + (Some(user), Some(event_id)) => { + let runtime = match existing_runtime { + Some(message) => message, + None => { + runtime::compile( + event_id, + checkpoint_mode, + &user, + &request_context, + &action_context, + compiler, + blob_sync, + ) + .await? + } + }; + request_context_message + .into_iter() + .chain(std::iter::once(runtime)) + .collect() + } (None, None) => Vec::new(), _ => { return Err(Error::Protocol( diff --git a/server/src/cursor/request/runtime.rs b/server/src/cursor/request/runtime.rs index 4dccce9..b8ea14a 100644 --- a/server/src/cursor/request/runtime.rs +++ b/server/src/cursor/request/runtime.rs @@ -87,10 +87,6 @@ pub async fn compile( .map(|env| env.time_zone.as_str()), )?; let mut values = BTreeMap::from([ - ( - "REQUEST_CONTEXT", - section(context::compile_context(request_context, &time.today)), - ), ("OPEN_FILES", section(open_files(user))), ( "SELECTED_CONTEXT", @@ -122,6 +118,41 @@ pub async fn compile( .await } +pub(super) fn compile_request_context( + event_id: &str, + request_context: &pb::RequestContext, + history: &[CanonicalMessage], +) -> Result> { + let time = Time::now( + request_context + .env + .as_ref() + .map(|env| env.time_zone.as_str()), + )?; + let text = context::compile_context(request_context, &time.today); + if text.is_empty() { + return Ok(None); + } + let message = CanonicalMessage::text( + format!("request-context:{event_id}"), + Role::User, + Origin::Prompt, + text, + ); + Ok(should_project_request_context(history, &message).then_some(message)) +} + +fn should_project_request_context( + history: &[CanonicalMessage], + current: &CanonicalMessage, +) -> bool { + history + .iter() + .rev() + .find(|message| message.message_id.starts_with("request-context:")) + .is_none_or(|previous| previous.content != current.content) +} + pub async fn compile_background( event_id: String, user: &pb::UserMessage, @@ -252,3 +283,55 @@ impl Time { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_context_is_only_projected_when_its_content_changes() { + let first = CanonicalMessage::text( + "request-context:first", + Role::User, + Origin::Prompt, + "same", + ); + let duplicate = CanonicalMessage::text( + "request-context:second", + Role::User, + Origin::Prompt, + "same", + ); + let changed = CanonicalMessage::text( + "request-context:third", + Role::User, + Origin::Prompt, + "changed", + ); + let runtime = CanonicalMessage::text( + "runtime:turn", + Role::User, + Origin::Runtime, + "next", + ); + + assert!(should_project_request_context(&[], &first)); + assert!(!should_project_request_context( + &[first.clone(), runtime.clone()], + &duplicate + )); + assert!(should_project_request_context( + &[first.clone(), runtime.clone()], + &changed + )); + assert!(should_project_request_context( + &[first.clone(), changed, runtime], + &CanonicalMessage::text( + "request-context:fourth", + Role::User, + Origin::Prompt, + "same", + ) + )); + } +} diff --git a/server/src/run/engine.rs b/server/src/run/engine.rs index 8e0ee54..c98de86 100644 --- a/server/src/run/engine.rs +++ b/server/src/run/engine.rs @@ -460,11 +460,8 @@ impl RunEngine { .iter() .map(|message| message.message_id.as_str()) .collect::>(); - let compactable = messages - .iter() - .filter(|message| !current_ids.contains(message.message_id.as_str())) - .cloned() - .collect::>(); + let (compactable, retained_request_context) = + auto_compaction_partition(messages, ¤t_ids); if compactable.is_empty() { return Ok((revision, None)); } @@ -533,7 +530,8 @@ impl RunEngine { }, runtime_event_id: Some(event_id), }; - let mut replacement = vec![summary_message]; + let mut replacement = retained_request_context.into_iter().collect::>(); + replacement.push(summary_message); replacement.extend(prepared.initial_messages.iter().cloned()); let revision = self .store @@ -565,6 +563,29 @@ impl RunEngine { } } +fn auto_compaction_partition( + messages: &[CanonicalMessage], + current_ids: &HashSet<&str>, +) -> (Vec, Option) { + let latest_request_context = messages + .iter() + .rposition(|message| message.message_id.starts_with("request-context:")); + let compactable = messages + .iter() + .enumerate() + .filter(|(index, message)| { + Some(*index) != latest_request_context + && !current_ids.contains(message.message_id.as_str()) + }) + .map(|(_, message)| message.clone()) + .collect(); + let retained = latest_request_context + .and_then(|index| messages.get(index)) + .filter(|message| !current_ids.contains(message.message_id.as_str())) + .cloned(); + (compactable, retained) +} + fn should_auto_compact(prepared: &PreparedRun, messages: &[CanonicalMessage]) -> bool { if prepared.action != RunAction::Start { return false; @@ -724,7 +745,7 @@ fn failure_message(failure: &RunFailure) -> String { #[cfg(test)] mod tests { - use super::{estimate_context_tokens, hydrate_tool_images}; + use super::{auto_compaction_partition, estimate_context_tokens, hydrate_tool_images}; use crate::{ model::{ CanonicalMessage, ContentPart, Origin, ProjectedContent, ProjectedMessage, PromptSpec, @@ -732,6 +753,7 @@ mod tests { }, store::Store, }; + use std::collections::HashSet; #[test] fn context_estimate_grows_with_prompt_history() { @@ -756,6 +778,42 @@ mod tests { assert!(estimate_context_tokens(&prompt, &long) > estimate_context_tokens(&prompt, &short)); } + #[test] + fn auto_compaction_preserves_only_the_latest_request_context() { + let first_context = CanonicalMessage::text( + "request-context:first", + Role::User, + Origin::Prompt, + "old rules", + ); + let old_runtime = + CanonicalMessage::text("runtime:first", Role::User, Origin::Runtime, "old query"); + let latest_context = CanonicalMessage::text( + "request-context:second", + Role::User, + Origin::Prompt, + "new rules", + ); + let current_runtime = CanonicalMessage::text( + "runtime:current", + Role::User, + Origin::Runtime, + "current query", + ); + let messages = vec![ + first_context.clone(), + old_runtime.clone(), + latest_context.clone(), + current_runtime, + ]; + let current_ids = HashSet::from(["runtime:current"]); + + let (compactable, retained) = auto_compaction_partition(&messages, ¤t_ids); + + assert_eq!(compactable, vec![first_context, old_runtime]); + assert_eq!(retained, Some(latest_context)); + } + #[tokio::test] async fn read_image_is_loaded_only_for_the_provider_projection() { let directory = tempfile::tempdir().unwrap(); diff --git a/server/tests/prefix_stability.rs b/server/tests/prefix_stability.rs index 0a7d258..da3d7e4 100644 --- a/server/tests/prefix_stability.rs +++ b/server/tests/prefix_stability.rs @@ -323,7 +323,6 @@ fn every_captured_mode_owns_and_renders_its_runtime_template() { .unwrap(), ); let values = BTreeMap::from([ - ("REQUEST_CONTEXT", String::new()), ("OPEN_FILES", String::new()), ("SELECTED_CONTEXT", String::new()), ("ACTION_CONTEXT", String::new()), diff --git a/server/tests/runtime_modes.rs b/server/tests/runtime_modes.rs index e2b88c3..6f61545 100644 --- a/server/tests/runtime_modes.rs +++ b/server/tests/runtime_modes.rs @@ -19,9 +19,9 @@ use cursor_server::{ use prost::Message; #[tokio::test] -async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message() { +async fn unchanged_request_context_is_not_repeated_and_preserves_the_provider_prefix() { let (_directory, store) = fixtures::temp_store().await; - let references = references(&store).await; + let first_references = references(&store).await; let provider = fake_provider::FakeProvider::default(); provider.push(vec![ ModelEvent::Start { @@ -32,6 +32,15 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message ModelEvent::TextEnd, ModelEvent::Done(FinishReason::Stop), ]); + provider.push(vec![ + ModelEvent::Start { + model_call_id: "model-2".into(), + }, + ModelEvent::TextStart, + ModelEvent::TextDelta("answer again".into()), + ModelEvent::TextEnd, + ModelEvent::Done(FinishReason::Stop), + ]); let assets = PromptAssets::load( std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("prompt/cursor") @@ -39,7 +48,7 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message ) .unwrap(); let registry = CursorSessionRegistry::new( - store, + store.clone(), Arc::new(provider.clone()), PromptCompiler::new(assets), Default::default(), @@ -49,12 +58,13 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message handle .command(CursorCommand::Append { seqno: 0, - message: Box::new(run_request(references)), + message: Box::new(run_request(first_references)), }) .await .unwrap(); let mut seqno = 1; + let mut checkpoint = None; loop { let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv()) .await @@ -65,15 +75,21 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message break; } let message = pb::AgentServerMessage::decode(payload).unwrap(); - if let Some(pb::agent_server_message::Message::KvServerMessage(kv)) = message.message { - handle - .command(CursorCommand::Append { - seqno, - message: Box::new(kv_ack(kv.id)), - }) - .await - .unwrap(); - seqno += 1; + match message.message { + Some(pb::agent_server_message::Message::KvServerMessage(kv)) => { + handle + .command(CursorCommand::Append { + seqno, + message: Box::new(kv_ack(kv.id)), + }) + .await + .unwrap(); + seqno += 1; + } + Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(state)) => { + checkpoint = Some(state); + } + _ => {} } } @@ -89,12 +105,23 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message .tools .iter() .any(|tool| tool.name == "GenerateImage")); - assert_eq!(request.history.len(), 1); + assert_eq!(request.history.len(), 2); + assert!(request.history[0] + .message_id + .starts_with("request-context:")); + let ProjectedContent::Parts(context_parts) = &request.history[0].content else { + panic!("request context message must use typed parts") + }; + let [ContentPart::Text { text: context_text }] = context_parts.as_slice() else { + panic!("request context message must contain one text part") + }; assert_eq!( - request.history[0].message_id, + request.history[1].message_id, "runtime:run-request:ask-request" ); - let ProjectedContent::Parts(parts) = &request.history[0].content else { + assert!(!request.prompt.instructions.contains("workspace rule")); + assert!(!request.prompt.instructions.contains("")); + let ProjectedContent::Parts(parts) = &request.history[1].content else { panic!("runtime message must use typed parts") }; let [ContentPart::Text { text }] = parts.as_slice() else { @@ -109,6 +136,15 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message "/tmp/mcp-test/lookup.json", "{"properties":{"query":{"type":"string"}},"type":"object"}", "Call a listed tool directly with CallMcpTool without calling GetMcpTools first.", + ] { + assert!( + context_text.contains(expected), + "missing request context section: {expected}" + ); + } + assert!(!context_text.contains("complete skill body")); + assert!(!context_text.contains("complete MCP server instructions")); + for expected in [ "Ask mode is active.", "\nexplain this\n", ] { @@ -117,9 +153,65 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message "missing runtime section: {expected}" ); } - assert!(!text.contains("complete skill body")); - assert!(!text.contains("complete MCP server instructions")); + assert!(!text.contains("")); + assert!(!text.contains("")); assert!(text.contains("/workspace/src/main.rs")); + + let second = registry.get_or_create("ask-request-2").await.unwrap(); + let mut second_output = second.subscribe(); + second + .command(CursorCommand::Append { + seqno: 0, + message: Box::new(run_request_with_state( + references(&store).await, + checkpoint.expect("first Run must publish a checkpoint"), + )), + }) + .await + .unwrap(); + let mut second_seqno = 1; + loop { + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), second_output.recv()) + .await + .unwrap() + .unwrap(); + let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); + if flags & connect::END_STREAM_FLAG != 0 { + break; + } + let message = pb::AgentServerMessage::decode(payload).unwrap(); + if let Some(pb::agent_server_message::Message::KvServerMessage(kv)) = message.message { + second + .command(CursorCommand::Append { + seqno: second_seqno, + message: Box::new(kv_ack(kv.id)), + }) + .await + .unwrap(); + second_seqno += 1; + } + } + + let requests = provider.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[1].prompt.instructions, requests[0].prompt.instructions, + "unchanged request context must not rewrite the system prompt" + ); + assert_eq!( + requests[1].history[..requests[0].history.len()], + requests[0].history, + "the previous provider history must remain an exact prefix" + ); + assert_eq!( + requests[1] + .history + .iter() + .filter(|message| message.message_id.starts_with("request-context:")) + .count(), + 1, + "identical request context must not be appended again" + ); } #[tokio::test] @@ -252,10 +344,10 @@ async fn missing_context_parts_use_current_cursor_response_and_cache_its_content let requests = provider.requests(); assert_eq!(requests.len(), 1); let ProjectedContent::Parts(parts) = &requests[0].history[0].content else { - panic!("runtime message must use typed parts") + panic!("request context message must use typed parts") }; let [ContentPart::Text { text }] = parts.as_slice() else { - panic!("this fixture has no images") + panic!("request context message must contain one text part") }; assert!(text.contains("")); assert!(text.contains("")); @@ -449,6 +541,31 @@ fn run_request(references: References) -> pb::AgentClientMessage { } } +fn run_request_with_state( + references: References, + state: pb::ConversationStateStructure, +) -> pb::AgentClientMessage { + let mut message = run_request(references); + let Some(pb::agent_client_message::Message::RunRequest(request)) = message.message.as_mut() + else { + unreachable!("run_request always returns a RunRequest") + }; + request.conversation_state = Some(state); + let Some(pb::conversation_action::Action::UserMessageAction(action)) = request + .action + .as_mut() + .and_then(|action| action.action.as_mut()) + else { + unreachable!("run_request always contains a UserMessageAction") + }; + action + .user_message + .as_mut() + .expect("run_request always contains a UserMessage") + .message_id = "wire-user-2".into(); + message +} + fn kv_ack(id: u32) -> pb::AgentClientMessage { pb::AgentClientMessage { message: Some(pb::agent_client_message::Message::KvClientMessage( diff --git a/server/tests/text_turn.rs b/server/tests/text_turn.rs index 3d1e7bb..0ee3553 100644 --- a/server/tests/text_turn.rs +++ b/server/tests/text_turn.rs @@ -259,18 +259,27 @@ async fn text_turn_runs_from_bidi_request_through_checkpoint_and_end_stream() { .contains("powered by Test Model")); let projected = &requests[0].history; assert_eq!(projected[0].role, Role::User); - let ProjectedContent::Parts(runtime) = &projected[0].content else { - panic!("runtime context must be text") + assert!(projected[0].message_id.starts_with("request-context:")); + let ProjectedContent::Parts(context) = &projected[0].content else { + panic!("request context must be text") + }; + assert!(matches!( + context.as_slice(), + [cursor_server::model::ContentPart::Text { text }] + if text.contains("") + )); + let ProjectedContent::Parts(runtime) = &projected[1].content else { + panic!("runtime user message must be text") }; assert!(matches!( runtime.as_slice(), [cursor_server::model::ContentPart::Text { text }] - if text.contains("") - && text.contains("\nhello\n") + if text.contains("\nhello\n") + && !text.contains("") )); assert_eq!( projected.len(), - 1, + 2, "the raw UserMessage is not projected twice" ); @@ -278,9 +287,15 @@ async fn text_turn_runs_from_bidi_request_through_checkpoint_and_end_stream() { .load_current_messages(&cursor_server::model::ConversationId::new("conversation")) .await .unwrap(); - assert_eq!(messages[0].message_id, "runtime:run-request:request"); + assert!(messages[0].message_id.starts_with("request-context:")); assert_eq!(messages[0].role, Role::User); - assert_eq!(messages.len(), 2, "runtime user plus final assistant"); + assert_eq!(messages[1].message_id, "runtime:run-request:request"); + assert_eq!(messages[1].role, Role::User); + assert_eq!( + messages.len(), + 3, + "request context plus runtime user and final assistant" + ); let stored_runs: Vec = sqlx::query_scalar("SELECT run_id FROM runs ORDER BY run_id") .fetch_all(store.pool()) .await