Compare commits

...
Author SHA1 Message Date
leookun 8a4fc5076a fix token usage average 2026-08-24 03:49:57 +08:00
leookun b5199de6f1 Merge branch 'refactor/0.1.0-beta' 2026-08-24 03:46:56 +08:00
leookun b74291af79 stabilize cursor request context and release workflow 2026-08-24 03:44:56 +08:00
leookun 2075b61763 release desktop beta 0.1.0-beta.1 2026-08-24 03:22:18 +08:00
leokun 4c2efb05c2 Merge pull request #304 from leookun/refactor/0.1.0-beta
Refactor/0.1.0-beta
2026-08-24 02:54:36 +08:00
29 changed files with 54421 additions and 86 deletions
@@ -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.
+77
View File
@@ -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<version>` 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<version>` 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.
@@ -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."
+31 -8
View File
@@ -2,11 +2,8 @@ name: Release desktop app
on:
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
@@ -24,6 +21,28 @@ 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:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
shell: bash
run: |
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY}" ]]; then
echo "TAURI_SIGNING_PRIVATE_KEY is not configured" >&2
exit 1
fi
- name: Read and verify app version
id: version
@@ -34,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}"
@@ -68,7 +91,7 @@ jobs:
target: ""
- platform: windows-x86_64
os: windows-latest
args: ""
args: "--bundles nsis"
target: ""
- platform: macos-aarch64
os: macos-15
@@ -120,10 +143,10 @@ jobs:
projectPath: apps/desktop
tagName: v__VERSION__
releaseName: Cursor BYOK v__VERSION__
releaseBody: 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
+3 -1
View File
@@ -30,4 +30,6 @@ server-go/log/
.cursor-app-formatted/
proto/extensions-cursor-app/
cursor-tab-server/cursor-tab-server-linux-amd64.tar
/scripts/cursor-proto/proto/
/scripts/cursor-proto/proto/*
!/scripts/cursor-proto/proto/agent_v1.proto
!/scripts/cursor-proto/proto/aiserver_v1.proto
Generated
+1 -1
View File
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.0"
version = "0.1.0-beta.1"
dependencies = [
"axum",
"cursor-server",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.0",
"version": "0.1.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.0",
"version": "0.1.0-beta.1",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.0",
"version": "0.1.0-beta.1",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.0"
version = "0.1.0-beta.1"
edition = "2021"
publish = false
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.0",
"version": "0.1.0-beta.1",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -77,9 +77,10 @@ export function DailyTokenUsageChart({
const maximumTotal = data.reduce((maximum, day) => Math.max(maximum, totalTokens(day)), 0);
const axisMaximum = Math.max(1, maximumTotal / DATA_HEIGHT_RATIO);
const emptyBarHeight = axisMaximum * EMPTY_BAR_RATIO;
const averageLevel = data.length === 0
const nonZeroTotals = data.map(totalTokens).filter((total) => total !== 0);
const averageLevel = nonZeroTotals.length === 0
? 0
: data.reduce((sum, day) => sum + totalTokens(day), 0) / data.length;
: nonZeroTotals.reduce((sum, total) => sum + total, 0) / nonZeroTotals.length;
const option = useMemo<EChartsCoreOption>(() => ({
animationDuration: 450,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are now in Agent mode. You have EXITED your previous mode. Continue with the task in the new mode.
</system_reminder>
<system_reminder>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are now in Ask mode. You have EXITED your previous mode. Continue with the task in the new mode.
</system_reminder>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<timestamp>{{TIMESTAMP}}</timestamp>
{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<timestamp>{{TIMESTAMP}}</timestamp>
<user_query>
{{USER_QUERY}}
</user_query>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are now in Debug mode. You have EXITED your previous mode. Continue with the task in the new mode.
</system_reminder>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are now in Multitask mode. You have EXITED your previous mode. Continue with the task in the new mode.
</system_reminder>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are now in Plan mode. You have EXITED your previous mode. Continue with the task in the new mode.
</system_reminder>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
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.
</system_reminder>
<timestamp>{{TIMESTAMP}}</timestamp>
+5 -7
View File
@@ -30,17 +30,15 @@ pub fn decode(data: &[u8], internal_id: String) -> Result<CanonicalMessage> {
.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<CanonicalMessage> {
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
+17
View File
@@ -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": "<rules>current rules</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!({
-1
View File
@@ -134,7 +134,6 @@ impl PromptAssets {
}
const RUNTIME_VARIABLES: &[&str] = &[
"REQUEST_CONTEXT",
"OPEN_FILES",
"SELECTED_CONTEXT",
"ACTION_CONTEXT",
+35 -15
View File
@@ -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(
+87 -4
View File
@@ -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<Option<CanonicalMessage>> {
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,
"<rules>same</rules>",
);
let duplicate = CanonicalMessage::text(
"request-context:second",
Role::User,
Origin::Prompt,
"<rules>same</rules>",
);
let changed = CanonicalMessage::text(
"request-context:third",
Role::User,
Origin::Prompt,
"<rules>changed</rules>",
);
let runtime = CanonicalMessage::text(
"runtime:turn",
Role::User,
Origin::Runtime,
"<user_query>next</user_query>",
);
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,
"<rules>same</rules>",
)
));
}
}
+65 -7
View File
@@ -460,11 +460,8 @@ impl RunEngine {
.iter()
.map(|message| message.message_id.as_str())
.collect::<HashSet<_>>();
let compactable = messages
.iter()
.filter(|message| !current_ids.contains(message.message_id.as_str()))
.cloned()
.collect::<Vec<_>>();
let (compactable, retained_request_context) =
auto_compaction_partition(messages, &current_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::<Vec<_>>();
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<CanonicalMessage>, Option<CanonicalMessage>) {
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, &current_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();
-1
View File
@@ -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()),
+137 -20
View File
@@ -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("<mcp_meta_tools>"));
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
"<definition_path>/tmp/mcp-test/lookup.json</definition_path>",
"<input_schema>{&quot;properties&quot;:{&quot;query&quot;:{&quot;type&quot;:&quot;string&quot;}},&quot;type&quot;:&quot;object&quot;}</input_schema>",
"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.",
"<user_query>\nexplain this\n</user_query>",
] {
@@ -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("<rules>"));
assert!(!text.contains("<mcp_meta_tools>"));
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("<mcp_meta_tool_server name=\"live-mcp\" identifier=\"live-mcp\">"));
assert!(text.contains("<mcp_tool name=\"current-tool\">"));
@@ -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(
+22 -7
View File
@@ -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("<user_info>")
));
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("<user_info>")
&& text.contains("<user_query>\nhello\n</user_query>")
if text.contains("<user_query>\nhello\n</user_query>")
&& !text.contains("<user_info>")
));
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<String> = sqlx::query_scalar("SELECT run_id FROM runs ORDER BY run_id")
.fetch_all(store.pool())
.await