Compare commits

...
60 changed files with 55409 additions and 243 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.3"
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.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.0",
"version": "0.1.0-beta.3",
"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.3",
"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.3"
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.3",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+4 -6
View File
@@ -10,8 +10,8 @@ import { CursorSettingsPage } from "./pages/CursorSettingsPage";
import { HomePage } from "./pages/HomePage";
import { ProvidersPage } from "./pages/ProvidersPage";
import { SettingsPage } from "./pages/SettingsPage";
import { checkForUpdate, hasNativeAppLifecycle } from "./native/appLifecycle";
import { useAppStore } from "./store/appStore";
import { updateStore } from "./store/updateStore";
export function App() {
return (
@@ -47,11 +47,9 @@ function AppMessages() {
}, [error, showMessage]);
useEffect(() => {
if (!hasNativeAppLifecycle()) return;
void checkForUpdate().then(async (update) => {
if (!update) return;
showMessage(t("发现新版本 {version},可在设置中安装", { version: update.version }), { duration: 6_000 });
await update.close();
void updateStore.check().then((version) => {
if (!version) return;
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
}).catch(() => {
// Startup checks are best-effort; manual checks in Settings report errors.
});
+9
View File
@@ -97,6 +97,13 @@ export interface ProxySettingsInput {
password?: string;
}
export type TabMode = "public" | "direct" | "custom";
export interface TabSettings {
mode: TabMode;
address: string;
}
export interface OverviewMetrics {
llm_calls: number;
successful_calls: number;
@@ -283,4 +290,6 @@ export const api = {
clearStatisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE" }),
proxySettings: () => request<ProxySettings>("/settings/proxy"),
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
tabSettings: () => request<TabSettings>("/settings/tab"),
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
};
@@ -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,
@@ -1,5 +1,10 @@
@use "../../styles/typography" as type;
.page {
display: grid;
gap: 16px;
}
.gate {
min-height: 250px;
display: flex;
@@ -0,0 +1,51 @@
@use "../../styles/typography" as type;
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
}
.row {
min-height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 12px 16px;
& + & {
border-top: 1px solid var(--vscode-sideBar-border);
}
}
.description {
min-width: 0;
display: grid;
gap: 5px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.selectControl,
.addressInput {
width: min(300px, 44%);
flex: 0 0 auto;
}
@media (max-width: 620px) {
.row {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.selectControl,
.addressInput {
width: 100%;
}
}
@@ -0,0 +1,53 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({ settings, saving, onChange, onSave }: {
settings: TabSettings;
saving: boolean;
onChange: (settings: TabSettings) => void;
onSave: () => void;
}) {
return <TitledCard
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
action={<Button size="small" variant="primary" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>}
>
<div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 选择")}</strong>
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
</div>
<div className={styles.selectControl}>
<Select
value={settings.mode}
ariaLabel={t("TAB 选择")}
options={[
{ value: "public", label: t("使用公益服务") },
{ value: "direct", label: t("直连") },
{ value: "custom", label: t("自定义") },
]}
onChange={(mode) => onChange({ ...settings, mode: mode as TabMode })}
/>
</div>
</div>
{settings.mode === "custom" && <div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 服务地址")}</strong>
<small>{t("原接口路径会追加到此服务地址。")}</small>
</div>
<TextInput
className={styles.addressInput}
value={settings.address}
placeholder="https://tab.leokun.cn"
aria-label={t("TAB 服务地址")}
onChange={(event) => onChange({ ...settings, address: event.target.value })}
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
/>
</div>}
</TitledCard>;
}
@@ -21,3 +21,11 @@
font-size: type.$font-size-xs;
}
}
.updateDot {
width: 7px;
height: 7px;
flex: 0 0 7px;
background: #ef4444;
border-radius: 50%;
}
@@ -1,13 +1,11 @@
import { useEffect, useRef, useState } from "react";
import type { Update } from "@tauri-apps/plugin-updater";
import { useEffect, useState } from "react";
import {
checkForUpdate,
currentAppVersion,
hasNativeAppLifecycle,
installUpdate,
readAutostart,
writeAutostart,
} from "../../native/appLifecycle";
import { updateStore, useUpdateStore } from "../../store/updateStore";
import { Button } from "../ui/Button";
import { Switch } from "../ui/Switch";
import { TitledCard } from "../ui/TitledCard";
@@ -17,13 +15,10 @@ import styles from "./AppLifecycleSettingsCard.module.scss";
export function AppLifecycleSettingsCard() {
const message = useMessage();
const native = hasNativeAppLifecycle();
const updateRef = useRef<Update | null>(null);
const { availableVersion, checking, installing } = useUpdateStore();
const [version, setVersion] = useState("…");
const [autostart, setAutostart] = useState(false);
const [loadingAutostart, setLoadingAutostart] = useState(native);
const [checking, setChecking] = useState(false);
const [installing, setInstalling] = useState(false);
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
useEffect(() => {
let disposed = false;
@@ -34,12 +29,7 @@ export function AppLifecycleSettingsCard() {
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
.finally(() => { if (!disposed) setLoadingAutostart(false); });
}
return () => {
disposed = true;
const update = updateRef.current;
updateRef.current = null;
if (update) void update.close();
};
return () => { disposed = true; };
}, [message, native]);
const toggleAutostart = async (enabled: boolean) => {
@@ -57,29 +47,17 @@ export function AppLifecycleSettingsCard() {
const checkUpdate = async () => {
try {
setChecking(true);
const previous = updateRef.current;
updateRef.current = null;
if (previous) await previous.close();
const update = await checkForUpdate();
updateRef.current = update;
setAvailableVersion(update?.version ?? null);
message(update ? t("发现新版本 {version}", { version: update.version }) : t("当前已是最新版本"));
const nextVersion = await updateStore.check();
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setChecking(false);
}
};
const updateNow = async () => {
const update = updateRef.current;
if (!update) return;
try {
setInstalling(true);
await installUpdate(update);
await updateStore.install();
} catch (cause) {
setInstalling(false);
message(cause instanceof Error ? cause.message : String(cause));
}
};
@@ -107,6 +85,7 @@ export function AppLifecycleSettingsCard() {
{availableVersion
? <Button size="small" variant="primary" disabled={installing} onClick={() => void updateNow()}>
{installing ? t("安装中…") : t("下载并安装")}
<span className={styles.updateDot} aria-hidden="true" />
</Button>
: <Button size="small" disabled={!native || checking} onClick={() => void checkUpdate()}>
{checking ? t("检查中…") : t("检查更新")}
+224 -89
View File
@@ -175,7 +175,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 156,
"line": 180,
"column": 89
}
]
@@ -278,12 +278,12 @@
"refs": [
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 108,
"line": 109,
"column": 47
},
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 172,
"line": 173,
"column": 15
}
]
@@ -353,7 +353,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 155,
"line": 179,
"column": 36
},
{
@@ -440,17 +440,17 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 120,
"line": 144,
"column": 40
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 120,
"line": 144,
"column": 119
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 123,
"line": 147,
"column": 63
}
]
@@ -515,7 +515,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 79,
"line": 87,
"column": 86
}
]
@@ -527,7 +527,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 109,
"line": 87,
"column": 27
}
]
@@ -573,17 +573,17 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 98,
"line": 106,
"column": 32
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 98,
"line": 106,
"column": 119
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 133,
"line": 157,
"column": 44
}
]
@@ -740,8 +740,8 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 67,
"column": 24
"line": 51,
"column": 29
}
]
},
@@ -754,7 +754,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 105,
"line": 83,
"column": 13
}
]
@@ -766,7 +766,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 216,
"line": 223,
"column": 14
}
]
@@ -805,12 +805,12 @@
},
{
"file": "layouts/AppLayout.tsx",
"line": 217,
"line": 224,
"column": 19
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 133,
"line": 157,
"column": 67
},
{
@@ -877,7 +877,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 141,
"line": 165,
"column": 70
},
{
@@ -887,6 +887,18 @@
}
]
},
"2f4a9609285d8f49": {
"source": "TAB 设置已保存",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 128,
"column": 15
}
]
},
"2f5f1d6fbfb061ed": {
"source": "未设置",
"kind": "text",
@@ -932,7 +944,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 133,
"line": 157,
"column": 89
}
]
@@ -944,7 +956,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 218,
"line": 225,
"column": 20
}
]
@@ -1024,12 +1036,12 @@
"refs": [
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 106,
"line": 107,
"column": 50
},
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 154,
"line": 155,
"column": 15
}
]
@@ -1137,6 +1149,18 @@
}
]
},
"3f6c25aa329163a4": {
"source": "原接口路径会追加到此服务地址。",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 41,
"column": 17
}
]
},
"3fd47edce45b3603": {
"source": "关闭",
"kind": "text",
@@ -1144,7 +1168,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 126,
"line": 150,
"column": 71
}
]
@@ -1168,12 +1192,12 @@
"refs": [
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 105,
"line": 106,
"column": 46
},
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 145,
"line": 146,
"column": 15
}
]
@@ -1219,18 +1243,6 @@
}
]
},
"4510b63fdcf7451f": {
"source": "Cursor 助手 v0.1.0",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppHeader.tsx",
"line": 20,
"column": 18
}
]
},
"470049252e54de6a": {
"source": "成功占比:{rate}",
"kind": "template",
@@ -1280,7 +1292,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 104,
"line": 82,
"column": 13
}
]
@@ -1304,7 +1316,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 71,
"line": 73,
"column": 48
}
]
@@ -1338,8 +1350,8 @@
},
{
"file": "layouts/AppLayout.tsx",
"line": 197,
"column": 84
"line": 196,
"column": 67
}
]
},
@@ -1348,6 +1360,11 @@
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 32,
"column": 39
},
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 89,
@@ -1389,6 +1406,18 @@
}
]
},
"5401344227e49e2f": {
"source": "TAB 设置",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 17,
"column": 90
}
]
},
"54644705e9c61009": {
"source": "端口设置",
"kind": "text",
@@ -1449,11 +1478,40 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 120,
"line": 144,
"column": 52
}
]
},
"59346e82b3dd2998": {
"source": "TAB 服务地址",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 40,
"column": 18
},
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 47,
"column": 21
}
]
},
"5a3bd99fa69a40c1": {
"source": "使用公益服务",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 30,
"column": 39
}
]
},
"5ae715656ffbc35d": {
"source": "合并到该上游所有模型的请求体。",
"kind": "text",
@@ -1483,6 +1541,20 @@
}
]
},
"5d59857bf039cac9": {
"source": "Cursor 助手 v{version}",
"kind": "template",
"placeholders": [
"version"
],
"refs": [
{
"file": "layouts/AppHeader.tsx",
"line": 31,
"column": 18
}
]
},
"5f8d556a9c47da3c": {
"source": "已关闭开机启动",
"kind": "text",
@@ -1490,7 +1562,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 50,
"line": 40,
"column": 40
}
]
@@ -1502,7 +1574,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 222,
"line": 229,
"column": 11
}
]
@@ -1528,12 +1600,12 @@
"refs": [
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 107,
"line": 108,
"column": 51
},
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 163,
"line": 164,
"column": 15
}
]
@@ -1614,7 +1686,7 @@
"refs": [
{
"file": "api.ts",
"line": 265,
"line": 272,
"column": 43
}
]
@@ -1626,7 +1698,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 102,
"line": 80,
"column": 18
}
]
@@ -1705,7 +1777,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 74,
"line": 76,
"column": 47
}
]
@@ -1768,8 +1840,8 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 67,
"column": 76
"line": 51,
"column": 78
}
]
},
@@ -1876,7 +1948,7 @@
"refs": [
{
"file": "api.ts",
"line": 260,
"line": 267,
"column": 43
}
]
@@ -1914,11 +1986,23 @@
"refs": [
{
"file": "App.tsx",
"line": 53,
"line": 52,
"column": 19
}
]
},
"7e0891860c9e6374": {
"source": "TAB 服务地址不能为空",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 125,
"column": 83
}
]
},
"7e1845870b528392": {
"source": "启用模型",
"kind": "text",
@@ -1979,7 +2063,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 112,
"line": 91,
"column": 37
}
]
@@ -2003,7 +2087,7 @@
"refs": [
{
"file": "api.ts",
"line": 214,
"line": 221,
"column": 21
}
]
@@ -2032,12 +2116,12 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 90,
"line": 68,
"column": 18
},
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 96,
"line": 74,
"column": 16
}
]
@@ -2049,7 +2133,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 150,
"line": 174,
"column": 21
}
]
@@ -2158,11 +2242,23 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 224,
"line": 231,
"column": 16
}
]
},
"8f9b0d6cc477d334": {
"source": "控制 Cursor TAB 相关接口的连接方式。",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 23,
"column": 17
}
]
},
"90800c48a1dd0655": {
"source": "{label} 必须是 JSON 对象",
"kind": "template",
@@ -2172,7 +2268,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 142,
"line": 166,
"column": 87
},
{
@@ -2201,7 +2297,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 69,
"line": 71,
"column": 39
}
]
@@ -2237,7 +2333,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 134,
"line": 158,
"column": 11
}
]
@@ -2278,6 +2374,23 @@
}
]
},
"966498853d801a52": {
"source": "TAB 选择",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 22,
"column": 18
},
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 28,
"column": 22
}
]
},
"9850ed41a5bfbb0c": {
"source": "已选 {count} 项",
"kind": "template",
@@ -2412,7 +2525,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 87,
"line": 65,
"column": 29
}
]
@@ -2434,6 +2547,11 @@
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 18,
"column": 109
},
{
"file": "components/settings/ProxySettingsCard.tsx",
"line": 33,
@@ -2482,7 +2600,7 @@
"refs": [
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 186,
"line": 187,
"column": 24
}
]
@@ -2618,7 +2736,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 50,
"line": 40,
"column": 25
}
]
@@ -2630,7 +2748,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 109,
"line": 87,
"column": 39
}
]
@@ -2680,16 +2798,28 @@
},
{
"file": "layouts/AppLayout.tsx",
"line": 237,
"line": 244,
"column": 64
},
{
"file": "layouts/AppLayout.tsx",
"line": 237,
"line": 244,
"column": 125
}
]
},
"b06325c5660f0c29": {
"source": "直连",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 31,
"column": 39
}
]
},
"b4411558b932266f": {
"source": "上游类型",
"kind": "text",
@@ -2788,7 +2918,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 126,
"line": 150,
"column": 93
}
]
@@ -2892,7 +3022,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 229,
"line": 236,
"column": 24
}
]
@@ -3000,17 +3130,17 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 97,
"line": 105,
"column": 32
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 97,
"line": 105,
"column": 95
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 123,
"line": 147,
"column": 51
}
]
@@ -3198,7 +3328,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 77,
"line": 85,
"column": 45
}
]
@@ -3210,7 +3340,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 91,
"line": 69,
"column": 17
}
]
@@ -3280,7 +3410,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 180,
"line": 182,
"column": 54
}
]
@@ -3292,7 +3422,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 128,
"line": 152,
"column": 18
}
]
@@ -3328,7 +3458,7 @@
"refs": [
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 104,
"line": 105,
"column": 14
}
]
@@ -3465,7 +3595,7 @@
"refs": [
{
"file": "components/charts/DailyTokenUsageChart.tsx",
"line": 134,
"line": 135,
"column": 15
}
]
@@ -3477,7 +3607,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 70,
"line": 72,
"column": 44
}
]
@@ -3542,7 +3672,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 78,
"line": 86,
"column": 72
}
]
@@ -3619,7 +3749,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 129,
"line": 153,
"column": 16
}
]
@@ -3648,7 +3778,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 126,
"line": 150,
"column": 45
}
]
@@ -3660,12 +3790,12 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 75,
"line": 77,
"column": 53
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 122,
"line": 146,
"column": 25
}
]
@@ -3701,7 +3831,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 54,
"line": 62,
"column": 187
},
{
@@ -3893,7 +4023,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 112,
"line": 91,
"column": 25
}
]
@@ -3985,7 +4115,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 72,
"line": 74,
"column": 48
}
]
@@ -3995,6 +4125,11 @@
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 18,
"column": 97
},
{
"file": "components/settings/ProxySettingsCard.tsx",
"line": 33,
+10 -1
View File
@@ -56,6 +56,7 @@
"2eb2bf7c6597ab9a": "Detailed records",
"2f1b67cdaa23351a": "Custom full request URL",
"2f4a361f878176d1": "{label} must be valid JSON",
"2f4a9609285d8f49": "TAB settings saved",
"2f5f1d6fbfb061ed": "Not set",
"2f6416a2c424856b": "Final request URL",
"2f7dec3be28d7597": "{count} selected",
@@ -75,13 +76,13 @@
"3cfae5728b92b334": "Token usage: {tokens}",
"3d13868593ae4eeb": "Display language",
"3da0bf1610ff5db5": "Recommended",
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
"3fd47edce45b3603": "Close",
"4125fc7ba333524c": "Default light",
"42655ed8e4108ae2": "Input (non-cached)",
"42a1d9e5b037c210": "Bytes",
"42aa8e01e98c0d8c": "Total duration",
"43cb41d62de2d179": "Proxy requires authentication",
"4510b63fdcf7451f": "Cursor Assistant v0.1.0",
"470049252e54de6a": "Success rate: {rate}",
"48b970b568a7f8f9": "Proxy settings",
"48d8db17bae06246": "{count} total",
@@ -93,13 +94,17 @@
"4eafa9e925b30bcd": "Custom",
"51d04bc3d286f018": "Last calendar day",
"5228358a6db59fe7": "For example: now, 2026-08-23 18:00",
"5401344227e49e2f": "TAB settings",
"54644705e9c61009": "Port settings",
"54c53e5fe791d1f3": "Initialize CA",
"550eddc3c7fefa99": "Sponsored",
"555737734a6371e6": "Delete provider",
"56432ba297009bdc": "Initialize the CA first",
"59346e82b3dd2998": "TAB service address",
"5a3bd99fa69a40c1": "Use public service",
"5ae715656ffbc35d": "Merge into the request body for every model from this provider.",
"5c55a67935af8f45": "All",
"5d59857bf039cac9": "Cursor Assistant v{version}",
"5f8d556a9c47da3c": "Launch at login disabled",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
"5fd2ec5a6e9b654c": "Total: {cost}",
@@ -132,6 +137,7 @@
"7a3cec4ca715de80": "Call statistics",
"7ba2d6728fe2531b": "Confirm clear",
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
"7e0891860c9e6374": "TAB service address is required",
"7e1845870b528392": "Enable model",
"7e1f06318e80c3af": "Statistics cleared",
"7e9ab9ada2cbf2cb": "Name and Base URL are required",
@@ -149,6 +155,7 @@
"8b6ff498515bcc2f": "Time",
"8d0c47eb9eac2d34": "Call type",
"8df48894086d6fbd": "Reason (optional)",
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
"90800c48a1dd0655": "{label} must be a JSON object",
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
"91aaf184cfc17ffd": "Overview",
@@ -158,6 +165,7 @@
"94803f35c825e47a": "Full request URL",
"94df1e7f04815daf": "Used only for display; does not change the model name sent to the provider.",
"95f76d30c25d5eda": "CA installation is not supported on this system",
"966498853d801a52": "TAB connection",
"9850ed41a5bfbb0c": "{count} selected",
"997ec8201c2adeda": "Open terminal to install CA",
"9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.",
@@ -186,6 +194,7 @@
"ad6a60ee93d3ba3e": "Loading call details…",
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
"aee88743413144a2": "Refresh",
"b06325c5660f0c29": "Direct",
"b4411558b932266f": "Provider type",
"b502b1d414664337": "Prompt: {tokens}",
"b5141d3d19e9a048": "Yes",
+10 -1
View File
@@ -56,6 +56,7 @@
"2eb2bf7c6597ab9a": "详细记录",
"2f1b67cdaa23351a": "自定义请求完整地址",
"2f4a361f878176d1": "{label} 必须是有效 JSON",
"2f4a9609285d8f49": "TAB 设置已保存",
"2f5f1d6fbfb061ed": "未设置",
"2f6416a2c424856b": "最终请求地址",
"2f7dec3be28d7597": "已选择 {count} 个",
@@ -75,13 +76,13 @@
"3cfae5728b92b334": "Token 用量:{tokens}",
"3d13868593ae4eeb": "界面语言",
"3da0bf1610ff5db5": "推荐内容",
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
"3fd47edce45b3603": "关闭",
"4125fc7ba333524c": "默认亮色",
"42655ed8e4108ae2": "输入(非缓存)",
"42a1d9e5b037c210": "字节数",
"42aa8e01e98c0d8c": "总耗时",
"43cb41d62de2d179": "代理需要认证",
"4510b63fdcf7451f": "Cursor 助手 v0.1.0",
"470049252e54de6a": "成功占比:{rate}",
"48b970b568a7f8f9": "代理设置",
"48d8db17bae06246": "共 {count} 条",
@@ -93,13 +94,17 @@
"4eafa9e925b30bcd": "自定义",
"51d04bc3d286f018": "近1自然日",
"5228358a6db59fe7": "如:现在、2026-08-23 18:00",
"5401344227e49e2f": "TAB 设置",
"54644705e9c61009": "端口设置",
"54c53e5fe791d1f3": "初始化 CA",
"550eddc3c7fefa99": "推广",
"555737734a6371e6": "删除上游",
"56432ba297009bdc": "请先初始化 CA",
"59346e82b3dd2998": "TAB 服务地址",
"5a3bd99fa69a40c1": "使用公益服务",
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
"5c55a67935af8f45": "全部",
"5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?",
"5fd2ec5a6e9b654c": "合计:{cost}",
@@ -132,6 +137,7 @@
"7a3cec4ca715de80": "调用统计",
"7ba2d6728fe2531b": "确认清理",
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
"7e0891860c9e6374": "TAB 服务地址不能为空",
"7e1845870b528392": "启用模型",
"7e1f06318e80c3af": "统计数据已清理",
"7e9ab9ada2cbf2cb": "名称和 Base URL 不能为空",
@@ -149,6 +155,7 @@
"8b6ff498515bcc2f": "时间",
"8d0c47eb9eac2d34": "调用类型",
"8df48894086d6fbd": "原因(可选)",
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
"919cb0ce0c8db4e7": "留空表示保留当前密码",
"91aaf184cfc17ffd": "数据概览",
@@ -158,6 +165,7 @@
"94803f35c825e47a": "请求完整地址",
"94df1e7f04815daf": "仅用于界面展示,不会改变发送给上游的模型名称。",
"95f76d30c25d5eda": "当前系统暂不支持安装 CA",
"966498853d801a52": "TAB 选择",
"9850ed41a5bfbb0c": "已选 {count} 项",
"997ec8201c2adeda": "打开终端安装 CA",
"9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。",
@@ -186,6 +194,7 @@
"ad6a60ee93d3ba3e": "正在加载调用详情…",
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
"aee88743413144a2": "刷新",
"b06325c5660f0c29": "直连",
"b4411558b932266f": "上游类型",
"b502b1d414664337": "提示词:{tokens}",
"b5141d3d19e9a048": "是",
+12 -1
View File
@@ -1,4 +1,6 @@
import { useEffect, useState } from "react";
import appIcon from "../../src-tauri/icons/32x32.png";
import { currentAppVersion } from "../native/appLifecycle";
import type { DesktopPlatform } from "./AppFrame";
import { WindowControls } from "./WindowControls";
import styles from "./AppHeader.module.scss";
@@ -10,6 +12,15 @@ type AppHeaderProps = {
export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
const showNativeUi = nativeDesktop && platform !== "macos";
const [version, setVersion] = useState("…");
useEffect(() => {
let disposed = false;
void currentAppVersion().then((next) => {
if (!disposed) setVersion(next);
});
return () => { disposed = true; };
}, []);
return <header className={styles.root}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
@@ -17,7 +28,7 @@ export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
{showNativeUi && <>
<div className={styles.identity} aria-label="Cursor BYOK">
<img src={appIcon} alt="" />
<span>{t("Cursor 助手 v0.1.0")}</span>
<span>{t("Cursor 助手 v{version}", { version })}</span>
</div>
<WindowControls />
</>}
@@ -83,7 +83,7 @@
}
}
.menuUnreadDot {
.menuIndicatorDot {
width: 7px;
height: 7px;
flex: 0 0 7px;
+4 -1
View File
@@ -18,6 +18,7 @@ import { useMessage } from "../components/ui/message";
import { VirtualList } from "../components/virtual/VirtualList";
import { useI18n } from "../i18n/store";
import { appStore, useAppStore } from "../store/appStore";
import { useUpdateStore } from "../store/updateStore";
import styles from "./AppLayout.module.scss";
import { PageActionsTarget } from "./PageActions";
@@ -43,6 +44,7 @@ function loadStoredAdIds(key: string): Set<string> {
export function AppLayout() {
const { busy } = useAppStore();
const { availableVersion } = useUpdateStore();
const { locale } = useI18n();
const message = useMessage();
const location = useLocation();
@@ -198,7 +200,7 @@ export function AppLayout() {
? <Icon src={item.icon} size="1.3em" />
: <Icon icon={item.icon} size="1.3em" />}
<span>{item.label}</span>
{!tutorialRead && <span className={styles.menuUnreadDot} aria-hidden="true" />}
{!tutorialRead && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
</button>
</div>
: <div className={styles.navigationRow} key={item.path}>
@@ -207,6 +209,7 @@ export function AppLayout() {
? <Icon src={item.icon} size="1.3em" />
: <Icon icon={item.icon} size="1.3em" />}
<span>{item.label}</span>
{item.path === "/settings" && availableVersion && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
</NavLink>
</div>}
</VirtualList>
+27 -3
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { api, type Model, type ProviderSelection } from "../api";
import { api, type Model, type ProviderSelection, type TabSettings } from "../api";
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
import { TabSettingsCard } from "../components/cursor/TabSettingsCard";
import styles from "../components/cursor/CursorSettings.module.scss";
import { PageContent } from "../components/layout/PageContent";
import controls from "../components/ui/Controls.module.scss";
@@ -24,12 +25,19 @@ export function CursorSettingsPage() {
const [caCommand, setCaCommand] = useState<string | null>(null);
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
const [deleting, setDeleting] = useState<Model | null>(null);
const [tabDraft, setTabDraft] = useState<TabSettings | null>(null);
const [savingTab, setSavingTab] = useState(false);
const grouped = useMemo(() => providers.map((provider) => ({ provider, models: models.filter((model) => model.provider_id === provider.provider_id) })).filter((group) => group.models.length > 0), [providers, models]);
const caReady = cursorHarness?.ca === "ready";
useEffect(() => {
if (!caCommand) return;
void api.copyCursorText(caCommand);
}, [caCommand]);
useEffect(() => {
void api.tabSettings()
.then(setTabDraft)
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]);
const initializeCa = async () => {
const status = await appStore.initializeCursorCa();
if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command);
@@ -111,15 +119,31 @@ export function CursorSettingsPage() {
setCaCommand(null);
setWaitingForCaRefresh(true);
};
const saveTab = async () => {
if (!tabDraft) return;
try {
if (tabDraft.mode === "custom" && !tabDraft.address.trim()) throw new Error(t("TAB 服务地址不能为空"));
setSavingTab(true);
setTabDraft(await api.setTabSettings(tabDraft));
message(t("TAB 设置已保存"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setSavingTab(false);
}
};
const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}>
<CursorModelProvider><CursorModelGate onAdd={openNew}>{list}</CursorModelGate></CursorModelProvider>
<div className={styles.page}>
{tabDraft && <TabSettingsCard settings={tabDraft} saving={savingTab} onChange={setTabDraft} onSave={() => void saveTab()} />}
<CursorModelProvider><CursorModelGate onAdd={openNew}>{list}</CursorModelGate></CursorModelProvider>
</div>
</CursorCaGate></CursorCaProvider>;
return <>
<PageActions>
<TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger>
</PageActions>
<PageContent title={t("Cursor 设置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(280, models.length * 55 + grouped.length * 62), content }]} />
<PageContent title={t("Cursor 设置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(430, models.length * 55 + grouped.length * 62 + 145), content }]} />
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} busy={cursorBusy} onClose={() => setDraft(null)} onSubmit={() => void save()}>
{draft && <CursorModelEditor draft={draft} providers={providers} editing={editing !== null} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />}
</Modal>
+74
View File
@@ -0,0 +1,74 @@
import { useSyncExternalStore } from "react";
import type { Update } from "@tauri-apps/plugin-updater";
import {
checkForUpdate,
hasNativeAppLifecycle,
installUpdate,
} from "../native/appLifecycle";
export type UpdateSnapshot = {
availableVersion: string | null;
checking: boolean;
installing: boolean;
};
let snapshot: UpdateSnapshot = {
availableVersion: null,
checking: false,
installing: false,
};
let availableUpdate: Update | null = null;
let pendingCheck: Promise<string | null> | null = null;
const listeners = new Set<() => void>();
function update(patch: Partial<UpdateSnapshot>) {
snapshot = { ...snapshot, ...patch };
listeners.forEach((listener) => listener());
}
async function replaceAvailableUpdate(next: Update | null) {
const previous = availableUpdate;
availableUpdate = next;
update({ availableVersion: next?.version ?? null });
if (previous && previous !== next) await previous.close();
}
export const updateStore = {
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot: () => snapshot,
async check(): Promise<string | null> {
if (!hasNativeAppLifecycle()) return null;
if (pendingCheck) return pendingCheck;
update({ checking: true });
pendingCheck = (async () => {
const next = await checkForUpdate();
await replaceAvailableUpdate(next);
return next?.version ?? null;
})();
try {
return await pendingCheck;
} finally {
pendingCheck = null;
update({ checking: false });
}
},
async install(): Promise<void> {
const current = availableUpdate;
if (!current) return;
update({ installing: true });
try {
await installUpdate(current);
} finally {
update({ installing: false });
}
},
};
export function useUpdateStore(): UpdateSnapshot {
return useSyncExternalStore(updateStore.subscribe, updateStore.getSnapshot);
}
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>
+4
View File
@@ -155,6 +155,10 @@ pub fn api_router(service: ControlService) -> Router {
"/__byok-api__/api/settings/proxy",
get(settings::get_proxy).put(settings::update_proxy),
)
.route(
"/__byok-api__/api/settings/tab",
get(settings::get_tab).put(settings::update_tab),
)
.route(
"/__byok-api__/api/harness/cursor/status",
get(harness::status),
+11 -1
View File
@@ -17,7 +17,9 @@ use crate::{
LlmCallSummary, Overview, ProviderEndpoint, ProviderEndpointInput, ProviderEndpointSecret,
ProviderModel, ProviderModelInput, ProviderType,
},
store::{PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store},
store::{
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store, TabSettings,
},
Error, Result,
};
@@ -372,6 +374,14 @@ impl ControlService {
pub async fn set_proxy_settings(&self, settings: ProxySettingsInput) -> Result<ProxySettings> {
self.store.set_proxy_settings(settings).await
}
pub async fn tab_settings(&self) -> Result<TabSettings> {
self.store.tab_settings().await
}
pub async fn set_tab_settings(&self, settings: TabSettings) -> Result<TabSettings> {
self.cursor_harness.set_tab_settings(settings).await
}
}
fn official_call(trace: CursorRunTraceSummary) -> CallSummary {
+14 -1
View File
@@ -1,7 +1,9 @@
use crate::Result;
use axum::{extract::State, Json};
use crate::store::{PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage};
use crate::store::{
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, TabSettings,
};
use super::{ControlService, ObservabilitySettings};
@@ -47,3 +49,14 @@ pub async fn update_proxy(
) -> Result<Json<ProxySettings>> {
Ok(Json(service.set_proxy_settings(settings).await?))
}
pub async fn get_tab(State(service): State<ControlService>) -> Result<Json<TabSettings>> {
Ok(Json(service.tab_settings().await?))
}
pub async fn update_tab(
State(service): State<ControlService>,
Json(settings): Json<TabSettings>,
) -> Result<Json<TabSettings>> {
Ok(Json(service.set_tab_settings(settings).await?))
}
+4
View File
@@ -8,6 +8,7 @@ use crate::{
blob_sync::BlobSynchronizer,
checkpoint::CheckpointBuilder,
context_sync::RequestContextSynchronizer,
interaction,
proto::agent::v1 as pb,
request,
session::CursorSession,
@@ -272,6 +273,9 @@ impl CursorActor {
)) => {
let _ = blob_sync.handle_client(message).await;
}
Some(pb::agent_client_message::Message::ClientHeartbeat(_)) => {
let _ = handle.emit(&interaction::heartbeat());
}
// TODO: ConversationAction has two different delivery paths that
// must not be conflated:
//
+2 -1
View File
@@ -13,7 +13,7 @@ use crate::{
observability::CursorTraceRecorder,
proto::{agent::v1 as agent, aiserver::v1 as ai},
proxy::{self, CursorProxy},
run_sse,
run_sse, tab,
},
cursor::{CursorParent, CursorSessionRegistry},
Result,
@@ -70,6 +70,7 @@ fn router_with_proxy(registry: CursorSessionRegistry, proxy: CursorProxy) -> Rou
post(analytics::bootstrap_statsig),
)
.route("/auth/full_stripe_profile", get(account::stripe_profile))
.merge(tab::router())
.route_layer(DefaultBodyLimit::disable())
.route_layer(RequestDecompressionLayer::new())
.fallback(proxy::forward)
+6
View File
@@ -66,6 +66,12 @@ pub fn thinking_completed(elapsed: Duration) -> pb::AgentServerMessage {
))
}
pub fn heartbeat() -> pb::AgentServerMessage {
server_interaction(pb::interaction_update::Message::Heartbeat(
pb::HeartbeatUpdate {},
))
}
pub fn arguments_delta(call: &ToolCall, delta: &str) -> Result<pb::AgentServerMessage> {
Ok(server_interaction(
pb::interaction_update::Message::PartialToolCall(pb::PartialToolCallUpdate {
+1
View File
@@ -22,6 +22,7 @@ pub mod request;
pub mod run_sse;
pub mod session;
pub mod sessions;
pub(crate) mod tab;
pub mod tools;
mod usage;
+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",
+53 -3
View File
@@ -82,14 +82,34 @@ impl CursorProxy {
pub async fn forward(
Extension(proxy): Extension<CursorProxy>,
request: Request<Body>,
) -> Result<Response<Body>> {
forward_request(&proxy, request, None).await
}
pub(crate) async fn forward_to_service(
proxy: &CursorProxy,
request: Request<Body>,
service_url: &str,
) -> Result<Response<Body>> {
forward_request(proxy, request, Some(service_url)).await
}
async fn forward_request(
proxy: &CursorProxy,
request: Request<Body>,
service_url: Option<&str>,
) -> Result<Response<Body>> {
let started = Instant::now();
let (parts, body) = request.into_parts();
let path = parts
.uri
.path_and_query()
.map_or("/", |value| value.as_str());
let url = upstream_url(&parts.headers, &proxy.upstream, path)?;
.map_or("/", |value| value.as_str())
.to_owned();
let url = match service_url {
Some(service_url) => format!("{}{}", service_url.trim_end_matches('/'), path),
None => upstream_url(&parts.headers, &proxy.upstream, &path)?,
};
let mut headers = parts.headers;
headers.remove(UPSTREAM_URL_HEADER);
@@ -239,7 +259,7 @@ mod tests {
};
use tower::ServiceExt;
use super::{forward, CursorProxy};
use super::{forward, forward_to_service, CursorProxy};
#[tokio::test]
async fn preserves_request_and_response() {
@@ -289,4 +309,34 @@ mod tests {
);
server.abort();
}
#[tokio::test]
async fn tab_service_keeps_its_base_path_and_the_original_query() {
let upstream = Router::new().route(
"/base/aiserver.v1.AiService/StreamCpp",
any(|request: Request<Body>| async move {
request.uri().path_and_query().unwrap().as_str().to_owned()
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, upstream).await.unwrap() });
let proxy = CursorProxy::for_upstream("http://unused.invalid").unwrap();
let response = forward_to_service(
&proxy,
Request::post("/aiserver.v1.AiService/StreamCpp?client=cursor")
.body(Body::empty())
.unwrap(),
&format!("http://{address}/base"),
)
.await
.unwrap();
assert_eq!(
to_bytes(response.into_body(), usize::MAX).await.unwrap(),
"/base/aiserver.v1.AiService/StreamCpp?client=cursor"
);
server.abort();
}
}
+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>",
)
));
}
}
+102 -11
View File
@@ -3,29 +3,24 @@ use axum::{
http::{header, HeaderValue, Response, StatusCode},
};
use bytes::Bytes;
use std::convert::Infallible;
use tokio::sync::mpsc;
use tokio_stream::StreamExt;
use tokio_util::sync::CancellationToken;
use crate::{
cursor::{observability::CursorTraceRecorder, CursorSessionRegistry},
cursor::{connect::END_STREAM_FLAG, observability::CursorTraceRecorder, CursorSessionRegistry},
Result,
};
pub async fn stream(registry: &CursorSessionRegistry, request_id: &str) -> Result<Response<Body>> {
let handle = registry.get_or_create(request_id).await?;
let mut receiver = handle.subscribe();
let receiver = handle.subscribe();
let trace = handle.trace().cloned();
if let Some(trace) = &trace {
trace.response_started(StatusCode::OK.as_u16()).await;
}
let body_stream = async_stream::stream! {
let mut trace = TraceStreamSink::new(trace, "byok_server");
while let Some(chunk) = receiver.recv().await {
trace.chunk(&chunk);
yield Ok::<Bytes, std::convert::Infallible>(chunk);
}
trace.finish(None);
};
let body_stream = local_body_stream(receiver, handle.cancellation(), trace);
let mut response = Response::new(Body::from_stream(body_stream));
*response.status_mut() = StatusCode::OK;
response.headers_mut().insert(
@@ -41,6 +36,63 @@ pub async fn stream(registry: &CursorSessionRegistry, request_id: &str) -> Resul
Ok(response)
}
fn local_body_stream(
mut receiver: mpsc::UnboundedReceiver<Bytes>,
cancellation: CancellationToken,
trace: Option<CursorTraceRecorder>,
) -> impl tokio_stream::Stream<Item = std::result::Result<Bytes, Infallible>> {
async_stream::stream! {
let mut guard = LocalRunGuard::new(cancellation);
let mut trace = TraceStreamSink::new(trace, "byok_server");
while let Some(chunk) = receiver.recv().await {
let terminal = is_end_stream_frame(&chunk);
trace.chunk(&chunk);
if terminal {
guard.complete();
trace.finish(None);
}
yield Ok::<Bytes, Infallible>(chunk);
if terminal {
return;
}
}
guard.complete();
trace.finish(None);
}
}
fn is_end_stream_frame(frame: &Bytes) -> bool {
frame
.first()
.is_some_and(|flags| flags & END_STREAM_FLAG != 0)
}
struct LocalRunGuard {
cancellation: CancellationToken,
completed: bool,
}
impl LocalRunGuard {
fn new(cancellation: CancellationToken) -> Self {
Self {
cancellation,
completed: false,
}
}
fn complete(&mut self) {
self.completed = true;
}
}
impl Drop for LocalRunGuard {
fn drop(&mut self) {
if !self.completed {
self.cancellation.cancel();
}
}
}
pub async fn upstream(
registry: CursorSessionRegistry,
request_id: String,
@@ -127,7 +179,11 @@ impl TraceStreamSink {
impl Drop for TraceStreamSink {
fn drop(&mut self) {
self.finish(None);
if self.sender.is_some() {
self.finish(Some(
"response stream dropped before completion".to_string(),
));
}
}
}
@@ -143,3 +199,38 @@ impl Drop for UpstreamRunGuard {
.finish_upstream(self.request_id.clone(), self.generation);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cursor::{connect, proto::agent::v1 as pb};
#[tokio::test]
async fn local_stream_cancels_when_the_client_disconnects() {
let (sender, receiver) = mpsc::unbounded_channel();
let cancellation = CancellationToken::new();
sender
.send(connect::encode_message(&pb::AgentServerMessage::default()).unwrap())
.unwrap();
let mut stream = Box::pin(local_body_stream(receiver, cancellation.clone(), None));
stream.next().await.unwrap().unwrap();
drop(sender);
drop(stream);
assert!(cancellation.is_cancelled());
}
#[tokio::test]
async fn terminal_frame_does_not_cancel_a_completed_local_run() {
let (sender, receiver) = mpsc::unbounded_channel();
let cancellation = CancellationToken::new();
sender.send(connect::encode_end_stream()).unwrap();
let mut stream = Box::pin(local_body_stream(receiver, cancellation.clone(), None));
let terminal = stream.next().await.unwrap().unwrap();
assert!(is_end_stream_frame(&terminal));
drop(stream);
assert!(!cancellation.is_cancelled());
}
}
+67
View File
@@ -0,0 +1,67 @@
use axum::{
body::Body,
extract::{Extension, State},
http::{Request, Response},
routing::post,
Router,
};
use crate::{
cursor::{proxy, CursorSessionRegistry},
Result,
};
pub const TAB_PATHS: [&str; 17] = [
"/aiserver.v1.AiService/StreamCpp",
"/aiserver.v1.AiService/StreamNextCursorPrediction",
"/aiserver.v1.AiService/GetCppEditClassification",
"/aiserver.v1.AiService/RefreshTabContext",
"/aiserver.v1.AiService/CppConfig",
"/aiserver.v1.AiService/CppEditHistoryStatus",
"/aiserver.v1.AiService/CppAppend",
"/aiserver.v1.AiService/CppEditHistoryAppend",
"/aiserver.v1.AiService/ReportAiCodeChangeMetrics",
"/aiserver.v1.AiService/WriteGitCommitMessage",
"/aiserver.v1.AiService/WriteGitBranchName",
"/aiserver.v1.CppService/AvailableModels",
"/aiserver.v1.CppService/RecordCppFate",
"/aiserver.v1.FileSyncService/FSSyncFile",
"/aiserver.v1.FileSyncService/FSIsEnabledForUser",
"/aiserver.v1.FileSyncService/FSConfig",
"/aiserver.v1.FileSyncService/FSUploadFile",
];
pub fn is_tab_path(path: &str) -> bool {
TAB_PATHS.contains(&path)
}
pub fn router() -> Router<CursorSessionRegistry> {
TAB_PATHS.into_iter().fold(Router::new(), |router, path| {
router.route(path, post(forward))
})
}
async fn forward(
State(registry): State<CursorSessionRegistry>,
Extension(upstream): Extension<proxy::CursorProxy>,
request: Request<Body>,
) -> Result<Response<Body>> {
let settings = registry.store().tab_settings().await?;
match settings.service_url() {
Some(service_url) => proxy::forward_to_service(&upstream, request, service_url).await,
None => proxy::forward(Extension(upstream), request).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_only_legacy_tab_routes() {
assert_eq!(TAB_PATHS.len(), 17);
assert!(is_tab_path("/aiserver.v1.AiService/StreamCpp"));
assert!(is_tab_path("/aiserver.v1.FileSyncService/FSUploadFile"));
assert!(!is_tab_path("/aiserver.v1.AiService/AvailableModels"));
}
}
+21 -2
View File
@@ -9,7 +9,10 @@ use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use crate::{store::Store, Error, Result};
use crate::{
store::{Store, TabMode, TabSettings},
Error, Result,
};
use self::{ca::CaManager, proxy::ProxyRuntime};
@@ -65,6 +68,7 @@ struct Inner {
ca: CaManager,
ca_initialization: Mutex<()>,
backend_addr: RwLock<Option<SocketAddr>>,
tab_mode: Arc<RwLock<TabMode>>,
proxy: Mutex<ProxyRuntime>,
}
@@ -76,6 +80,7 @@ impl CursorHarness {
ca: CaManager::managed()?,
ca_initialization: Mutex::new(()),
backend_addr: RwLock::new(None),
tab_mode: Arc::new(RwLock::new(TabMode::default())),
proxy: Mutex::new(ProxyRuntime::default()),
}),
})
@@ -138,6 +143,12 @@ impl CursorHarness {
self.status().await
}
pub async fn set_tab_settings(&self, settings: TabSettings) -> Result<TabSettings> {
let saved = self.inner.store.set_tab_settings(settings).await?;
*self.inner.tab_mode.write() = saved.mode;
Ok(saved)
}
async fn enable(&self) -> Result<()> {
if !matches!(self.inner.ca.state()?, CaState::Ready) {
return Err(Error::Config(
@@ -158,7 +169,15 @@ impl CursorHarness {
}
let ca = self.inner.ca.load()?;
let requested_port = self.inner.store.port_settings().await?.proxy_port;
let (url, actual_port) = proxy.start(backend_addr, ca, requested_port).await?;
*self.inner.tab_mode.write() = self.inner.store.tab_settings().await?.mode;
let (url, actual_port) = proxy
.start(
backend_addr,
ca,
requested_port,
self.inner.tab_mode.clone(),
)
.await?;
if let Err(error) = self.inner.store.set_proxy_port(actual_port).await {
proxy.stop().await;
return Err(error);
+29 -4
View File
@@ -1,4 +1,4 @@
use std::net::SocketAddr;
use std::{net::SocketAddr, sync::Arc};
use hudsucker::{
certificate_authority::RcgenAuthority,
@@ -8,7 +8,13 @@ use hudsucker::{
};
use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle};
use crate::{cursor::proxy::UPSTREAM_URL_HEADER, Error, Result};
use parking_lot::RwLock;
use crate::{
cursor::{proxy::UPSTREAM_URL_HEADER, tab::is_tab_path},
store::TabMode,
Error, Result,
};
use super::ca::LoadedCa;
@@ -33,6 +39,7 @@ impl ProxyRuntime {
backend: SocketAddr,
ca: LoadedCa,
requested_port: u16,
tab_mode: Arc<RwLock<TabMode>>,
) -> Result<(String, u16)> {
if let Some(url) = self.url() {
return Ok((url, self.port.unwrap_or_default()));
@@ -45,7 +52,7 @@ impl ProxyRuntime {
.with_listener(listener)
.with_ca(authority)
.with_rustls_connector(aws_lc_rs::default_provider())
.with_http_handler(CursorRelay { backend })
.with_http_handler(CursorRelay { backend, tab_mode })
.with_graceful_shutdown(async move {
let _ = done.await;
})
@@ -89,6 +96,7 @@ async fn bind_proxy_listener(requested_port: u16) -> Result<TcpListener> {
#[derive(Clone)]
struct CursorRelay {
backend: SocketAddr,
tab_mode: Arc<RwLock<TabMode>>,
}
impl HttpHandler for CursorRelay {
@@ -98,7 +106,8 @@ impl HttpHandler for CursorRelay {
mut request: Request<Body>,
) -> RequestOrResponse {
let original = request.uri().clone();
if is_cursor_host(original.host().unwrap_or_default()) && is_local_path(original.path()) {
let locally_routed = should_route_locally(original.path(), *self.tab_mode.read());
if is_cursor_host(original.host().unwrap_or_default()) && locally_routed {
if let Ok(value) = original.to_string().parse() {
request.headers_mut().insert(UPSTREAM_URL_HEADER, value);
}
@@ -157,6 +166,10 @@ fn is_local_path(path: &str) -> bool {
)
}
fn should_route_locally(path: &str, tab_mode: TabMode) -> bool {
is_local_path(path) || (is_tab_path(path) && tab_mode != TabMode::Direct)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -179,5 +192,17 @@ mod tests {
"/aiserver.v1.AnalyticsService/BootstrapStatsig"
));
assert!(!is_local_path("/unrelated"));
assert!(should_route_locally(
"/aiserver.v1.AiService/StreamCpp",
TabMode::Public
));
assert!(should_route_locally(
"/aiserver.v1.AiService/StreamCpp",
TabMode::Custom
));
assert!(!should_route_locally(
"/aiserver.v1.AiService/StreamCpp",
TabMode::Direct
));
}
}
+2 -2
View File
@@ -140,11 +140,11 @@ impl Provider for OpenAiResponsesProvider {
}
if text_open { text_open = false; yield ModelEvent::TextEnd; }
}
"response.reasoning_summary_text.delta" => {
"response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => {
if !thinking_open { thinking_open = true; yield ModelEvent::ThinkingStart; }
if let Some(delta) = value.get("delta").and_then(Value::as_str) { yield ModelEvent::ThinkingDelta(delta.into()); }
}
"response.reasoning_summary_text.done" => {
"response.reasoning_summary_text.done" | "response.reasoning_text.done" => {
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
}
"response.output_item.added" => {
+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();
+93
View File
@@ -6,8 +6,11 @@ use super::{now_ms, Store};
const PORT_SETTINGS_KEY: &str = "network_ports";
const PROXY_SETTINGS_KEY: &str = "outbound_proxy";
const TAB_SETTINGS_KEY: &str = "cursor_tab";
const INSTALLATION_ID_KEY: &str = "installation_id";
pub const PUBLIC_TAB_SERVICE_URL: &str = "https://tab.leokun.cn";
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub struct PortSettings {
pub proxy_port: u16,
@@ -28,6 +31,31 @@ impl ProxyMode {
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TabMode {
#[default]
Public,
Direct,
Custom,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub struct TabSettings {
pub mode: TabMode,
pub address: String,
}
impl TabSettings {
pub fn service_url(&self) -> Option<&str> {
match self.mode {
TabMode::Public => Some(PUBLIC_TAB_SERVICE_URL),
TabMode::Direct => None,
TabMode::Custom => Some(&self.address),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub struct ProxySettingsInput {
pub mode: ProxyMode,
@@ -139,6 +167,47 @@ impl Store {
self.proxy_settings().await
}
pub async fn tab_settings(&self) -> Result<TabSettings> {
let value = sqlx::query_scalar::<_, String>(
"SELECT value_json FROM service_settings WHERE setting_key = ?",
)
.bind(TAB_SETTINGS_KEY)
.fetch_optional(&self.pool)
.await?;
value
.map(|value| serde_json::from_str(&value).map_err(Into::into))
.unwrap_or_else(|| Ok(TabSettings::default()))
}
pub async fn set_tab_settings(&self, mut settings: TabSettings) -> Result<TabSettings> {
settings.address = settings.address.trim().trim_end_matches('/').to_owned();
if settings.mode == TabMode::Custom {
let parsed = url::Url::parse(&settings.address).map_err(|error| {
crate::Error::Config(format!("invalid TAB service address: {error}"))
})?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(crate::Error::Config(
"TAB service address must use http or https".into(),
));
}
if parsed.host_str().is_none()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(crate::Error::Config(
"TAB service address must be a base URL without a query or fragment".into(),
));
}
}
sqlx::query("INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms")
.bind(TAB_SETTINGS_KEY)
.bind(serde_json::to_string(&settings)?)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(settings)
}
pub async fn port_settings(&self) -> Result<PortSettings> {
let value = sqlx::query_scalar::<_, String>(
"SELECT value_json FROM service_settings WHERE setting_key = ?",
@@ -244,4 +313,28 @@ mod tests {
"secret"
);
}
#[tokio::test]
async fn tab_settings_default_to_public_and_validate_custom_urls() {
let store = Store::connect("sqlite::memory:").await.unwrap();
assert_eq!(store.tab_settings().await.unwrap(), TabSettings::default());
let saved = store
.set_tab_settings(TabSettings {
mode: TabMode::Custom,
address: " https://tab.example.com/base/ ".into(),
})
.await
.unwrap();
assert_eq!(saved.address, "https://tab.example.com/base");
assert_eq!(store.tab_settings().await.unwrap(), saved);
assert!(store
.set_tab_settings(TabSettings {
mode: TabMode::Custom,
address: "file:///tmp/tab".into(),
})
.await
.is_err());
}
}
+48
View File
@@ -146,6 +146,54 @@ async fn registry_shutdown_cancels_runs_and_closes_run_sse_outputs() {
assert_eq!(output.recv().await, None);
}
#[tokio::test]
async fn client_heartbeat_returns_a_server_protocol_heartbeat() {
let (_directory, store) = fixtures::temp_store().await;
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("prompt/cursor")
.as_path(),
)
.unwrap();
let registry = CursorSessionRegistry::new(
store,
Arc::new(fake_provider::FakeProvider::default()),
PromptCompiler::new(assets),
Default::default(),
);
let handle = registry.get_or_create("heartbeat-run").await.unwrap();
let mut output = handle.subscribe();
handle
.command(CursorCommand::Append {
seqno: 0,
message: Box::new(pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::ClientHeartbeat(
pb::ClientHeartbeat {},
)),
}),
})
.await
.unwrap();
let frame = tokio::time::timeout(std::time::Duration::from_secs(1), output.recv())
.await
.unwrap()
.unwrap();
let (_, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
let message = pb::AgentServerMessage::decode(payload).unwrap();
assert!(matches!(
message.message,
Some(pb::agent_server_message::Message::InteractionUpdate(
pb::InteractionUpdate {
message: Some(pb::interaction_update::Message::Heartbeat(_)),
}
))
));
registry.shutdown().await;
}
#[tokio::test]
async fn runtime_user_message_action_aborts_active_exec_before_canceled_end_stream() {
let (_directory, store) = fixtures::temp_store().await;
-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()),
+31
View File
@@ -329,6 +329,37 @@ async fn openai_responses_raw_stream_does_not_invent_reasoning_effort() {
assert_eq!(replayed, ["opaque-1", "opaque-2"]);
}
#[tokio::test]
async fn openai_responses_streams_openrouter_reasoning_text_events() {
let (base_url, _requests, server) = fixture_server(
"/v1/responses",
concat!(
"data: {\"type\":\"response.reasoning_text.delta\",\"delta\":\"still working\"}\n\n",
"data: {\"type\":\"response.reasoning_text.done\"}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{}}\n\n",
),
)
.await;
let provider = OpenAiResponsesProvider::new(
reqwest::Client::new(),
config(ProviderKind::OpenAiResponses, base_url, None),
);
let events = collect(provider.stream(invocation(), CancellationToken::new())).await;
server.abort();
assert!(events
.iter()
.any(|event| matches!(event, ModelEvent::ThinkingStart)));
assert!(events.iter().any(
|event| matches!(event, ModelEvent::ThinkingDelta(delta) if delta == "still working")
));
assert!(events
.iter()
.any(|event| matches!(event, ModelEvent::ThinkingEnd)));
assert_eq!(events.last(), Some(&ModelEvent::Done(FinishReason::Stop)));
}
#[tokio::test]
async fn openai_responses_reasoning_item_done_closes_an_open_summary() {
let (base_url, _requests, server) = fixture_server(
+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