Compare commits

...
74 changed files with 119243 additions and 300 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."
+30 -7
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_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.5"
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.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.0",
"version": "0.1.0-beta.5",
"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.5",
"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.5"
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.5",
"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.
});
+27 -1
View File
@@ -56,6 +56,15 @@ export interface ModelInput {
supports_image_generation: boolean;
}
export interface ModelConnectivityResult {
duration_ms: number;
first_text_ms: number | null;
output_tokens: number;
tokens_per_second: number;
tokens_estimated: boolean;
output: string;
}
export type CaState = "missing" | "untrusted" | "ready" | "invalid" | "unsupported";
export type IntegrationState = "disabled" | "enabled" | "degraded";
export interface CursorHarnessStatus {
@@ -97,6 +106,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;
@@ -214,7 +230,14 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
throw new Error(t("无法连接本地管理服务"), { cause });
}
if (!response.ok) {
const message = await response.text();
const body = await response.text();
let message = body;
try {
const parsed = JSON.parse(body) as { message?: unknown };
if (typeof parsed.message === "string") message = parsed.message;
} catch {
// Plain-text errors are already suitable for display.
}
throw new Error(message || `${response.status} ${response.statusText}`);
}
if (response.status === 204) return undefined as T;
@@ -241,6 +264,7 @@ export const api = {
models: () => request<Model[]>("/models"),
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
testModel: (hash: string) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; providerIds?: number[] }) => {
const params = new URLSearchParams();
if (filter) {
@@ -283,4 +307,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,
@@ -0,0 +1,35 @@
import type { ModelConnectivityResult } from "../../api";
import { Icon } from "../ui/Icon";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { informationOutlineIcon } from "../ui/icons";
import styles from "./CursorSettings.module.scss";
export type CursorModelTestState =
| { status: "success"; result: ModelConnectivityResult }
| { status: "error"; error: string };
export function CursorModelTestResult({ state }: { state: CursorModelTestState }) {
const success = state.status === "success";
const summary = success
? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) })
: t("错误:{error}", { error: state.error });
const detail = success
? t("速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", {
speed: formatSpeed(state.result.tokens_per_second),
firstText: state.result.first_text_ms ?? "--",
duration: state.result.duration_ms,
tokens: state.result.output_tokens,
estimated: state.result.tokens_estimated ? t("(估算)") : "",
output: state.result.output || "--",
})
: t("测试失败:{error}", { error: state.error });
return <div className={`${styles.testResult} ${success ? styles.testSuccess : styles.testError}`}>
<span className={styles.testResultText}>{summary}</span>
<TooltipTrigger label={detail}><span className={styles.testResultHint} tabIndex={0}><Icon icon={informationOutlineIcon} size="1.1em" /></span></TooltipTrigger>
</div>;
}
function formatSpeed(value: number) {
return Number.isFinite(value) ? value.toFixed(1) : "0.0";
}
@@ -1,5 +1,10 @@
@use "../../styles/typography" as type;
.page {
display: grid;
gap: 16px;
}
.gate {
min-height: 250px;
display: flex;
@@ -81,11 +86,46 @@
display: flex;
gap: 2px;
}
.testResult {
min-width: 0;
max-width: 220px;
display: flex;
align-items: center;
gap: 5px;
font-size: type.$font-size-xs;
}
.testSuccess {
color: var(--vscode-testing-iconPassed, #73c991);
}
.testError {
color: var(--vscode-errorForeground, #f48771);
}
.testResultText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.testResultHint {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
color: currentColor;
cursor: help;
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
}
.editor {
display: flex;
flex-direction: column;
gap: 14px;
}
.editorTestResult {
margin-top: 14px;
}
.command {
margin: 0;
padding: 10px;
@@ -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("检查更新")}
@@ -2,7 +2,7 @@
.region {
position: fixed;
z-index: 11000;
z-index: 16000;
top: calc(48px + env(safe-area-inset-top));
right: 0;
left: 0;
+2 -1
View File
@@ -4,7 +4,7 @@ import { ScrollableContent } from "../virtual/ScrollableContent";
import controls from "./Controls.module.scss";
import styles from "./Modal.module.scss";
export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit, closeLabel = t("取消"), submitLabel = t("保存") }: { id?: string; open: boolean; title: string; children: ReactNode; busy?: boolean; wide?: boolean; onClose: () => void; onSubmit?: () => void; closeLabel?: string; submitLabel?: string }) {
export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: { id?: string; open: boolean; title: string; children: ReactNode; busy?: boolean; wide?: boolean; onClose: () => void; onSubmit?: () => void; secondaryAction?: ReactNode; closeLabel?: string; submitLabel?: string }) {
const dialog = useRef<HTMLDivElement>(null);
const closeRef = useRef(onClose);
const busyRef = useRef(Boolean(busy));
@@ -29,6 +29,7 @@ export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<footer>
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
{secondaryAction}
{onSubmit && <button type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
</footer>
</div>
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -5,6 +5,7 @@
"028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}",
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "Edit",
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
"0580e0a99a6f1afc": "Artifacts",
"05912a17829faacc": "All call records and detailed traces will be deleted. Providers, models, CA, and application settings are unaffected. This action cannot be undone.",
"076832c1b2de22c3": "Cache write: {tokens}",
@@ -22,9 +23,11 @@
"0d5e2bdb15579fc4": "Messages",
"0e41f8e3d59ec47b": "Storage management",
"0ec1e85b0c3cfa65": "Call details",
"105a9082c346f958": "Testing…",
"124be3f86f197802": "Token usage",
"12ae77e6202d063e": "Custom Headers",
"12b86e89701b0241": "Enter a model name and press Enter to add it",
"133340e53175128a": "Test all",
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
"13b61c5f697b6700": "Cache hit rate",
"146da2e2a991493e": "Fetching…",
@@ -56,6 +59,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 +79,14 @@
"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",
"461d6a57900c2ed7": "Connectivity test failed: {error}",
"470049252e54de6a": "Success rate: {rate}",
"48b970b568a7f8f9": "Proxy settings",
"48d8db17bae06246": "{count} total",
@@ -92,14 +97,20 @@
"4d0680f9efaef147": "Unread",
"4eafa9e925b30bcd": "Custom",
"51d04bc3d286f018": "Last calendar day",
"51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models",
"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.",
"5b17f59d33bde39e": "Error: {error}",
"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}",
@@ -111,6 +122,7 @@
"63c73c4730f4473e": "Apply",
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
"651f274470153a05": "Software updates",
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}",
"653b123c956d3bcb": "Calls",
"656ab25e264cc4e4": "No models are available to Cursor yet",
"65a6318e07ec1e07": "Tools",
@@ -118,6 +130,7 @@
"68152165b3348852": "Select the request protocol used by this provider.",
"68ea5dd4d7af20e6": "System settings",
"6a9906c79f26c0ba": "Start time",
"6aa8f49cc992dfd7": "Test",
"6ae80538c2b2572d": "Minimize window",
"6d1876364ac6457d": "Proxy mode",
"6e86570183c3cdd0": "You're up to date",
@@ -132,6 +145,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 +163,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 +173,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.",
@@ -175,6 +191,7 @@
"a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.",
"a49ffd73bc85333d": "Average",
"a621ab606db2a11f": "Password",
"a693d69af48bfe48": "Save and test",
"a748cc074f78de00": "View details",
"a98585871c5313ff": "Display name",
"a9ab292ea9feecdc": "Provider",
@@ -186,17 +203,20 @@
"ad6a60ee93d3ba3e": "Loading call details…",
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
"aee88743413144a2": "Refresh",
"b06325c5660f0c29": "Direct",
"b4411558b932266f": "Provider type",
"b502b1d414664337": "Prompt: {tokens}",
"b5141d3d19e9a048": "Yes",
"b710ec36ad312918": "The model service API root URL, for example https://api.openai.com/v1.",
"b75a46aad3e7c132": "Non-cached input: {tokens}",
"b79354009c614ae9": "Statistics",
"b86967982067d295": " (estimated)",
"b89a0e4584f27ab5": "Open terminal",
"b8c9b486c83b5778": "Hide ad",
"b9670c85a4ab939e": "Route",
"b97ad406809572e1": "Enable reasoning",
"b9af2de88d903be7": "Proxy address",
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
"bb2b7736433ae867": "Cursor tracing",
"bb7efdcb6af6e805": "Default dark",
"bda62ce1d5e4ace9": "Tell us why",
@@ -236,11 +256,13 @@
"df8b71c74d9b8478": "Response stream",
"dfb802238b38fbd4": "Enabled",
"e025f1ff71996425": "Set",
"e0fae77446a389a3": "Speed: {speed} tokens/s",
"e1295adecbb77755": "Close ad",
"e14115de7f7c5795": "Token usage over the past year",
"e14a5eee9b0b0f9f": "Whether the model declares reasoning support.",
"e14f20d572c02611": "Provider call sequence",
"e18516550b9a5105": "No usage",
"e24ebe4a866d69bf": "Test failed: {error}",
"e25bf3f419bb68f0": "Call history",
"e3fee05f688708b4": "LLM calls",
"e5043c7a2b408271": "Last 10 minutes",
+23 -1
View File
@@ -5,6 +5,7 @@
"028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}",
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "编辑",
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
"0580e0a99a6f1afc": "工件数",
"05912a17829faacc": "所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。",
"076832c1b2de22c3": "缓存写入:{tokens}",
@@ -22,9 +23,11 @@
"0d5e2bdb15579fc4": "消息数",
"0e41f8e3d59ec47b": "存储管理",
"0ec1e85b0c3cfa65": "调用详情",
"105a9082c346f958": "测试中…",
"124be3f86f197802": "Token 消耗",
"12ae77e6202d063e": "自定义 Headers",
"12b86e89701b0241": "输入模型名称后按 Enter 添加",
"133340e53175128a": "一键测试",
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
"13b61c5f697b6700": "缓存命中率",
"146da2e2a991493e": "获取中…",
@@ -56,6 +59,7 @@
"2eb2bf7c6597ab9a": "详细记录",
"2f1b67cdaa23351a": "自定义请求完整地址",
"2f4a361f878176d1": "{label} 必须是有效 JSON",
"2f4a9609285d8f49": "TAB 设置已保存",
"2f5f1d6fbfb061ed": "未设置",
"2f6416a2c424856b": "最终请求地址",
"2f7dec3be28d7597": "已选择 {count} 个",
@@ -75,13 +79,14 @@
"3cfae5728b92b334": "Token 用量:{tokens}",
"3d13868593ae4eeb": "界面语言",
"3da0bf1610ff5db5": "推荐内容",
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
"3fd47edce45b3603": "关闭",
"4125fc7ba333524c": "默认亮色",
"42655ed8e4108ae2": "输入(非缓存)",
"42a1d9e5b037c210": "字节数",
"42aa8e01e98c0d8c": "总耗时",
"43cb41d62de2d179": "代理需要认证",
"4510b63fdcf7451f": "Cursor 助手 v0.1.0",
"461d6a57900c2ed7": "连通性测试失败:{error}",
"470049252e54de6a": "成功占比:{rate}",
"48b970b568a7f8f9": "代理设置",
"48d8db17bae06246": "共 {count} 条",
@@ -92,14 +97,20 @@
"4d0680f9efaef147": "未读",
"4eafa9e925b30bcd": "自定义",
"51d04bc3d286f018": "近1自然日",
"51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功",
"5228358a6db59fe7": "如:现在、2026-08-23 18:00",
"5401344227e49e2f": "TAB 设置",
"54644705e9c61009": "端口设置",
"54c53e5fe791d1f3": "初始化 CA",
"550eddc3c7fefa99": "推广",
"555737734a6371e6": "删除上游",
"56432ba297009bdc": "请先初始化 CA",
"59346e82b3dd2998": "TAB 服务地址",
"5a3bd99fa69a40c1": "使用公益服务",
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
"5b17f59d33bde39e": "错误:{error}",
"5c55a67935af8f45": "全部",
"5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?",
"5fd2ec5a6e9b654c": "合计:{cost}",
@@ -111,6 +122,7 @@
"63c73c4730f4473e": "应用",
"6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板",
"651f274470153a05": "软件更新",
"652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}",
"653b123c956d3bcb": "调用",
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
"65a6318e07ec1e07": "工具数",
@@ -118,6 +130,7 @@
"68152165b3348852": "选择上游服务使用的请求协议。",
"68ea5dd4d7af20e6": "系统设置",
"6a9906c79f26c0ba": "开始时间",
"6aa8f49cc992dfd7": "测试",
"6ae80538c2b2572d": "最小化窗口",
"6d1876364ac6457d": "代理方式",
"6e86570183c3cdd0": "当前已是最新版本",
@@ -132,6 +145,7 @@
"7a3cec4ca715de80": "调用统计",
"7ba2d6728fe2531b": "确认清理",
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
"7e0891860c9e6374": "TAB 服务地址不能为空",
"7e1845870b528392": "启用模型",
"7e1f06318e80c3af": "统计数据已清理",
"7e9ab9ada2cbf2cb": "名称和 Base URL 不能为空",
@@ -149,6 +163,7 @@
"8b6ff498515bcc2f": "时间",
"8d0c47eb9eac2d34": "调用类型",
"8df48894086d6fbd": "原因(可选)",
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
"919cb0ce0c8db4e7": "留空表示保留当前密码",
"91aaf184cfc17ffd": "数据概览",
@@ -158,6 +173,7 @@
"94803f35c825e47a": "请求完整地址",
"94df1e7f04815daf": "仅用于界面展示,不会改变发送给上游的模型名称。",
"95f76d30c25d5eda": "当前系统暂不支持安装 CA",
"966498853d801a52": "TAB 选择",
"9850ed41a5bfbb0c": "已选 {count} 项",
"997ec8201c2adeda": "打开终端安装 CA",
"9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。",
@@ -175,6 +191,7 @@
"a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。",
"a49ffd73bc85333d": "平均",
"a621ab606db2a11f": "密码",
"a693d69af48bfe48": "保存并测试",
"a748cc074f78de00": "查看详情",
"a98585871c5313ff": "显示名称",
"a9ab292ea9feecdc": "上游",
@@ -186,17 +203,20 @@
"ad6a60ee93d3ba3e": "正在加载调用详情…",
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
"aee88743413144a2": "刷新",
"b06325c5660f0c29": "直连",
"b4411558b932266f": "上游类型",
"b502b1d414664337": "提示词:{tokens}",
"b5141d3d19e9a048": "是",
"b710ec36ad312918": "模型服务的 API 根地址,例如 https://api.openai.com/v1。",
"b75a46aad3e7c132": "非缓存输入:{tokens}",
"b79354009c614ae9": "统计数据",
"b86967982067d295": "(估算)",
"b89a0e4584f27ab5": "打开终端",
"b8c9b486c83b5778": "不再显示广告",
"b9670c85a4ab939e": "路由",
"b97ad406809572e1": "启用推理",
"b9af2de88d903be7": "代理地址",
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
"bb2b7736433ae867": "Cursor 追踪",
"bb7efdcb6af6e805": "默认暗色",
"bda62ce1d5e4ace9": "可以告诉我们原因",
@@ -236,11 +256,13 @@
"df8b71c74d9b8478": "响应流",
"dfb802238b38fbd4": "已启用",
"e025f1ff71996425": "已设置",
"e0fae77446a389a3": "速度:{speed} tokens/s",
"e1295adecbb77755": "关闭广告",
"e14115de7f7c5795": "过去一年的 Token 用量",
"e14a5eee9b0b0f9f": "是否声明模型支持推理能力。",
"e14f20d572c02611": "上游调用序号",
"e18516550b9a5105": "无用量",
"e24ebe4a866d69bf": "测试失败:{error}",
"e25bf3f419bb68f0": "调用详细",
"e3fee05f688708b4": "LLM 调用",
"e5043c7a2b408271": "近10分钟",
+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;
+9 -4
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,10 +44,12 @@ function loadStoredAdIds(key: string): Set<string> {
export function AppLayout() {
const { busy } = useAppStore();
const { availableVersion } = useUpdateStore();
const { locale } = useI18n();
const message = useMessage();
const location = useLocation();
const [actionTarget, setActionTarget] = useState<HTMLDivElement | null>(null);
const [leftActionTarget, setLeftActionTarget] = useState<HTMLDivElement | null>(null);
const [rightActionTarget, setRightActionTarget] = useState<HTMLDivElement | null>(null);
const [ads, setAds] = useState<AdSlot[]>([]);
const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
@@ -198,7 +201,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 +210,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>
@@ -238,13 +242,14 @@ export function AppLayout() {
<main className={styles.content}>
<div className={styles.actionRegion}>
<Card className={styles.actions}>
<div ref={setLeftActionTarget} className={styles.pageActions} />
{location.pathname !== "/" && <TooltipTrigger label={t("刷新")}><button className={controls.iconButton} aria-label={t("刷新")} disabled={busy} onClick={() => void appStore.refresh()}>
<Icon className={busy ? controls.spin : ""} icon={refreshIcon} size="1.1em" />
</button></TooltipTrigger>}
<div ref={setActionTarget} className={styles.pageActions} />
<div ref={setRightActionTarget} className={styles.pageActions} />
</Card>
</div>
<PageActionsTarget.Provider value={actionTarget}>
<PageActionsTarget.Provider value={{ left: leftActionTarget, right: rightActionTarget }}>
<KeepAliveRouteOutlet
activeCacheKey={location.pathname}
include={keptAlivePages}
+9 -3
View File
@@ -2,10 +2,16 @@ import { createContext, useContext, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useKeepAliveContext } from "keepalive-for-react";
export const PageActionsTarget = createContext<HTMLElement | null>(null);
type PageActionsTargets = {
left: HTMLElement | null;
right: HTMLElement | null;
};
export function PageActions({ children }: { children: ReactNode }) {
const target = useContext(PageActionsTarget);
export const PageActionsTarget = createContext<PageActionsTargets>({ left: null, right: null });
export function PageActions({ children, position = "right" }: { children: ReactNode; position?: "left" | "right" }) {
const targets = useContext(PageActionsTarget);
const { active } = useKeepAliveContext();
const target = targets[position];
return active && target ? createPortal(children, target) : null;
}
+118 -18
View File
@@ -1,7 +1,9 @@
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 { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult";
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 +26,23 @@ 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 [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set());
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
const [savingAndTesting, setSavingAndTesting] = useState(false);
const [batchTesting, setBatchTesting] = 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);
@@ -71,29 +84,79 @@ export function CursorSettingsPage() {
const save = async () => {
if (!draft) return;
try {
const modelIds = editing
? [draft.model.model_id.trim()]
: [...new Set(draft.modelIds.map((modelId) => modelId.trim()).filter(Boolean))];
if (!modelIds.length) throw new Error(t("请至少选择或输入一个模型"));
if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空"));
if (draft.customRequestUrl && !draft.model.request_url.trim()) throw new Error(t("请求完整地址不能为空"));
const modelInputs = modelIds.map((modelId, index) => ({
...draft.model,
model_id: modelId,
display_name: modelIds.length === 1 ? draft.model.display_name.trim() || modelId : modelId,
sort_order: draft.model.sort_order + index,
}));
const ok = editing
const modelInputs = cursorModelInputs(draft, editing !== null);
const saved = editing
? await appStore.updateCursorModel(editing.model_hash, modelInputs[0])
: await appStore.createCursorModels(providerSelection(draft), modelInputs);
if (ok) { setDraft(null); setEditing(null); }
if (saved) { setDraft(null); setEditing(null); }
} catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); }
};
const testModel = async (model: Model, notify = true) => {
setTestingModelHashes((current) => new Set(current).add(model.model_hash));
try {
const result = await api.testModel(model.model_hash);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "success", result }));
if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms }));
return true;
} catch (cause) {
const error = cause instanceof Error ? cause.message : String(cause);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error }));
if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 });
return false;
} finally {
setTestingModelHashes((current) => {
const next = new Set(current);
next.delete(model.model_hash);
return next;
});
}
};
const testSingleModel = async (model: Model) => {
await testModel(model);
await appStore.refresh();
};
const saveAndTest = async () => {
if (!draft || !editing) return;
setSavingAndTesting(true);
try {
const [input] = cursorModelInputs(draft, true);
const saved = await appStore.updateCursorModel(editing.model_hash, input);
if (!saved) {
const error = appStore.getSnapshot().error;
if (error) message(error);
return;
}
setEditing(saved);
await testSingleModel(saved);
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setSavingAndTesting(false);
}
};
const testAllModels = async () => {
if (!models.length || batchTesting) return;
const targets = [...models];
setBatchTesting(true);
try {
const results = await Promise.all(targets.map((model) => testModel(model, false)));
await appStore.refresh();
const successful = results.filter(Boolean).length;
const failed = targets.length - successful;
message(failed === 0
? t("全部 {count} 个模型连通性测试成功", { count: targets.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }), { duration: failed === 0 ? 2400 : 5000 });
} finally {
setBatchTesting(false);
}
};
const list = <div className={styles.groups}>{grouped.map(({ provider, models: childModels }) => <TitledCard key={provider.provider_id} title={<div className={styles.providerTitle}><Icon icon={provider.provider_type === "anthropic" ? claudeIcon : openAiIcon} /><span>{provider.name}</span></div>}>
<div className={styles.models}>{childModels.map((model) => <div className={styles.modelRow} key={model.model_hash}>
<div className={styles.modelName}><strong>{model.display_name}</strong><small>{model.model_id} · {model.model_hash}</small></div>
{/* <span className={styles.badge}>{model.enabled ? t("已启用") : t("已停用")}</span> */}
{modelTestResults.get(model.model_hash) && <CursorModelTestResult state={modelTestResults.get(model.model_hash)!} />}
<div className={styles.rowActions}>
<button type="button" className={`${controls.secondary} ${controls.small}`} disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting} onClick={() => void testSingleModel(model)}>{testingModelHashes.has(model.model_hash) ? t("测试中…") : t("测试")}</button>
<TooltipTrigger label={t("编辑模型")}><button className={controls.iconButton} aria-label={t("编辑模型")} onClick={() => openEdit(model)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
<TooltipTrigger label={t("删除模型")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除模型")} onClick={() => setDeleting(model)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
</div>
@@ -111,17 +174,39 @@ 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()}>
<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 <>
{models.length > 0 && <PageActions position="left">
<button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button>
</PageActions>}
<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 }]} />
<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()} />}
<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 || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={editing ? <button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button> : undefined}>
{draft && <>
<CursorModelEditor draft={draft} providers={providers} editing={editing !== null} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
{editing && modelTestResults.get(editing.model_hash) && <div className={styles.editorTestResult}><CursorModelTestResult state={modelTestResults.get(editing.model_hash)!} /></div>}
</>}
</Modal>
<Modal open={caCommand !== null} title={t("安装本地 CA")} closeLabel={t("关闭")} submitLabel={t("打开终端")} onClose={() => setCaCommand(null)} onSubmit={openCaTerminal}>
<div className={styles.editor}>
@@ -143,6 +228,21 @@ function parseObject(text: string, label: string): Record<string, unknown> {
return parsed as Record<string, unknown>;
}
function cursorModelInputs(draft: CursorModelDraft, editing: boolean) {
const modelIds = editing
? [draft.model.model_id.trim()]
: [...new Set(draft.modelIds.map((modelId) => modelId.trim()).filter(Boolean))];
if (!modelIds.length) throw new Error(t("请至少选择或输入一个模型"));
if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空"));
if (draft.customRequestUrl && !draft.model.request_url.trim()) throw new Error(t("请求完整地址不能为空"));
return modelIds.map((modelId, index) => ({
...draft.model,
model_id: modelId,
display_name: modelIds.length === 1 ? draft.model.display_name.trim() || modelId : modelId,
sort_order: draft.model.sort_order + index,
}));
}
function providerName(baseUrl: string): string {
try {
return new URL(baseUrl.trim()).hostname;
+3 -3
View File
@@ -198,12 +198,12 @@ export const appStore = {
async updateCursorModel(hash: string, model: ModelInput) {
update({ cursorBusy: true, error: null });
try {
await api.updateModel(hash, model);
const updated = await api.updateModel(hash, model);
await appStore.refresh();
return true;
return updated;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return false;
return null;
} finally { update({ cursorBusy: false }); }
},
+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
File diff suppressed because it is too large Load Diff
+929
View File
@@ -0,0 +1,929 @@
syntax = "proto3";
package git_forge.v1;
option go_package = "github.com/leookun/cursor-byok/cursor-proto/gen/git_forge/v1;git_forgev1";
// Copied from: local:git_forge.v1.BatchGetBlobOidsRequest (var: Gn)
message BatchGetBlobOidsRequest {
string repo_uuid = 1;
string revision = 2;
repeated string paths = 3;
}
// Copied from: local:git_forge.v1.BatchGetBlobOidsResponse (var: Vn)
message BatchGetBlobOidsResponse {
repeated BlobOidResult results = 1;
string resolved_commit_sha = 2;
}
// Copied from: local:git_forge.v1.BatchGetCommitMetadataRequest (var: Ut)
message BatchGetCommitMetadataRequest {
string repo_uuid = 1;
repeated string revisions = 2;
}
// Copied from: local:git_forge.v1.BatchGetCommitMetadataResponse (var: Gt)
message BatchGetCommitMetadataResponse {
repeated BatchGetCommitMetadataResult results = 1;
}
// Copied from: local:git_forge.v1.BatchGetCommitMetadataResult (var: Qt)
message BatchGetCommitMetadataResult {
string revision = 1;
CommitMetadata metadata = 2;
}
// Copied from: local:git_forge.v1.BatchGetRepoContentRequest (var: Un)
message BatchGetRepoContentRequest {
string repo_uuid = 1;
string revision = 2;
repeated string paths = 3;
optional uint64 max_file_size = 4;
}
// Copied from: local:git_forge.v1.BatchGetRepoContentResponse (var: Qn)
message BatchGetRepoContentResponse {
repeated BatchRepoContentResult results = 1;
string resolved_commit_sha = 2;
}
// Copied from: local:git_forge.v1.BatchRepoContentResult (var: xn)
message BatchRepoContentResult {
string path = 1;
bool found = 2;
oneof content {
FileContent file_content = 3;
DirectoryContent directory_content = 4;
}
}
// Copied from: local:git_forge.v1.BlameChunk (var: GSv)
message BlameChunk {
repeated ShortCommit commits = 1;
repeated BlameLineRange line_ranges = 2;
}
// Copied from: local:git_forge.v1.BlameLineRange (var: VSv)
message BlameLineRange {
bytes commit_sha = 1;
uint32 start_in_blamed_file = 2;
uint32 len = 3;
}
// Copied from: local:git_forge.v1.BlobOidResult (var: Hn)
message BlobOidResult {
string path = 1;
bool found = 2;
string blob_oid = 3;
}
// Copied from: local:git_forge.v1.BranchDeletion (var: Or)
message BranchDeletion {
string target_ref = 1;
string expected_head_sha = 2;
}
// Copied from: local:git_forge.v1.CanMergeRequest (var: on)
message CanMergeRequest {
string repo_uuid = 1;
string ours = 2;
string theirs = 3;
uint64 change_number = 4;
MergeMode mode = 7;
bool use_git_conflict_semantics = 8;
}
// Copied from: local:git_forge.v1.CanMergeResponse (var: LSv)
message CanMergeResponse {
bool can_merge_without_conflicts = 1;
optional bytes merged_tree_sha = 2;
repeated string conflicted_paths = 3;
}
// Copied from: local:git_forge.v1.ChangeKind (var: TQd)
enum ChangeKind {
CHANGE_KIND_UNSPECIFIED = 0;
CHANGE_KIND_ADDED = 1;
CHANGE_KIND_DELETED = 2;
CHANGE_KIND_MODIFIED = 3;
CHANGE_KIND_RENAMED = 4;
CHANGE_KIND_COPIED = 5;
}
// Copied from: local:git_forge.v1.Commit (var: azi)
message Commit {
string sha = 1;
string message = 2;
Signature author = 3;
Signature committer = 4;
repeated string parent_shas = 5;
string tree_sha = 7;
optional string change_id = 8;
}
// Copied from: local:git_forge.v1.CommitDiffChunk (var: rwv)
message CommitDiffChunk {
optional CommitDiffHeader header = 1;
repeated DiffEntry entries = 3;
}
// Copied from: local:git_forge.v1.CommitDiffHeader (var: iwv)
message CommitDiffHeader {
Commit commit = 1;
optional string base_commit_sha = 2;
CommitDiffStats stats = 3;
bool has_more = 5;
optional string next_page_cursor = 6;
repeated DiffEntry entries = 7;
}
// Copied from: local:git_forge.v1.CommitDiffStats (var: owv)
message CommitDiffStats {
uint32 files_changed = 1;
int32 additions = 2;
int32 deletions = 3;
}
// Copied from: local:git_forge.v1.CommitFileDelete (var: OSv)
message CommitFileDelete {
}
// Copied from: local:git_forge.v1.CommitFileMode (var: kSv)
enum CommitFileMode {
COMMIT_FILE_MODE_UNSPECIFIED = 0;
COMMIT_FILE_MODE_REGULAR = 1;
COMMIT_FILE_MODE_EXECUTABLE = 2;
COMMIT_FILE_MODE_SYMLINK = 3;
}
// Copied from: local:git_forge.v1.CommitFileOperation (var: FSv)
message CommitFileOperation {
string path = 1;
oneof operation {
CommitFileUpsert upsert = 2;
CommitFileDelete delete = 3;
}
}
// Copied from: local:git_forge.v1.CommitFileUpsert (var: NSv)
message CommitFileUpsert {
bytes content = 1;
CommitFileMode mode = 2;
}
// Copied from: local:git_forge.v1.CommitMetadata (var: xt)
message CommitMetadata {
string sha = 1;
Signature author = 2;
Signature committer = 3;
string message = 4;
}
// Copied from: local:git_forge.v1.CompareCommitsRequest (var: rn)
message CompareCommitsRequest {
string repo_uuid = 1;
string base_revision = 2;
string head_revision = 3;
}
// Copied from: local:git_forge.v1.CompareCommitsResponse (var: PSv)
message CompareCommitsResponse {
CompareCommitsStatus status = 1;
int32 ahead_by = 2;
int32 behind_by = 3;
string base_commit_sha = 4;
string head_commit_sha = 5;
string merge_base_commit_sha = 6;
}
// Copied from: local:git_forge.v1.CompareCommitsStatus (var: wSv)
enum CompareCommitsStatus {
COMPARE_COMMITS_STATUS_UNSPECIFIED = 0;
COMPARE_COMMITS_STATUS_IDENTICAL = 1;
COMPARE_COMMITS_STATUS_AHEAD = 2;
COMPARE_COMMITS_STATUS_BEHIND = 3;
COMPARE_COMMITS_STATUS_DIVERGED = 4;
}
// Copied from: local:git_forge.v1.ComputeMergeCommitRequest (var: mn)
message ComputeMergeCommitRequest {
string repo_uuid = 1;
string ours_sha = 2;
string theirs_sha = 3;
string message = 4;
Signature author = 5;
Signature committer = 6;
MergeMode mode = 7;
}
// Copied from: local:git_forge.v1.ComputeMergeCommitResponse (var: cn)
message ComputeMergeCommitResponse {
string merge_commit_sha = 1;
bytes packfile = 2;
}
// Copied from: local:git_forge.v1.CreateBranchRequest (var: Qr)
message CreateBranchRequest {
string repo_uuid = 1;
string target_ref = 2;
string new_head_sha = 3;
}
// Copied from: local:git_forge.v1.CreateBranchResponse (var: Gr)
message CreateBranchResponse {
string new_head_sha = 1;
string wal_entry_key = 2;
bool unchanged = 3;
}
// Copied from: local:git_forge.v1.CreateCommitFromFilesRequest (var: En)
message CreateCommitFromFilesRequest {
string repo_uuid = 1;
string target_ref = 2;
optional string expected_head_sha = 3;
string message = 4;
Signature author = 5;
optional Signature committer = 6;
repeated CommitFileOperation files = 7;
}
// Copied from: local:git_forge.v1.CreateCommitFromFilesResponse (var: Sn)
message CreateCommitFromFilesResponse {
string commit_sha = 1;
string tree_sha = 2;
string old_head_sha = 3;
string wal_entry_key = 4;
}
// Copied from: local:git_forge.v1.CreateMergeCommitRequest (var: ln)
message CreateMergeCommitRequest {
string repo_uuid = 1;
optional string ours_sha = 2;
optional string theirs_sha = 3;
string ours_ref = 4;
string theirs_ref = 5;
string message = 6;
Signature author = 7;
Signature committer = 8;
uint64 change_number = 9;
MergeMode mode = 10;
}
// Copied from: local:git_forge.v1.CreateMergeCommitResponse (var: un)
message CreateMergeCommitResponse {
string merge_commit_sha = 1;
string wal_entry_key = 2;
}
// Copied from: local:git_forge.v1.CreateRepoRequest (var: kr)
message CreateRepoRequest {
string repo_uuid = 1;
}
// Copied from: local:git_forge.v1.CreateRepoResponse (var: wr)
message CreateRepoResponse {
}
// Copied from: local:git_forge.v1.CreateRevertBranchConflict (var: Jn)
message CreateRevertBranchConflict {
repeated string conflicted_paths = 1;
}
// Copied from: local:git_forge.v1.CreateRevertBranchRequest (var: In)
message CreateRevertBranchRequest {
string repo_uuid = 1;
string commit_sha = 2;
string base_ref = 3;
string branch_ref = 4;
string message = 5;
Signature author = 6;
optional Signature committer = 7;
}
// Copied from: local:git_forge.v1.CreateRevertBranchResponse (var: vn)
message CreateRevertBranchResponse {
oneof result {
CreateRevertBranchSuccess success = 1;
CreateRevertBranchConflict conflict = 2;
}
}
// Copied from: local:git_forge.v1.CreateRevertBranchSuccess (var: Bn)
message CreateRevertBranchSuccess {
string revert_commit_sha = 1;
string base_sha = 2;
string wal_entry_key = 3;
}
// Copied from: local:git_forge.v1.DeleteBranchesRequest (var: Ur)
message DeleteBranchesRequest {
string repo_uuid = 1;
repeated BranchDeletion branches = 2;
}
// Copied from: local:git_forge.v1.DeleteBranchesResponse (var: xr)
message DeleteBranchesResponse {
uint32 deleted_count = 1;
uint32 missing_count = 2;
string wal_entry_key = 3;
bool unchanged = 4;
}
// Copied from: local:git_forge.v1.DiffEntry (var: czi)
message DiffEntry {
string path = 1;
optional string old_path = 2;
ChangeKind change_kind = 3;
optional FileMode old_mode = 4;
optional FileMode new_mode = 5;
optional string old_sha = 6;
optional string new_sha = 7;
optional FileStats stats = 8;
optional string patch = 9;
optional bool is_generated = 10;
}
// Copied from: local:git_forge.v1.DiffHeader (var: swv)
message DiffHeader {
string merge_base_commit_sha = 1;
bool has_more = 4;
optional string next_page_cursor = 5;
repeated DiffEntry entries = 6;
}
// Copied from: local:git_forge.v1.DirectoryContent (var: BSv)
message DirectoryContent {
repeated RepoContentEntry entries = 1;
string sha = 2;
}
// Copied from: local:git_forge.v1.FastForwardRefRequest (var: Fr)
message FastForwardRefRequest {
string repo_uuid = 1;
string target_ref = 2;
string expected_head_sha = 3;
string new_head_sha = 4;
}
// Copied from: local:git_forge.v1.FastForwardRefResponse (var: Dr)
message FastForwardRefResponse {
string old_head_sha = 1;
string new_head_sha = 2;
string wal_entry_key = 3;
bool unchanged = 4;
}
// Copied from: local:git_forge.v1.FileContent (var: IQd)
message FileContent {
string size = 1;
string encoding = 2;
string content = 3;
string sha = 4;
}
// Copied from: local:git_forge.v1.FileHistoryCommitEntry (var: HSv)
message FileHistoryCommitEntry {
ShortCommit commit = 1;
optional string diff_base_commit_sha = 2;
optional FileStats path_stats = 8;
}
// Copied from: local:git_forge.v1.FileHistoryWithDiffStatsChunk (var: er)
message FileHistoryWithDiffStatsChunk {
repeated FileHistoryCommitEntry entries = 1;
bool exhausted = 2;
bool scan_truncated = 3;
optional string resume_commit_sha = 4;
}
// Copied from: local:git_forge.v1.FileMode (var: xQd)
enum FileMode {
FILE_MODE_UNSPECIFIED = 0;
FILE_MODE_REGULAR = 1;
FILE_MODE_EXECUTABLE = 2;
FILE_MODE_SYMLINK = 3;
FILE_MODE_GITLINK = 4;
}
// Copied from: local:git_forge.v1.FileStats (var: DQd)
message FileStats {
int32 additions = 1;
int32 deletions = 2;
bool is_binary = 3;
}
// Copied from: local:git_forge.v1.GetBlameRequest (var: nr)
message GetBlameRequest {
string repo_uuid = 1;
string start_commit_sha = 2;
string path = 3;
}
// Copied from: local:git_forge.v1.GetBlobRequest (var: Ht)
message GetBlobRequest {
string repo_uuid = 1;
string blob_sha = 2;
}
// Copied from: local:git_forge.v1.GetBlobResponse (var: xSv)
message GetBlobResponse {
FileContent blob = 1;
}
// Copied from: local:git_forge.v1.GetCommitDiffRequest (var: Ir)
message GetCommitDiffRequest {
string repo_uuid = 1;
string commit_sha = 2;
optional string base_commit_sha = 3;
bool include_patches = 4;
repeated string paths = 5;
optional uint32 page_size = 6;
optional string page_cursor = 7;
}
// Copied from: local:git_forge.v1.GetCommitRequest (var: Dt)
message GetCommitRequest {
string repo_uuid = 1;
string commit_sha = 2;
}
// Copied from: local:git_forge.v1.GetCommitResponse (var: ESv)
message GetCommitResponse {
Commit commit = 1;
}
// Copied from: local:git_forge.v1.GetDefaultBranchRequest (var: qn)
message GetDefaultBranchRequest {
string repo_uuid = 1;
}
// Copied from: local:git_forge.v1.GetDefaultBranchResponse (var: Fn)
message GetDefaultBranchResponse {
string default_branch = 1;
}
// Copied from: local:git_forge.v1.GetFileHistoryPageWithDiffStatsResponse (var: zSv)
message GetFileHistoryPageWithDiffStatsResponse {
repeated FileHistoryCommitEntry entries = 1;
bool has_more = 2;
optional string next_cursor = 3;
}
// Copied from: local:git_forge.v1.GetFileHistoryRequest (var: zn)
message GetFileHistoryRequest {
string repo_uuid = 1;
string start_commit_sha = 2;
optional string path = 3;
uint32 max_commits = 4;
}
// Copied from: local:git_forge.v1.GetFileHistoryResponse (var: WSv)
message GetFileHistoryResponse {
repeated ShortCommit commits = 1;
}
// Copied from: local:git_forge.v1.GetFileHistoryWithDiffStatsRequest (var: Xn)
message GetFileHistoryWithDiffStatsRequest {
string repo_uuid = 1;
string start_commit_sha = 2;
optional string path = 3;
uint32 max_commits = 4;
optional string next_cursor = 5;
bool include_diff_stats = 6;
optional uint32 max_commits_scanned = 7;
}
// Copied from: local:git_forge.v1.GetFileHistoryWithDiffStatsResponse (var: Zn)
message GetFileHistoryWithDiffStatsResponse {
repeated FileHistoryCommitEntry entries = 1;
bool has_more = 2;
optional string next_cursor = 3;
}
// Copied from: local:git_forge.v1.GetFuzzyPathsRequest (var: lr)
message GetFuzzyPathsRequest {
string repo_uuid = 1;
string commit_sha = 2;
string query = 3;
uint32 limit = 4;
}
// Copied from: local:git_forge.v1.GetFuzzyPathsResponse (var: QSv)
message GetFuzzyPathsResponse {
repeated string paths = 1;
bool has_more = 2;
}
// Copied from: local:git_forge.v1.GetLocalDevInfoRequest (var: qt)
message GetLocalDevInfoRequest {
}
// Copied from: local:git_forge.v1.GetLocalDevInfoResponse (var: Ft)
message GetLocalDevInfoResponse {
string repo_uuid = 1;
string git_forge_root_dir = 2;
}
// Copied from: local:git_forge.v1.GetPullRequestDiffRequest (var: Er)
message GetPullRequestDiffRequest {
string repo_uuid = 1;
string head_commit_sha = 2;
string base_commit_sha = 3;
optional bool include_patches = 4;
optional uint32 page_size = 5;
optional string page_cursor = 6;
optional bool include_file_stats = 7;
optional bool include_generated = 8;
}
// Copied from: local:git_forge.v1.GetRepoContentDetailsRequest (var: Kn)
message GetRepoContentDetailsRequest {
string repo_uuid = 1;
PathIdentifier path_identifier = 2;
}
// Copied from: local:git_forge.v1.GetRepoContentDetailsResponse (var: jSv)
message GetRepoContentDetailsResponse {
optional RepoContentDetails details = 1;
PathIdentifier path_identifier = 2;
string resolved_commit_sha = 3;
}
// Copied from: local:git_forge.v1.GetRepoContentRequest (var: Dn)
message GetRepoContentRequest {
string repo_uuid = 1;
optional uint64 max_file_size = 4;
oneof id {
PathIdentifier path_identifier = 2;
string ref_and_path = 3;
}
}
// Copied from: local:git_forge.v1.GetRepoContentResponse (var: RQd)
message GetRepoContentResponse {
PathIdentifier path_identifier = 3;
string resolved_commit_sha = 4;
oneof content {
FileContent file_content = 1;
DirectoryContent directory_content = 2;
}
}
// Copied from: local:git_forge.v1.GetTagRequest (var: Yt)
message GetTagRequest {
string repo_uuid = 1;
string tag_sha = 2;
}
// Copied from: local:git_forge.v1.GetTagResponse (var: TSv)
message GetTagResponse {
Tag tag = 1;
}
// Copied from: local:git_forge.v1.GetTreeBlameRequest (var: or)
message GetTreeBlameRequest {
string repo_uuid = 1;
string start_commit_sha = 2;
string path = 3;
}
// Copied from: local:git_forge.v1.GetTreeBlameResponse (var: KSv)
message GetTreeBlameResponse {
repeated TreeEntryBlame entries = 1;
}
// Copied from: local:git_forge.v1.GetTreeRequest (var: Wt)
message GetTreeRequest {
string repo_uuid = 1;
string tree_sha = 2;
bool recursive = 3;
}
// Copied from: local:git_forge.v1.GetTreeResponse (var: ASv)
message GetTreeResponse {
Tree tree = 1;
}
// Copied from: local:git_forge.v1.GrepLineKind (var: CSv)
enum GrepLineKind {
GREP_LINE_KIND_UNSPECIFIED = 0;
GREP_LINE_KIND_MATCH = 1;
GREP_LINE_KIND_CONTEXT = 2;
}
// Copied from: local:git_forge.v1.GrepMatch (var: JSv)
message GrepMatch {
string path = 1;
string lines = 2;
uint32 line_number = 3;
uint64 absolute_offset = 4;
repeated GrepSubmatch submatches = 5;
GrepLineKind kind = 6;
}
// Copied from: local:git_forge.v1.GrepRepoChunk (var: ewv)
message GrepRepoChunk {
repeated GrepMatch matches = 1;
bool limit_hit = 2;
}
// Copied from: local:git_forge.v1.GrepRepoRequest (var: pr)
message GrepRepoRequest {
string repo_uuid = 1;
string revision = 2;
string query = 3;
GrepSearchOptions options = 4;
uint32 max_results = 5;
}
// Copied from: local:git_forge.v1.GrepSearchOptions (var: ZSv)
message GrepSearchOptions {
bool literal = 1;
bool case_insensitive = 2;
bool whole_word = 3;
uint32 context_before = 4;
uint32 context_after = 5;
optional uint64 max_lines = 6;
optional string filter_path = 7;
repeated string includes = 8;
repeated string excludes = 9;
}
// Copied from: local:git_forge.v1.GrepSubmatch (var: XSv)
message GrepSubmatch {
uint32 start = 1;
uint32 end = 2;
}
// Copied from: local:git_forge.v1.ListCommitsInRangeRequest (var: tn)
message ListCommitsInRangeRequest {
string repo_uuid = 1;
string base_revision = 2;
string head_revision = 3;
optional int32 max_commits = 4;
bool oldest_first = 5;
ListCommitsSort sort = 6;
}
// Copied from: local:git_forge.v1.ListCommitsInRangeResponse (var: MSv)
message ListCommitsInRangeResponse {
repeated Commit commits = 1;
string base_commit_sha = 2;
string head_commit_sha = 3;
string merge_base_commit_sha = 4;
bool truncated = 5;
}
// Copied from: local:git_forge.v1.ListCommitsRequest (var: Zt)
message ListCommitsRequest {
string repo_uuid = 1;
string revision = 2;
int32 page = 3;
int32 per_page = 4;
ListCommitsSort sort = 5;
}
// Copied from: local:git_forge.v1.ListCommitsResponse (var: en)
message ListCommitsResponse {
repeated Commit commits = 1;
optional int32 next_page = 2;
}
// Copied from: local:git_forge.v1.ListCommitsSort (var: k)
enum ListCommitsSort {
LIST_COMMITS_SORT_UNSPECIFIED = 0;
LIST_COMMITS_SORT_COMMIT_TIME = 1;
LIST_COMMITS_SORT_TOPOLOGICAL = 2;
}
// Copied from: local:git_forge.v1.ListRefsFilter (var: AQd)
enum ListRefsFilter {
LIST_REFS_FILTER_UNSPECIFIED = 0;
LIST_REFS_FILTER_ALL = 1;
LIST_REFS_FILTER_BRANCHES = 2;
LIST_REFS_FILTER_TAGS = 3;
}
// Copied from: local:git_forge.v1.ListRefsRequest (var: Pr)
message ListRefsRequest {
string repo_uuid = 1;
ListRefsFilter filter = 2;
bool names_only = 3;
string prefix = 4;
optional uint32 limit = 5;
optional string page_token = 7;
}
// Copied from: local:git_forge.v1.ListRefsResponse (var: awv)
message ListRefsResponse {
repeated string refs = 1;
repeated RefInfo ref_infos = 2;
optional string next_page_token = 5;
}
// Copied from: local:git_forge.v1.ListTreePathsRequest (var: mr)
message ListTreePathsRequest {
string repo_uuid = 1;
string revision = 2;
repeated string includes = 3;
repeated string excludes = 4;
uint32 limit = 5;
}
// Copied from: local:git_forge.v1.ListTreePathsResponse (var: YSv)
message ListTreePathsResponse {
repeated string paths = 1;
bool has_more = 2;
}
// Copied from: local:git_forge.v1.MergeMode (var: EQd)
enum MergeMode {
MERGE_MODE_UNSPECIFIED = 0;
MERGE_MODE_MERGE_COMMIT = 1;
MERGE_MODE_SQUASH = 2;
}
// Copied from: local:git_forge.v1.NotifyRepoPushedRequest (var: Ar)
message NotifyRepoPushedRequest {
string repo_uuid = 1;
}
// Copied from: local:git_forge.v1.NotifyRepoPushedResponse (var: _r)
message NotifyRepoPushedResponse {
}
// Copied from: local:git_forge.v1.PathIdentifier (var: B$t)
message PathIdentifier {
string revision = 1;
string path = 2;
}
// Copied from: local:git_forge.v1.PrepareChangeMergeRequest (var: dn)
message PrepareChangeMergeRequest {
string repo_uuid = 1;
string base_ref = 2;
string head_ref = 3;
uint64 change_number = 4;
optional string expected_base_sha = 5;
optional string expected_head_sha = 6;
MergeMode mode = 7;
}
// Copied from: local:git_forge.v1.PrepareChangeMergeResponse (var: pn)
message PrepareChangeMergeResponse {
bool mergeable = 1;
optional string merged_tree_sha = 2;
optional string change_merge_ref = 3;
optional string dummy_commit_sha = 4;
}
// Copied from: local:git_forge.v1.PullRequestDiffChunk (var: nwv)
message PullRequestDiffChunk {
optional DiffHeader header = 1;
repeated DiffEntry entries = 3;
}
// Copied from: local:git_forge.v1.RebaseStackBranch (var: gn)
message RebaseStackBranch {
string head_ref = 1;
string expected_old_oid = 2;
optional string replay_base_oid = 3;
}
// Copied from: local:git_forge.v1.RebaseStackBranchUpdate (var: _n)
message RebaseStackBranchUpdate {
string head_ref = 1;
string old_oid = 2;
string new_oid = 3;
}
// Copied from: local:git_forge.v1.RebaseStackConflict (var: Tn)
message RebaseStackConflict {
string conflicted_head_ref = 1;
repeated string conflicted_paths = 2;
}
// Copied from: local:git_forge.v1.RebaseStackRequest (var: fn)
message RebaseStackRequest {
string repo_uuid = 1;
string onto_ref = 2;
optional string expected_onto_oid = 3;
repeated RebaseStackBranch branches = 4;
bool dry_run = 5;
}
// Copied from: local:git_forge.v1.RebaseStackResponse (var: hn)
message RebaseStackResponse {
oneof result {
RebaseStackSuccess success = 1;
RebaseStackConflict conflict = 2;
}
}
// Copied from: local:git_forge.v1.RebaseStackSuccess (var: An)
message RebaseStackSuccess {
string wal_entry_key = 1;
repeated RebaseStackBranchUpdate updates = 2;
}
// Copied from: local:git_forge.v1.RefInfo (var: MQd)
message RefInfo {
string name = 1;
string target_sha = 2;
string object_sha = 3;
string object_type = 4;
}
// Copied from: local:git_forge.v1.RepoContentDetails (var: $Sv)
message RepoContentDetails {
string type = 1;
optional uint64 size = 2;
bool is_binary = 3;
bool too_large_to_introspect = 4;
}
// Copied from: local:git_forge.v1.RepoContentEntry (var: twv)
message RepoContentEntry {
string type = 1;
string name = 2;
string path = 3;
string sha = 4;
optional uint64 size = 5;
}
// Copied from: local:git_forge.v1.ResolveRefPathRequest (var: Mn)
message ResolveRefPathRequest {
string repo_uuid = 1;
string ref_path = 2;
}
// Copied from: local:git_forge.v1.ResolveRefPathResponse (var: USv)
message ResolveRefPathResponse {
PathIdentifier path_identifier = 1;
string resolved_commit_sha = 2;
}
// Copied from: local:git_forge.v1.SearchRefsRequest (var: Lr)
message SearchRefsRequest {
string repo_uuid = 1;
ListRefsFilter filter = 2;
string query = 3;
optional uint32 limit = 4;
}
// Copied from: local:git_forge.v1.SearchRefsResponse (var: cwv)
message SearchRefsResponse {
repeated RefInfo ref_infos = 1;
bool exact_match = 2;
}
// Copied from: local:git_forge.v1.ShortCommit (var: fMn)
message ShortCommit {
bytes sha = 1;
string summary = 2;
string author_name = 3;
string author_email = 4;
int64 timestamp = 5;
}
// Copied from: local:git_forge.v1.Signature (var: o_t)
message Signature {
string name = 1;
string email = 2;
int64 timestamp = 3;
int32 timezone_offset = 4;
}
// Copied from: local:git_forge.v1.Tag (var: DSv)
message Tag {
string sha = 1;
string name = 2;
string message = 3;
Signature tagger = 4;
string object_sha = 5;
string object_type = 6;
}
// Copied from: local:git_forge.v1.Tree (var: ISv)
message Tree {
string sha = 1;
repeated TreeEntry tree = 2;
bool truncated = 3;
}
// Copied from: local:git_forge.v1.TreeEntry (var: RSv)
message TreeEntry {
string path = 1;
string mode = 2;
string type = 3;
string sha = 4;
optional uint64 size = 5;
}
// Copied from: local:git_forge.v1.TreeEntryBlame (var: qSv)
message TreeEntryBlame {
string name = 1;
ShortCommit last_commit = 2;
}
+41
View File
@@ -0,0 +1,41 @@
syntax = "proto3";
package internapi.v1;
option go_package = "github.com/leookun/cursor-byok/cursor-proto/gen/internapi/v1;internapiv1";
// Copied from: local:internapi.v1.BlobData (var: RLs)
message BlobData {
BlobType blob_type = 1;
bytes blob_id = 2;
int32 index = 3;
oneof type_specific_data {
ImageBlobData image_data = 4;
}
}
// Copied from: local:internapi.v1.BlobDataPerMessage (var: ocu)
message BlobDataPerMessage {
repeated BlobData blob_data = 1;
}
// Copied from: local:internapi.v1.BlobType (var: rcu)
enum BlobType {
BLOB_TYPE_UNSPECIFIED = 0;
BLOB_TYPE_IMAGE = 1;
BLOB_TYPE_INVOCATION_CONTEXT = 2;
BLOB_TYPE_EXTRA_CONTEXT = 3;
BLOB_TYPE_GIT_PR_DIFF_SELECTION = 4;
BLOB_TYPE_SELECTED_PULL_REQUEST = 5;
BLOB_TYPE_TEXT = 6;
BLOB_TYPE_RICH_TEXT = 7;
BLOB_TYPE_EXTERNAL_LINK_PDF = 8;
BLOB_TYPE_DOCUMENT = 9;
BLOB_TYPE_VIDEO = 10;
}
// Copied from: local:internapi.v1.ImageBlobData (var: scu)
message ImageBlobData {
string mime_type = 1;
}
File diff suppressed because it is too large Load Diff
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>
+3 -2
View File
@@ -41,8 +41,9 @@ impl App {
config.provider_request_timeout,
));
let run_registry = RunRegistry::default();
let registry = CursorSessionRegistry::new(store.clone(), provider, compiler, run_registry);
let control = control::ControlService::new(store.clone())?;
let registry =
CursorSessionRegistry::new(store.clone(), provider.clone(), compiler, run_registry);
let control = control::ControlService::new(store.clone(), provider)?;
let harness = control.cursor_harness().clone();
let mut router = handlers::router(registry.clone())?;
router = match &config.console {
+15 -2
View File
@@ -22,7 +22,8 @@ use tower_http::{
use url::{Host, Url};
pub use service::{
CallDetail, CallSummary, ControlService, DiscoveredModels, ObservabilitySettings,
CallDetail, CallSummary, ControlService, DiscoveredModels, ModelConnectivityResult,
ObservabilitySettings,
};
pub fn web_router(service: ControlService, assets: impl AsRef<std::path::Path>) -> Router {
@@ -137,6 +138,10 @@ pub fn api_router(service: ControlService) -> Router {
"/__byok-api__/api/models/{model_hash}",
put(models::update).delete(models::remove),
)
.route(
"/__byok-api__/api/models/{model_hash}/test",
post(models::test),
)
.route("/__byok-api__/api/llm-calls", get(calls::list))
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
.route(
@@ -155,6 +160,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),
@@ -242,7 +251,11 @@ mod tests {
))
.await
.unwrap();
let router = api_router(ControlService::new(store).unwrap());
let provider = std::sync::Arc::new(crate::provider::ProviderRouter::new(
store.clone(),
std::time::Duration::from_secs(300),
));
let router = api_router(ControlService::new(store, provider).unwrap());
let response = router
.clone()
+8 -1
View File
@@ -10,7 +10,7 @@ use crate::{
Result,
};
use super::{ControlService, DiscoveredModels};
use super::{ControlService, DiscoveredModels, ModelConnectivityResult};
#[derive(Deserialize)]
pub struct SaveModels {
@@ -48,6 +48,13 @@ pub async fn update(
Ok(Json(service.update_model(&model_hash, &input).await?))
}
pub async fn test(
State(service): State<ControlService>,
Path(model_hash): Path<String>,
) -> Result<Json<ModelConnectivityResult>> {
Ok(Json(service.test_model(&model_hash).await?))
}
pub async fn discover(
State(service): State<ControlService>,
Path(provider_id): Path<i64>,
+283 -6
View File
@@ -1,8 +1,10 @@
use std::collections::BTreeSet;
use std::{collections::BTreeSet, sync::Arc, time::Instant};
use base64::{engine::general_purpose::STANDARD, Engine};
use futures_util::StreamExt;
use reqwest::header::{HeaderName, HeaderValue};
use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken;
use url::Url;
use super::ads::{
@@ -13,11 +15,15 @@ use super::ads::{
use crate::{
harness::CursorHarness,
model::{
CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest, LlmCallResponseChunk,
LlmCallSummary, Overview, ProviderEndpoint, ProviderEndpointInput, ProviderEndpointSecret,
ProviderModel, ProviderModelInput, ProviderType,
ContentPart, CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest,
LlmCallResponseChunk, LlmCallSummary, ModelInvocation, ModelRequest, ModelSpec, Overview,
ProjectedContent, ProjectedMessage, PromptSpec, ProviderEndpoint, ProviderEndpointInput,
ProviderEndpointSecret, ProviderModel, ProviderModelInput, ProviderType, Role,
},
provider::{ModelEvent, Provider},
store::{
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store, TabSettings,
},
store::{PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store},
Error, Result,
};
@@ -25,6 +31,7 @@ use crate::{
pub struct ControlService {
store: Store,
cursor_harness: CursorHarness,
provider: Arc<dyn Provider>,
}
#[derive(Clone, Debug, Serialize)]
@@ -32,6 +39,16 @@ pub struct DiscoveredModels {
pub models: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ModelConnectivityResult {
pub duration_ms: u64,
pub first_text_ms: Option<u64>,
pub output_tokens: u64,
pub tokens_per_second: f64,
pub tokens_estimated: bool,
pub output: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct CallDetail {
pub call: CallSummary,
@@ -72,10 +89,11 @@ pub struct ObservabilitySettings {
}
impl ControlService {
pub fn new(store: Store) -> Result<Self> {
pub fn new(store: Store, provider: Arc<dyn Provider>) -> Result<Self> {
Ok(Self {
cursor_harness: CursorHarness::new(store.clone())?,
store,
provider,
})
}
@@ -200,6 +218,125 @@ impl ControlService {
self.store.update_provider_model(model_hash, input).await
}
pub async fn test_model(&self, model_hash: &str) -> Result<ModelConnectivityResult> {
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
const TEST_PROMPT: &str = "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation.";
let configured = self
.store
.provider_model(model_hash)
.await?
.ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?;
let mut model = ModelSpec::new(model_hash);
if configured.reasoning_enabled {
model.reasoning.enabled = true;
model.reasoning.effort = Some(
configured
.reasoning_effort
.filter(|effort| !effort.trim().is_empty())
.unwrap_or_else(|| "medium".into()),
);
}
let test_id = format!("model-test-{}", uuid::Uuid::new_v4());
let call_id = test_id.clone();
let invocation = ModelInvocation {
call_id: test_id.clone(),
run_id: test_id.clone(),
conversation_id: test_id,
provider_call_index: 0,
request: ModelRequest {
prompt: PromptSpec {
instructions: String::new(),
tools: Vec::new(),
},
model,
history: vec![ProjectedMessage {
message_id: "connectivity-test".into(),
role: Role::User,
content: ProjectedContent::Parts(vec![ContentPart::Text {
text: TEST_PROMPT.into(),
}]),
}],
},
};
let cancellation = CancellationToken::new();
let started = Instant::now();
let mut first_text_at = None;
let mut output_tokens = None;
let mut output = String::new();
let stream = self.provider.stream(invocation, cancellation.clone());
let completed = tokio::time::timeout(TEST_TIMEOUT, async {
futures_util::pin_mut!(stream);
let mut finished = false;
while let Some(event) = stream.next().await {
match event? {
ModelEvent::TextDelta(delta) => {
if first_text_at.is_none() && !delta.trim().is_empty() {
first_text_at = Some(Instant::now());
}
output.push_str(&delta);
}
ModelEvent::Usage(usage) => {
if let Some(tokens) = usage.output_tokens.filter(|tokens| *tokens > 0) {
output_tokens = Some(
output_tokens.map_or(tokens, |current: u64| current.max(tokens)),
);
}
}
ModelEvent::Done(_) => finished = true,
_ => {}
}
}
if !finished {
return Err(Error::Protocol(
"provider stream ended without Done during connectivity test".into(),
));
}
Ok(())
})
.await;
match completed {
Ok(result) => result?,
Err(_) => {
cancellation.cancel();
self.store
.finish_llm_call(
&call_id,
"error",
None,
started.elapsed().as_millis().min(i64::MAX as u128) as i64,
Some("timeout"),
Some("model connectivity test timed out after 45 seconds"),
)
.await?;
return Err(Error::Provider(
"model connectivity test timed out after 45 seconds".into(),
));
}
}
let elapsed = started.elapsed();
let output = output.trim().to_string();
let tokens_estimated = output_tokens.is_none();
let output_tokens = output_tokens.unwrap_or_else(|| estimate_output_tokens(&output));
Ok(ModelConnectivityResult {
duration_ms: elapsed.as_millis().min(u128::from(u64::MAX)) as u64,
first_text_ms: first_text_at.map(|first| {
first
.duration_since(started)
.as_millis()
.min(u128::from(u64::MAX)) as u64
}),
output_tokens,
tokens_per_second: if elapsed.is_zero() {
0.0
} else {
output_tokens as f64 / elapsed.as_secs_f64()
},
tokens_estimated,
output,
})
}
pub async fn create_provider_with_models(
&self,
provider: &ProviderEndpointInput,
@@ -372,6 +509,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 {
@@ -543,6 +688,17 @@ fn model_ids(value: &serde_json::Value) -> Vec<String> {
.collect()
}
fn estimate_output_tokens(output: &str) -> u64 {
let words = output.split_whitespace().count() as u64;
if words > 0 {
words
} else if output.is_empty() {
0
} else {
((output.chars().count() as u64) + 3) / 4
}
}
fn apply_custom_headers(
mut request: reqwest::RequestBuilder,
headers: &serde_json::Value,
@@ -562,3 +718,124 @@ fn apply_custom_headers(
}
Ok(request)
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use tokio_util::sync::CancellationToken;
use crate::{
model::{
ModelInvocation, ProjectedContent, ProviderEndpointInput, ProviderModelInput,
ProviderType,
},
provider::{FinishReason, ModelEvent, Provider, ProviderStream},
store::Store,
};
use super::ControlService;
struct TestProvider {
invocation: Arc<Mutex<Option<ModelInvocation>>>,
}
impl Provider for TestProvider {
fn stream(
&self,
invocation: ModelInvocation,
_cancellation: CancellationToken,
) -> ProviderStream {
*self.invocation.lock().unwrap() = Some(invocation);
Box::pin(futures_util::stream::iter([
Ok(ModelEvent::Start {
model_call_id: "test-call".into(),
}),
Ok(ModelEvent::TextStart),
Ok(ModelEvent::TextDelta("OK".into())),
Ok(ModelEvent::TextEnd),
Ok(ModelEvent::Usage(crate::model::Usage {
output_tokens: Some(2),
..Default::default()
})),
Ok(ModelEvent::Done(FinishReason::Stop)),
]))
}
}
#[tokio::test]
async fn connectivity_test_uses_the_configured_llm_provider() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("control.db").display()
))
.await
.unwrap();
let invocation = Arc::new(Mutex::new(None));
let provider = store
.create_provider(&ProviderEndpointInput {
name: "Test".into(),
provider_type: ProviderType::OpenAiResponses,
base_url: "https://example.com/v1".into(),
api_key: None,
custom_headers: serde_json::json!({}),
extra_params: serde_json::json!({}),
})
.await
.unwrap();
let model = store
.save_provider_model(
provider.provider_id,
&ProviderModelInput {
model_id: "reasoning-model".into(),
display_name: "Reasoning Model".into(),
endpoint_type: ProviderType::OpenAiResponses,
request_url: String::new(),
enabled: true,
sort_order: 0,
context_window_tokens: None,
max_output_tokens: None,
reasoning_enabled: true,
reasoning_effort: None,
supports_image_generation: false,
},
)
.await
.unwrap();
let service = ControlService::new(
store,
Arc::new(TestProvider {
invocation: invocation.clone(),
}),
)
.unwrap();
let result = service.test_model(&model.model_hash).await.unwrap();
assert_eq!(result.output, "OK");
assert_eq!(result.output_tokens, 2);
assert!(!result.tokens_estimated);
assert!(result.tokens_per_second > 0.0);
let invocation = invocation.lock().unwrap().clone().unwrap();
assert_eq!(invocation.request.model.model_id, model.model_hash);
assert!(invocation.request.model.reasoning.enabled);
assert_eq!(
invocation.request.model.reasoning.effort.as_deref(),
Some("medium")
);
assert!(invocation.request.prompt.tools.is_empty());
assert_eq!(invocation.request.history.len(), 1);
assert!(matches!(
&invocation.request.history[0].content,
ProjectedContent::Parts(parts)
if matches!(&parts[..], [crate::model::ContentPart::Text { text }] if text == "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation.")
));
}
#[test]
fn connectivity_output_token_estimate_handles_words_and_empty_text() {
assert_eq!(super::estimate_output_tokens("1 2 3"), 3);
assert_eq!(super::estimate_output_tokens(""), 0);
}
}
+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?))
}
+7
View File
@@ -1,6 +1,7 @@
use prost::Message;
use crate::{
cursor::interaction,
cursor::proto::{agent::v1 as agent, aiserver::v1 as ai},
cursor::{CursorCommand, CursorParent, CursorSessionRegistry},
Error, Result,
@@ -166,6 +167,12 @@ pub async fn append(
if let Some(parent) = parent {
handle.set_parent(parent)?;
}
if matches!(
request.message.message.as_ref(),
Some(agent::agent_client_message::Message::ClientHeartbeat(_))
) {
handle.emit(&interaction::heartbeat())?;
}
handle
.command(CursorCommand::Append {
seqno: request.seqno,
+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();
}
}
+68 -4
View File
@@ -459,24 +459,36 @@ pub fn dynamic_mcp(
Error::Protocol(format!("MCP tool {} is missing input schema", wire.name))
})?),
};
let name = model_tool_name(&wire.name);
let definition = ToolDefinition {
name: wire.name.clone(),
name: name.clone(),
description: wire.description.clone(),
parameters,
};
if output
.insert(wire.name.clone(), (wire.clone(), definition))
.insert(name.clone(), (wire.clone(), definition))
.is_some()
{
return Err(Error::Protocol(format!(
"duplicate MCP tool definition: {}",
wire.name
"duplicate MCP tool name after normalization: {name}"
)));
}
}
Ok(output)
}
fn model_tool_name(name: &str) -> String {
name.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
character
} else {
'_'
}
})
.collect()
}
fn prost_value(value: &prost_types::Value) -> Value {
use prost_types::value::Kind;
match value.kind.as_ref() {
@@ -511,6 +523,58 @@ fn xml(value: &str) -> String {
mod tests {
use super::*;
fn direct_mcp_tool(name: &str) -> pb::McpToolDefinition {
pb::McpToolDefinition {
name: name.into(),
provider_identifier: "extension-GitKraken".into(),
tool_name: "git_status".into(),
description: "Get repository status".into(),
input_schema_json: Some(r#"{"type":"object"}"#.into()),
..Default::default()
}
}
#[test]
fn dynamic_mcp_normalizes_extension_identifier_for_model_tool_names() {
let original = "user-eamodio.gitlens-extension-GitKraken-git_status";
let request = pb::AgentRunRequest {
mcp_tools: Some(pb::McpTools {
mcp_tools: vec![direct_mcp_tool(original)],
}),
..Default::default()
};
let tools = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap();
let normalized = "user-eamodio_gitlens-extension-GitKraken-git_status";
let (wire, definition) = tools.get(normalized).unwrap();
assert_eq!(definition.name, normalized);
assert_eq!(wire.name, original);
assert_eq!(wire.provider_identifier, "extension-GitKraken");
assert_eq!(wire.tool_name, "git_status");
assert!(normalized
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')));
}
#[test]
fn dynamic_mcp_rejects_names_that_collide_after_normalization() {
let request = pb::AgentRunRequest {
mcp_tools: Some(pb::McpTools {
mcp_tools: vec![
direct_mcp_tool("server.name-tool"),
direct_mcp_tool("server_name-tool"),
],
}),
..Default::default()
};
let error = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap_err();
assert!(error
.to_string()
.contains("duplicate MCP tool name after normalization: server_name-tool"));
}
#[test]
fn meta_mcp_routes_projects_descriptor_routing_without_runtime_discovery() {
let context = pb::RequestContext {
+26 -6
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,9 +223,10 @@ 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![
(Some(user), Some(event_id)) => {
let runtime = match existing_runtime {
Some(message) => message,
None => {
runtime::compile(
event_id,
checkpoint_mode,
@@ -221,9 +236,14 @@ pub(crate) async fn prepare(
compiler,
blob_sync,
)
.await?,
],
},
.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
));
}
}
+110 -17
View File
@@ -27,6 +27,12 @@ struct ResponseToolState {
ended: bool,
}
enum ResponseToolArguments<'a> {
None,
Delta(&'a str),
Snapshot(&'a str),
}
pub struct OpenAiResponsesProvider {
client: reqwest::Client,
config: ProviderConfig,
@@ -140,11 +146,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" => {
@@ -152,7 +158,7 @@ impl Provider for OpenAiResponsesProvider {
if item.get("type").and_then(Value::as_str) == Some("function_call") {
let index = required_u64(&value, "output_index")? as usize;
saw_tool = true;
for event in update_response_tool(index, item, None, false, &mut tools)? { yield event; }
for event in update_response_tool(index, item, ResponseToolArguments::None, false, &mut tools)? { yield event; }
}
}
"response.output_item.done" => {
@@ -172,8 +178,11 @@ impl Provider for OpenAiResponsesProvider {
Some("function_call") => {
let index = required_u64(&value, "output_index")? as usize;
saw_tool = true;
let final_arguments = item.get("arguments").and_then(Value::as_str);
for event in update_response_tool(index, item, final_arguments, true, &mut tools)? { yield event; }
let arguments = item
.get("arguments")
.and_then(Value::as_str)
.map_or(ResponseToolArguments::None, ResponseToolArguments::Snapshot);
for event in update_response_tool(index, item, arguments, true, &mut tools)? { yield event; }
}
_ => {}
}
@@ -182,13 +191,16 @@ impl Provider for OpenAiResponsesProvider {
let index = required_u64(&value, "output_index")? as usize;
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
saw_tool = true;
for event in update_response_tool(index, &Value::Null, Some(delta), false, &mut tools)? { yield event; }
for event in update_response_tool(index, &Value::Null, ResponseToolArguments::Delta(delta), false, &mut tools)? { yield event; }
}
}
"response.function_call_arguments.done" => {
let index = required_u64(&value, "output_index")? as usize;
let final_arguments = value.get("arguments").and_then(Value::as_str);
for event in update_response_tool(index, &Value::Null, final_arguments, true, &mut tools)? { yield event; }
let arguments = value
.get("arguments")
.and_then(Value::as_str)
.map_or(ResponseToolArguments::None, ResponseToolArguments::Snapshot);
for event in update_response_tool(index, &Value::Null, arguments, true, &mut tools)? { yield event; }
}
"response.completed" => {
if let Some(usage) = value.pointer("/response/usage") { yield ModelEvent::Usage(responses_usage(usage)); }
@@ -208,8 +220,11 @@ impl Provider for OpenAiResponsesProvider {
}
Some("function_call") => {
saw_tool = true;
let final_arguments = item.get("arguments").and_then(Value::as_str);
for event in update_response_tool(index, item, final_arguments, true, &mut tools)? { yield event; }
let arguments = item
.get("arguments")
.and_then(Value::as_str)
.map_or(ResponseToolArguments::None, ResponseToolArguments::Snapshot);
for event in update_response_tool(index, item, arguments, true, &mut tools)? { yield event; }
}
_ => {}
}
@@ -303,7 +318,7 @@ fn reconcile_response_text(
fn update_response_tool(
index: usize,
item: &Value,
arguments: Option<&str>,
arguments: ResponseToolArguments<'_>,
done: bool,
tools: &mut std::collections::BTreeMap<usize, ResponseToolState>,
) -> Result<Vec<ModelEvent>> {
@@ -314,11 +329,17 @@ fn update_response_tool(
if let Some(name) = item.get("name").and_then(Value::as_str) {
tool.name.get_or_insert_with(|| name.into());
}
if let Some(arguments) = arguments {
if arguments.starts_with(&tool.arguments) {
tool.arguments.push_str(&arguments[tool.arguments.len()..]);
} else if !tool.arguments.ends_with(arguments) {
tool.arguments.push_str(arguments);
match arguments {
ResponseToolArguments::None => {}
ResponseToolArguments::Delta(delta) => tool.arguments.push_str(delta),
ResponseToolArguments::Snapshot(snapshot) if snapshot == tool.arguments => {}
ResponseToolArguments::Snapshot(snapshot) if snapshot.starts_with(&tool.arguments) => {
tool.arguments.push_str(&snapshot[tool.arguments.len()..]);
}
ResponseToolArguments::Snapshot(_) => {
return Err(Error::Provider(
"OpenAI Responses final tool arguments do not match streamed arguments".into(),
));
}
}
@@ -453,6 +474,7 @@ fn responses_content(parts: &[ContentPart], text_type: &str) -> Result<Vec<Value
ContentPart::Text { text } => Some(Ok(json!({"type":text_type, "text":text}))),
ContentPart::Image { mime_type, data } => Some(Ok(json!({
"type":"input_image",
"detail":"auto",
"image_url":format!("data:{mime_type};base64,{}", STANDARD.encode(data)),
}))),
})
@@ -508,8 +530,11 @@ fn responses_usage(value: &Value) -> Usage {
#[cfg(test)]
mod tests {
use super::responses_input;
use std::collections::BTreeMap;
use super::{responses_input, update_response_tool, ResponseToolArguments, ResponseToolState};
use crate::model::{ContentPart, ProjectedContent, ProjectedMessage, Role, ToolResultContent};
use crate::provider::ModelEvent;
#[test]
fn read_image_stays_in_its_function_call_output() {
@@ -539,5 +564,73 @@ mod tests {
assert_eq!(input[0]["call_id"], "call");
assert_eq!(input[0]["output"][0]["type"], "input_text");
assert_eq!(input[0]["output"][1]["type"], "input_image");
assert_eq!(input[0]["output"][1]["detail"], "auto");
}
#[test]
fn tool_argument_deltas_are_ordered_bytes_and_final_snapshots_are_idempotent() {
let item = serde_json::json!({"call_id": "call-1", "name": "Shell"});
let mut tools = BTreeMap::<usize, ResponseToolState>::new();
let mut events = update_response_tool(
0,
&item,
ResponseToolArguments::Delta(r#"{"block_until_ms":300"#),
false,
&mut tools,
)
.unwrap();
events.extend(
update_response_tool(
0,
&item,
ResponseToolArguments::Delta("00"),
false,
&mut tools,
)
.unwrap(),
);
events.extend(
update_response_tool(
0,
&item,
ResponseToolArguments::Delta("}"),
false,
&mut tools,
)
.unwrap(),
);
events.extend(
update_response_tool(
0,
&item,
ResponseToolArguments::Snapshot(r#"{"block_until_ms":30000}"#),
true,
&mut tools,
)
.unwrap(),
);
let arguments = events
.iter()
.filter_map(|event| match event {
ModelEvent::ToolCallArgumentsDelta { delta, .. } => Some(delta.as_str()),
_ => None,
})
.collect::<String>();
assert_eq!(arguments, r#"{"block_until_ms":30000}"#);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&arguments).unwrap()["block_until_ms"],
30000
);
assert!(update_response_tool(
0,
&item,
ResponseToolArguments::Snapshot(r#"{"block_until_ms":30000}"#),
true,
&mut tools,
)
.unwrap()
.is_empty());
}
}
+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());
}
}
+53
View File
@@ -146,6 +146,59 @@ 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();
cursor_server::cursor::bidi_append::append(
&registry,
cursor_server::cursor::bidi_append::DecodedAppend {
request_id: "heartbeat-run".into(),
// A transport heartbeat must not wait for missing application messages.
seqno: 1,
message: pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::ClientHeartbeat(
pb::ClientHeartbeat {},
)),
},
},
None,
)
.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()),
+64
View File
@@ -305,6 +305,7 @@ async fn openai_responses_raw_stream_does_not_invent_reasoning_effort() {
assert!(body.get("service_tier").is_none());
assert_eq!(body["max_output_tokens"], 4096);
assert_eq!(body["input"][0]["content"][1]["type"], "input_image");
assert_eq!(body["input"][0]["content"][1]["detail"], "auto");
assert_eq!(
body["input"][0]["content"][1]["image_url"],
"data:image/png;base64,AQID"
@@ -329,6 +330,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(
@@ -392,6 +424,38 @@ async fn openai_responses_item_done_closes_text_and_tool_arguments() {
);
}
#[tokio::test]
async fn openai_responses_preserves_delta_that_repeats_the_streamed_suffix() {
let (base_url, _requests, server) = fixture_server(
"/v1/responses",
concat!(
"data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"Shell\"}}\n\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"block_until_ms\\\":300\"}\n\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"00\"}\n\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"}\"}\n\n",
"data: {\"type\":\"response.function_call_arguments.done\",\"output_index\":0,\"arguments\":\"{\\\"block_until_ms\\\":30000}\"}\n\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"Shell\",\"arguments\":\"{\\\"block_until_ms\\\":30000}\"}}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{}}\n\n",
),
)
.await;
let provider = OpenAiResponsesProvider::new(
reqwest::Client::new(),
config(ProviderKind::OpenAiResponses, base_url, None),
);
let (sender, _receiver) = tokio::sync::mpsc::channel(32);
let result = consume_model_cycle(
provider.stream(invocation(), CancellationToken::new()),
&sender,
&CancellationToken::new(),
)
.await;
server.abort();
assert_eq!(result.unwrap().calls[0].arguments["block_until_ms"], 30000);
}
#[tokio::test]
async fn openai_responses_completed_object_recovers_missing_item_events() {
let (base_url, _requests, server) = fixture_server(
+129 -12
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,7 +75,8 @@ 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 {
match message.message {
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
handle
.command(CursorCommand::Append {
seqno,
@@ -75,6 +86,11 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message
.unwrap();
seqno += 1;
}
Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(state)) => {
checkpoint = Some(state);
}
_ => {}
}
}
let requests = provider.requests();
@@ -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