Compare commits

..
4 Commits
Author SHA1 Message Date
leokun f6be6c2b27 release: v0.1.3 2026-08-26 22:09:36 +08:00
leokun ded534fecf feat: update UI 2026-08-26 22:01:14 +08:00
leokun 5450fc76e2 fix: shell 2026-08-26 20:20:38 +08:00
leokun df053c3720 fix: harden concurrent persistence and task recovery 2026-08-26 16:46:41 +08:00
74 changed files with 3150 additions and 395 deletions
+20
View File
@@ -213,6 +213,26 @@ jobs:
--output legacy-update/update.json \
--notes "Cursor BYOK v${VERSION}"
- name: Normalize Tauri updater download URLs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p tauri-update
gh release download "v${VERSION}" --pattern latest.json --dir tauri-update
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" > tauri-update/release.json
node scripts/release/normalize-tauri-update.mjs \
--manifest tauri-update/latest.json \
--release tauri-update/release.json \
--repository "${GITHUB_REPOSITORY}" \
--version "${VERSION}"
- name: Upload normalized Tauri updater manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: gh release upload "v${VERSION}" tauri-update/latest.json --clobber
- name: Upload legacy updater assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Generated
+1 -1
View File
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"axum",
"cursor-server",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.2",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.2",
"version": "0.1.3",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.2",
"version": "0.1.3",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.2"
version = "0.1.3"
edition = "2021"
publish = false
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+2 -1
View File
@@ -291,7 +291,8 @@ export const api = {
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
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" }),
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
const params = new URLSearchParams();
if (filter) {
@@ -1,5 +1,7 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { init, Rect, type ElementEvent } from "zrender";
import type { Locale } from "../../i18n/runtime";
import { useI18n } from "../../i18n/store";
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
import styles from "./ContributionCalendarChart.module.scss";
@@ -54,8 +56,6 @@ const CALENDAR_CONFIG = {
axisLabelGap: 8,
axisLabelWidth: 28,
} as const;
const tokenFormatter = new Intl.NumberFormat("zh-CN");
function parseDate(date: string) {
return new Date(`${date}T00:00:00Z`);
}
@@ -72,9 +72,10 @@ function isCellExtra(value: unknown): value is CellExtra {
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
}
function buildCalendarLayout(data: ContributionDay[]) {
function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
if (data.length === 0) return null;
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
const firstDate = parseDate(data[0].date);
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
@@ -88,20 +89,22 @@ function buildCalendarLayout(data: ContributionDay[]) {
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
const date = parseDate(cell.date);
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: `${date.getUTCMonth() + 1}月`, column: cell.column });
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: monthFormatter.format(date), column: cell.column });
return ticks;
}, []);
return { cells, columnCount, monthTicks };
}
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
const { locale } = useI18n();
const scrollerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
const scheduleDrawRef = useRef<() => void>(() => undefined);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
const layout = useMemo(() => buildCalendarLayout(data), [data]);
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
layoutRef.current = layout;
useLayoutEffect(() => {
@@ -1,25 +1,26 @@
import type { IconifyIcon } from "@iconify/react/offline";
import { useEffect, useRef } from "react";
import Sortable from "sortablejs";
import type { Model } from "../../api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Icon } from "../ui/Icon";
import { claudeIcon, dragIcon, openAiIcon } from "../ui/icons";
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../ui/icons";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
export function CursorModelCards({
models,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: {
export type CursorModelGrouping = "flat" | "provider" | "type";
export type CursorModelGroup = {
key: string;
label: string;
icon: IconifyIcon;
models: Model[];
};
type CursorModelCardsProps = {
models: Model[];
grouping: CursorModelGrouping;
disabled: boolean;
testingModelHashes: Set<string>;
testResults: Map<string, CursorModelTestState>;
@@ -28,7 +29,54 @@ export function CursorModelCards({
onDuplicate: (model: Model) => void;
onDelete: (model: Model) => void;
onReorder: (modelHashes: string[]) => void;
}) {
};
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
sortable: boolean;
};
export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModelGrouping, "flat">): CursorModelGroup[] {
const groups = new Map<string, CursorModelGroup>();
for (const model of models) {
const descriptor = grouping === "provider" ? providerGroup(model) : typeGroup(model);
const group = groups.get(descriptor.key);
if (group) {
group.models.push(model);
} else {
groups.set(descriptor.key, { ...descriptor, models: [model] });
}
}
return [...groups.values()];
}
export function CursorModelCards(props: CursorModelCardsProps) {
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
<ModelGrid {...props} sortable />
</div>;
return <div className={styles.modelGroups}>
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
<div className={styles.modelGroupHeader}>
<Icon icon={group.icon} size="1.1em" />
<span>{group.label}</span>
</div>
<ModelGrid {...props} models={group.models} sortable={false} />
</section>)}
</div>;
}
function ModelGrid({
models,
sortable: sortableEnabled,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: ModelGridProps) {
const grid = useRef<HTMLDivElement>(null);
const sortable = useRef<Sortable | null>(null);
const currentModels = useRef(models);
@@ -37,7 +85,7 @@ export function CursorModelCards({
reorder.current = onReorder;
useEffect(() => {
if (!grid.current) return;
if (!sortableEnabled || !grid.current) return;
sortable.current = Sortable.create(grid.current, {
animation: 160,
dataIdAttr: "data-model-hash",
@@ -72,7 +120,7 @@ export function CursorModelCards({
sortable.current?.destroy();
sortable.current = null;
};
}, []);
}, [sortableEnabled]);
useEffect(() => {
sortable.current?.option("disabled", disabled);
@@ -84,14 +132,14 @@ export function CursorModelCards({
const result = testResults.get(model.model_hash);
const testing = testingModelHashes.has(model.model_hash);
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
<button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
{sortableEnabled && <button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
<Icon icon={dragIcon} size="1.25em" />
</button>
</button>}
<div className={styles.modelCardContent}>
<div className={styles.modelCardTop}>
<div className={styles.modelCardName}>
<strong>{model.display_name}</strong>
<span>{model.model_id}</span>
<span className={styles.modelCardNameText}>{model.display_name}</span>
<span className={styles.modelCardModelId}>{model.model_id}</span>
</div>
<span className={styles.modelTypeBadge}>
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
@@ -102,7 +150,7 @@ export function CursorModelCards({
<CursorModelTestResult state={result} testing={testing} />
</div>
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled} onClick={() => onTest(model)}>{testing ? t("测试中…") : t("测试")}</Button>
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
@@ -112,3 +160,27 @@ export function CursorModelCards({
})}
</div>;
}
function providerGroup(model: Model) {
const label = providerDomain(model.base_url);
return { key: label, label, icon: flatColorOrganizationIcon };
}
function providerDomain(baseUrl: string) {
const value = baseUrl.trim();
try {
return new URL(value).hostname.toLowerCase() || value;
} catch {
try {
return new URL(`https://${value}`).hostname.toLowerCase() || value;
} catch {
return value;
}
}
}
function typeGroup(model: Model) {
if (model.type === "anthropic") return { key: "anthropic", label: "Anthropic", icon: claudeIcon };
if (model.openai_endpoint === "/v1/chat/completions") return { key: "openai-chat", label: "OpenAI Chat", icon: openAiIcon };
return { key: "openai-responses", label: "OpenAI Responses", icon: openAiIcon };
}
@@ -24,7 +24,7 @@ export const emptyCursorModelDraft = (): CursorModelDraft => ({
base_url: "",
use_full_url: false,
api_key: "",
tooltip_data: t("备注"),
tooltip_data: "",
model_id: "",
reasoning_effort: null,
openai_endpoint: "/v1/responses",
@@ -88,17 +88,17 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={modelOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput placeholder={t("例如:主力模型")} value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput placeholder={t("请输入模型备注")} value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
{draft.model.type === "openai" ? <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("推理强度")}> <Select ariaLabel={t("推理强度")} value={draft.model.reasoning_effort ?? ""} options={effortOptions(true)} onChange={(value) => setModel({ reasoning_effort: value || null })} /></FormField>
</> : <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("思考强度")}> <Select ariaLabel={t("思考强度")} value={draft.model.anthropic_thinking_effort ?? "xhigh"} options={effortOptions(false)} onChange={(anthropic_thinking_effort) => setModel({ anthropic_thinking_effort })} /></FormField>
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用 adaptive thinking")} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
</>}
<ToggleJsonField
@@ -6,11 +6,13 @@ import styles from "./CursorModelTestResult.module.scss";
export type CursorModelTestState =
| { status: "success"; result: ModelConnectivityResult }
| { status: "error"; error: string };
| { status: "error"; error: string }
| { status: "cancelled" };
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
const success = state.status === "success";
const summary = success
@@ -5,6 +5,29 @@
gap: 16px;
}
.groupActions {
display: flex;
align-items: center;
overflow: hidden;
border-radius: 5px;
button {
height: 25px;
padding: 0 8px;
color: var(--vscode-descriptionForeground);
background: transparent;
border: 0;
border-right: 1px solid var(--vscode-sideBar-border);
font-size: type.$font-size-xs;
white-space: nowrap;
&:last-child { border-right: 0; }
&:hover:not(:disabled) { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
}
.gateActions {
display: flex;
align-items: center;
@@ -39,13 +62,34 @@
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
}
.modelGroups {
display: flex;
flex-direction: column;
gap: 20px;
padding-top: 10px;
}
.modelGroup {
display: flex;
flex-direction: column;
gap: 10px;
}
.modelGroupHeader {
display: flex;
align-items: center;
gap: 6px;
color: var(--vscode-foreground);
font-size: type.$font-size-base;
opacity: 0.6;
padding-left: 6px;
}
.modelCard {
position: relative;
padding: 16px;
}
.modelCardContent {
height: 150px;
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: space-between;
@@ -64,19 +108,19 @@
flex-direction: column;
gap: 4px;
strong,
span {
.modelCardNameText,
.modelCardModelId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
.modelCardNameText {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
}
span {
.modelCardModelId {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
font-size: type.$font-size-2xs;
}
}
.modelTypeBadge {
@@ -15,8 +15,8 @@
top: var(--app-content-top);
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
margin: 0;
padding-left: 10px;
font-size: type.$font-size-lg;
padding-left:0;
font-size: type.$font-size-xxl;
font-weight: bolder;
transform: translateY(-150%);
-webkit-user-select: none;
@@ -26,8 +26,8 @@
top: 0;
margin: 0;
padding-left: 10px;
font-size: type.$font-size-lg;
padding-left: 0;
font-size: type.$font-size-xxl;
font-weight: bolder;
transform: translateY(-150%);
}
@@ -18,7 +18,7 @@
border-radius: 5px;
button {
height: 30px;
height: 25px;
padding: 0 8px;
color: var(--vscode-descriptionForeground);
background: transparent;
@@ -97,6 +97,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
const hostRef = useRef<HTMLDivElement | null>(null)
const localViewportRef = useRef<HTMLDivElement | null>(null)
const localContentRef = useRef<HTMLDivElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null)
const thumbRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
@@ -107,6 +108,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
const { state, api, metricsRef, scrollToNow } = useScrollableModel({
viewportRef: localViewportRef,
contentRef: localContentRef,
trackRef,
thumbRef,
contentHeight,
@@ -132,7 +134,10 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
)
const setContentRef = useCallback(
(node: HTMLDivElement | null) => assignRef(contentRef, node),
(node: HTMLDivElement | null) => {
localContentRef.current = node
assignRef(contentRef, node)
},
[contentRef]
)
@@ -33,6 +33,7 @@ export interface ScrollMetrics extends ScrollAreaState {
interface UseScrollableModelOptions {
viewportRef: RefObject<HTMLDivElement | null>
contentRef: RefObject<HTMLDivElement | null>
trackRef: RefObject<HTMLDivElement | null>
thumbRef: RefObject<HTMLDivElement | null>
contentHeight?: number
@@ -131,6 +132,7 @@ function applyThumbStyle(
export function useScrollableModel(options: UseScrollableModelOptions) {
const {
viewportRef,
contentRef,
trackRef,
thumbRef,
contentHeight,
@@ -319,28 +321,30 @@ export function useScrollableModel(options: UseScrollableModelOptions) {
useEffect(() => {
const element = viewportRef.current
const content = contentRef.current
const track = trackRef.current
if (!element || !track) return
if (!element || !content || !track) return
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) {
const viewportEntry = entries.find((entry) => entry.target === element)
if (viewportEntry) {
onViewportResizeRef.current?.({
width: entry.contentRect.width,
height: entry.contentRect.height,
width: viewportEntry.contentRect.width,
height: viewportEntry.contentRect.height,
})
}
snapshot(false)
})
resizeObserver.observe(element)
resizeObserver.observe(content)
resizeObserver.observe(track)
snapshot(false)
return () => {
resizeObserver.disconnect()
}
}, [snapshot, trackRef, viewportRef])
}, [contentRef, snapshot, trackRef, viewportRef])
useEffect(() => {
snapshot(false)
@@ -8,6 +8,7 @@
color: var(--vscode-foreground);
font-size: type.$font-size-xs;
cursor: pointer;
flex-shrink: 0;
input {
position: absolute;
@@ -34,8 +34,8 @@
}
.iconButton {
width: 30px;
height: 30px;
width: 25px;
height: 25px;
display: grid;
place-items: center;
padding: 0;
@@ -47,6 +47,16 @@
.input {
height: 34px;
padding: 0 10px;
&[type="number"] {
appearance: textfield;
}
&[type="number"]::-webkit-inner-spin-button,
&[type="number"]::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
}
.secret {
@@ -34,12 +34,14 @@
box-shadow: 0 20px 50px rgb(0 0 0 / 45%);
header {
flex: 0 0 auto;
padding: 10px 18px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
font-size: type.$font-size-base;
font-weight: 630;
}
footer {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
gap: 8px;
@@ -52,6 +54,10 @@
width: min(1080px, calc(100vw - 48px));
}
.fullHeight {
height: calc(100vh - var(--window-header-height) - 24px);
}
.banner {
flex: 0 0 auto;
padding: 12px 18px;
@@ -59,14 +65,8 @@
}
.body {
flex: 0 1 auto;
flex: 1 1 auto;
min-height: 0;
max-height: calc(100vh - var(--window-header-height) - 112px);
}
.bodyViewport {
height: auto;
max-height: calc(100vh - var(--window-header-height) - 112px);
}
.bodyContent {
+4 -3
View File
@@ -12,6 +12,7 @@ type ModalProps = {
banner?: ReactNode;
busy?: boolean;
wide?: boolean;
fullHeight?: boolean;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
initialFocus?: "first" | "submit";
@@ -36,7 +37,7 @@ function focusableElements(root: HTMLElement) {
.filter((element) => element.getClientRects().length > 0);
}
export function Modal({ id, open, title, children, banner, busy, wide, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
const dialog = useRef<HTMLDivElement>(null);
const submitButton = useRef<HTMLButtonElement>(null);
const closeRef = useRef(onClose);
@@ -87,10 +88,10 @@ export function Modal({ id, open, title, children, banner, busy, wide, role = "d
if (!open) return null;
return createPortal(<div className={styles.mask}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide, fullHeight && styles.fullHeight].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
<header id={titleId}>{title}</header>
{banner && <div className={styles.banner}>{banner}</div>}
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<ScrollableContent alwaysShowVertical className={styles.body} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<footer>
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
{secondaryAction}
-1
View File
@@ -15,7 +15,6 @@ export const flatColorSettingsIcon = icon('<path fill="#607D8B" d="M39.6 27.2c.1
export const claudeIcon = icon('<path fill="#d97757" d="m50.228 170.321l50.357-28.257l.843-2.463l-.843-1.361h-2.462l-8.426-.518l-28.775-.778l-24.952-1.037l-24.175-1.296l-6.092-1.297L0 125.796l.583-3.759l5.12-3.434l7.324.648l16.202 1.101l24.304 1.685l17.629 1.037l26.118 2.722h4.148l.583-1.685l-1.426-1.037l-1.101-1.037l-25.147-17.045l-27.22-18.017l-14.258-10.37l-7.713-5.25l-3.888-4.925l-1.685-10.758l7-7.713l9.397.649l2.398.648l9.527 7.323l20.35 15.75L94.817 91.9l3.889 3.24l1.555-1.102l.195-.777l-1.75-2.917l-14.453-26.118l-15.425-26.572l-6.87-11.018l-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0l10.63 1.426l4.472 3.888l6.61 15.101l10.694 23.786l16.591 32.34l4.861 9.592l2.592 8.879l.973 2.722h1.685v-1.556l1.36-18.211l2.528-22.36l2.463-28.776l.843-8.1l4.018-9.722l7.971-5.25l6.222 2.981l5.12 7.324l-.713 4.73l-3.046 19.768l-5.962 30.98l-3.889 20.739h2.268l2.593-2.593l10.499-13.934l17.628-22.036l7.778-8.749l9.073-9.657l5.833-4.601h11.018l8.1 12.055l-3.628 12.443l-11.342 14.388l-9.398 12.184l-13.48 18.147l-8.426 14.518l.778 1.166l2.01-.194l30.46-6.481l16.462-2.982l19.637-3.37l8.88 4.148l.971 4.213l-3.5 8.62l-20.998 5.184l-24.628 4.926l-36.682 8.685l-.454.324l.519.648l16.526 1.555l7.065.389h17.304l32.21 2.398l8.426 5.574l5.055 6.805l-.843 5.184l-12.962 6.611l-17.498-4.148l-40.83-9.721l-14-3.5h-1.944v1.167l11.666 11.406l21.387 19.314l26.767 24.887l1.36 6.157l-3.434 4.86l-3.63-.518l-23.526-17.693l-9.073-7.972l-20.545-17.304h-1.36v1.814l4.73 6.935l25.017 37.59l1.296 11.536l-1.814 3.76l-6.481 2.268l-7.13-1.297l-14.647-20.544l-15.1-23.138l-12.185-20.739l-1.49.843l-7.194 77.448l-3.37 3.953l-7.778 2.981l-6.48-4.925l-3.436-7.972l3.435-15.749l4.148-20.544l3.37-16.333l3.046-20.285l1.815-6.74l-.13-.454l-1.49.194l-15.295 20.999l-23.267 31.433l-18.406 19.702l-4.407 1.75l-7.648-3.954l.713-7.064l4.277-6.286l25.47-32.405l15.36-20.092l9.917-11.6l-.065-1.686h-.583L44.07 198.125l-12.055 1.555l-5.185-4.86l.648-7.972l2.463-2.593l20.35-13.999z"/>', 256, 257);
export const openAiIcon = icon('<path fill="currentColor" d="M20.57 10.18c.45-1.36.3-2.85-.43-4.09a5.03 5.03 0 0 0-5.42-2.42a5.05 5.05 0 0 0-7.14-.38c-.66.59-1.15 1.35-1.43 2.19A4.98 4.98 0 0 0 2.82 7.9c-1.1 1.9-.85 4.29.62 5.91c-.45 1.36-.3 2.85.43 4.09a5.045 5.045 0 0 0 5.43 2.42A5.03 5.03 0 0 0 13.06 22c2.19 0 4.14-1.41 4.81-3.5a4.98 4.98 0 0 0 3.33-2.42a5.04 5.04 0 0 0-.62-5.89Zm-7.52 10.51c-.88 0-1.72-.31-2.4-.87l.12-.07l3.98-2.3c.2-.12.33-.33.33-.57v-5.61l1.68.97s.03.02.03.04v4.65c0 2.07-1.68 3.74-3.75 3.75ZM5 17.25c-.44-.76-.6-1.65-.45-2.51l.12.07l3.99 2.3c.2.12.45.12.65 0l4.87-2.81v1.94s-.01.04-.03.05l-4.03 2.33A3.756 3.756 0 0 1 5 17.25M3.95 8.58a3.7 3.7 0 0 1 1.97-1.64v4.73c0 .23.12.45.32.56l4.85 2.8l-1.68.97h-.06l-4.03-2.32a3.754 3.754 0 0 1-1.37-5.12zm13.83 3.21l-4.86-2.82L14.6 8h.06l4.03 2.33a3.743 3.743 0 0 1 1.37 5.12a3.8 3.8 0 0 1-1.94 1.64v-4.73a.67.67 0 0 0-.34-.56Zm1.68-2.52l-.12-.07l-3.98-2.32a.63.63 0 0 0-.65 0L9.84 9.69V7.75s0-.04.02-.05l4.03-2.32a3.75 3.75 0 0 1 5.12 1.38c.44.76.59 1.64.45 2.51v.02ZM8.93 12.72l-1.68-.97s-.03-.03-.03-.05V7.06c0-2.07 1.68-3.75 3.75-3.74c.87 0 1.72.31 2.39.87l-.12.07l-3.98 2.3c-.2.12-.33.33-.33.57v5.6Zm.91-1.97l2.17-1.25l2.17 1.25v2.5l-2.16 1.25l-2.17-1.25v-2.5Z"/>');
// Menu icons intentionally use filled glyphs from different collections so they
// can be compared in context before settling on one visual family.
export const homeIcon = icon('<g fill="currentColor"><path d="M13 15.4C13 13.3258 13 12.2887 13.659 11.6444C14.318 11 15.3787 11 17.5 11C19.6213 11 20.682 11 21.341 11.6444C22 12.2887 22 13.3258 22 15.4V17.6C22 19.6742 22 20.7113 21.341 21.3556C20.682 22 19.6213 22 17.5 22C15.3787 22 14.318 22 13.659 21.3556C13 20.7113 13 19.6742 13 17.6V15.4Z"/><path d="M2 8.6C2 10.6742 2 11.7113 2.65901 12.3556C3.31802 13 4.37868 13 6.5 13C8.62132 13 9.68198 13 10.341 12.3556C11 11.7113 11 10.6742 11 8.6V6.4C11 4.32582 11 3.28873 10.341 2.64437C9.68198 2 8.62132 2 6.5 2C4.37868 2 3.31802 2 2.65901 2.64437C2 3.28873 2 4.32582 2 6.4V8.6Z"/><path d="M13 5.5C13 4.4128 13 3.8692 13.1713 3.44041C13.3996 2.86867 13.8376 2.41443 14.389 2.17761C14.8024 2 15.3266 2 16.375 2H18.625C19.6734 2 20.1976 2 20.611 2.17761C21.1624 2.41443 21.6004 2.86867 21.8287 3.44041C22 3.8692 22 4.4128 22 5.5C22 6.5872 22 7.1308 21.8287 7.55959C21.6004 8.13133 21.1624 8.58557 20.611 8.82239C20.1976 9 19.6734 9 18.625 9H16.375C15.3266 9 14.8024 9 14.389 8.82239C13.8376 8.58557 13.3996 8.13133 13.1713 7.55959C13 7.1308 13 6.5872 13 5.5Z"/><path d="M2 18.5C2 19.5872 2 20.1308 2.17127 20.5596C2.39963 21.1313 2.83765 21.5856 3.38896 21.8224C3.80245 22 4.32663 22 5.375 22H7.625C8.67337 22 9.19755 22 9.61104 21.8224C10.1624 21.5856 10.6004 21.1313 10.8287 20.5596C11 20.1308 11 19.5872 11 18.5C11 17.4128 11 16.8692 10.8287 16.4404C10.6004 15.8687 10.1624 15.4144 9.61104 15.1776C9.19755 15 8.67337 15 7.625 15H5.375C4.32663 15 3.80245 15 3.38896 15.1776C2.83765 15.4144 2.39963 15.8687 2.17127 16.4404C2 16.8692 2 17.4128 2 18.5Z"/></g>');
+242 -88
View File
@@ -77,7 +77,7 @@
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 106,
"line": 154,
"column": 85
},
{
@@ -107,7 +107,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 101,
"line": 134,
"column": 27
}
]
@@ -164,7 +164,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 230,
"line": 284,
"column": 73
}
]
@@ -188,7 +188,7 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 13,
"line": 14,
"column": 105
}
]
@@ -200,7 +200,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 241,
"line": 295,
"column": 89
}
]
@@ -343,25 +343,10 @@
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 105,
"column": 95
},
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 12,
"line": 13,
"column": 109
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 200,
"column": 231
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 203,
"column": 480
}
]
},
@@ -389,7 +374,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 240,
"line": 294,
"column": 36
}
]
@@ -413,8 +398,8 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 200,
"column": 243
"line": 252,
"column": 211
}
]
},
@@ -483,18 +468,18 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 201,
"line": 255,
"column": 51
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 201,
"line": 255,
"column": 130
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 203,
"column": 63
"line": 257,
"column": 74
}
]
},
@@ -577,7 +562,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 211,
"line": 265,
"column": 52
}
]
@@ -700,7 +685,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 234,
"line": 288,
"column": 89
}
]
@@ -729,6 +714,18 @@
}
]
},
"22d7895ea5fca72e": {
"source": "按供应商",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 250,
"column": 134
}
]
},
"23e49479e15e6770": {
"source": "发现新版本 {version}",
"kind": "template",
@@ -817,7 +814,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 233,
"line": 287,
"column": 186
}
]
@@ -854,8 +851,8 @@
},
{
"file": "components/ui/Modal.tsx",
"line": 39,
"column": 179
"line": 40,
"column": 191
},
{
"file": "layouts/AppLayout.tsx",
@@ -869,7 +866,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 211,
"line": 265,
"column": 76
},
{
@@ -936,7 +933,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 247,
"line": 301,
"column": 70
}
]
@@ -1010,12 +1007,12 @@
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 108,
"line": 156,
"column": 119
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 211,
"line": 265,
"column": 99
}
]
@@ -1061,7 +1058,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 232,
"line": 286,
"column": 123
}
]
@@ -1220,7 +1217,7 @@
"refs": [
{
"file": "components/charts/ContributionCalendarChart.tsx",
"line": 267,
"line": 270,
"column": 18
}
]
@@ -1266,6 +1263,18 @@
}
]
},
"3fd118e2ffe0b2b6": {
"source": "取消全部测试",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 252,
"column": 197
}
]
},
"3fd47edce45b3603": {
"source": "关闭",
"kind": "text",
@@ -1273,7 +1282,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 208,
"line": 262,
"column": 80
}
]
@@ -1357,7 +1366,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 106,
"line": 143,
"column": 27
}
]
@@ -1474,11 +1483,28 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 233,
"line": 287,
"column": 34
}
]
},
"49be72e6045c007d": {
"source": "取消测试",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 153,
"column": 107
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 257,
"column": 681
}
]
},
"4a8d6841b4023edf": {
"source": "确认导入",
"kind": "text",
@@ -1585,8 +1611,8 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 138,
"column": 11
"line": 180,
"column": 13
}
]
},
@@ -1645,7 +1671,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 163,
"line": 205,
"column": 47
}
]
@@ -1669,7 +1695,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 201,
"line": 255,
"column": 63
}
]
@@ -1691,7 +1717,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 233,
"line": 287,
"column": 83
}
]
@@ -1780,11 +1806,23 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 18,
"line": 20,
"column": 7
}
]
},
"5ba65a74c4e792c5": {
"source": "按类型",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 251,
"column": 122
}
]
},
"5c55a67935af8f45": {
"source": "全部",
"kind": "text",
@@ -1954,7 +1992,7 @@
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 107,
"line": 155,
"column": 90
}
]
@@ -1966,7 +2004,7 @@
"refs": [
{
"file": "api.ts",
"line": 313,
"line": 314,
"column": 43
}
]
@@ -1997,7 +2035,7 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 20,
"line": 22,
"column": 7
}
]
@@ -2100,8 +2138,8 @@
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 105,
"column": 107
"line": 153,
"column": 119
}
]
},
@@ -2170,6 +2208,18 @@
}
]
},
"72644ec4389da2f7": {
"source": "默认平铺",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 249,
"column": 103
}
]
},
"736c9dc2a04c65fd": {
"source": "模型配置已发生变化,请刷新后重试",
"kind": "text",
@@ -2230,7 +2280,7 @@
"refs": [
{
"file": "api.ts",
"line": 308,
"line": 309,
"column": 43
}
]
@@ -2271,7 +2321,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 228,
"line": 282,
"column": 67
}
]
@@ -2362,6 +2412,21 @@
}
]
},
"83c4efccd9a6bf69": {
"source": "连通性测试已取消:成功 {successful},失败 {failed}",
"kind": "template",
"placeholders": [
"successful",
"failed"
],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 178,
"column": 11
}
]
},
"83fcfb4c1f2c1641": {
"source": "获取模型",
"kind": "text",
@@ -2572,7 +2637,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 202,
"line": 256,
"column": 25
}
]
@@ -2598,7 +2663,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 248,
"line": 302,
"column": 87
}
]
@@ -2646,7 +2711,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 211,
"line": 265,
"column": 250
}
]
@@ -2819,7 +2884,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 147,
"line": 189,
"column": 22
}
]
@@ -2870,8 +2935,8 @@
},
{
"file": "components/ui/Modal.tsx",
"line": 39,
"column": 202
"line": 40,
"column": 214
},
{
"file": "pages/SettingsPage.tsx",
@@ -2930,6 +2995,20 @@
}
]
},
"a4d222236dc1003d": {
"source": "取消测试失败:{error}",
"kind": "template",
"placeholders": [
"error"
],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 117,
"column": 15
}
]
},
"a5fb6189a8ad011d": {
"source": "打开教程",
"kind": "text",
@@ -2966,8 +3045,8 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 203,
"column": 492
"line": 257,
"column": 693
}
]
},
@@ -2988,7 +3067,7 @@
},
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 32,
"line": 34,
"column": 86
}
]
@@ -3012,13 +3091,13 @@
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 87,
"column": 93
"line": 135,
"column": 113
},
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 87,
"column": 111
"line": 135,
"column": 131
}
]
},
@@ -3068,6 +3147,18 @@
}
]
},
"ac58d0f9a3f8d389": {
"source": "请输入模型备注",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"column": 118
}
]
},
"ac69f68b7010ec79": {
"source": "下载并安装",
"kind": "text",
@@ -3250,7 +3341,7 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 25,
"line": 27,
"column": 50
}
]
@@ -3262,7 +3353,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 208,
"line": 262,
"column": 103
}
]
@@ -3313,6 +3404,18 @@
}
]
},
"ba5865fbc734e672": {
"source": "例如:主力模型",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 91,
"column": 99
}
]
},
"baff6c144180b185": {
"source": "连通性测试完成:成功 {successful},失败 {failed}",
"kind": "template",
@@ -3323,8 +3426,8 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 139,
"column": 11
"line": 181,
"column": 13
}
]
},
@@ -3433,8 +3536,8 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 203,
"column": 51
"line": 257,
"column": 62
}
]
},
@@ -3645,8 +3748,47 @@
"refs": [
{
"file": "components/ui/Modal.tsx",
"line": 95,
"line": 98,
"column": 135
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 257,
"column": 653
}
]
},
"d60669bb26a22f5d": {
"source": "留空使用默认值",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"column": 121
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 96,
"column": 122
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 99,
"column": 122
}
]
},
"d6b1f203680f5496": {
"source": "留空使用 adaptive thinking",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 101,
"column": 137
}
]
},
@@ -3686,7 +3828,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 209,
"line": 263,
"column": 47
}
]
@@ -3708,11 +3850,6 @@
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 27,
"column": 19
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
@@ -3720,6 +3857,18 @@
}
]
},
"db340a9896306d08": {
"source": "测试已取消",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 15,
"column": 127
}
]
},
"dbd3596e4a86f3c2": {
"source": "配置模型",
"kind": "text",
@@ -3830,7 +3979,7 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 17,
"line": 19,
"column": 7
}
]
@@ -3854,7 +4003,7 @@
"refs": [
{
"file": "components/charts/ContributionCalendarChart.tsx",
"line": 252,
"line": 255,
"column": 50
}
]
@@ -3878,7 +4027,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 159,
"line": 201,
"column": 26
}
]
@@ -3904,7 +4053,7 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 28,
"line": 30,
"column": 7
}
]
@@ -4087,7 +4236,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 209,
"line": 263,
"column": 77
}
]
@@ -4101,6 +4250,11 @@
"file": "components/CallTable.tsx",
"line": 178,
"column": 15
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 248,
"column": 69
}
]
},
@@ -4111,7 +4265,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 208,
"line": 262,
"column": 53
}
]
@@ -4198,7 +4352,7 @@
"refs": [
{
"file": "components/charts/ContributionCalendarChart.tsx",
"line": 258,
"line": 261,
"column": 23
}
]
+12
View File
@@ -47,6 +47,7 @@
"20e14248fd4fb981": "{label} must be an integer greater than 0",
"217cfe7db1e3d10a": "Use system language",
"22c6b4eb4caee6ae": "Proxy settings saved",
"22d7895ea5fca72e": "By provider",
"23e49479e15e6770": "Version {version} is available",
"24a0a24864454575": "Existing, skipped",
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
@@ -83,6 +84,7 @@
"3d13868593ae4eeb": "Display language",
"3da0bf1610ff5db5": "Recommended",
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
"3fd118e2ffe0b2b6": "Cancel all tests",
"3fd47edce45b3603": "Close",
"4125fc7ba333524c": "Default light",
"42655ed8e4108ae2": "Input (non-cached)",
@@ -98,6 +100,7 @@
"4927a53bcc886afb": "Loading…",
"497c85690c4cc0fc": "No data",
"499c729eb09aa2a6": "Context window tokens",
"49be72e6045c007d": "Cancel test",
"4a8d6841b4023edf": "Confirm import",
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
"4d0680f9efaef147": "Unread",
@@ -119,6 +122,7 @@
"5a284a1a2be8da0e": "Read models from the local legacy configuration. New and existing models are shown before confirmation.",
"5a3bd99fa69a40c1": "Use public service",
"5b17f59d33bde39e": "Error: {error}",
"5ba65a74c4e792c5": "By type",
"5c55a67935af8f45": "All",
"5cae248525cb9140": "All call records and detailed traces will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
"5d59857bf039cac9": "Cursor Assistant v{version}",
@@ -147,6 +151,7 @@
"6d1876364ac6457d": "Proxy mode",
"6e86570183c3cdd0": "You're up to date",
"7005693f4f050bce": "Cache I/O {cost}",
"72644ec4389da2f7": "Flat",
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
"7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.",
"788db1cfec2a3db5": "Theme",
@@ -162,6 +167,7 @@
"7f3c8312816fe26a": "Refreshing…",
"7f68ebad19ba6bcd": "Check for updates",
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
"83c4efccd9a6bf69": "Connectivity tests cancelled: {successful} succeeded, {failed} failed",
"83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login",
"843ac7e15a5047a7": "Confirm legacy model configuration import",
@@ -202,6 +208,7 @@
"a363743025795ec7": "I've initialized it — refresh",
"a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.",
"a49ffd73bc85333d": "Average",
"a4d222236dc1003d": "Failed to cancel test: {error}",
"a5fb6189a8ad011d": "Open tutorial",
"a621ab606db2a11f": "Password",
"a693d69af48bfe48": "Save and test",
@@ -211,6 +218,7 @@
"a98585871c5313ff": "Display name",
"ab9084a640fbb864": "Deselect all",
"abecab6701177721": "Launch at login enabled",
"ac58d0f9a3f8d389": "Enter model notes",
"ac69f68b7010ec79": "Download and install",
"ad6a60ee93d3ba3e": "Loading call details…",
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
@@ -227,6 +235,7 @@
"b8c9b486c83b5778": "Hide ad",
"b9670c85a4ab939e": "Route",
"b9af2de88d903be7": "Proxy address",
"ba5865fbc734e672": "For example: Primary model",
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
"bb2b7736433ae867": "Cursor tracing",
"bb7efdcb6af6e805": "Default dark",
@@ -251,11 +260,14 @@
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
"d3716cc5a2f5a810": "Server address",
"d3d21191f32e79a5": "Processing…",
"d60669bb26a22f5d": "Leave blank to use the default",
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
"d86fa42c3848c680": "Use system proxy",
"d8c47e9776cf1082": "Main menu",
"da521d1c1cbd36af": "Authorization is required to install the certificate",
"da7ae985487c38e6": "Last hour",
"daede9881787abe7": "Notes",
"db340a9896306d08": "Test cancelled",
"dbd3596e4a86f3c2": "Configured models",
"ddde16f8839da3ce": "Total requests",
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
+12
View File
@@ -47,6 +47,7 @@
"20e14248fd4fb981": "{label} 必须是大于 0 的整数",
"217cfe7db1e3d10a": "跟随系统",
"22c6b4eb4caee6ae": "代理设置已保存",
"22d7895ea5fca72e": "按供应商",
"23e49479e15e6770": "发现新版本 {version}",
"24a0a24864454575": "已存在,跳过",
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
@@ -83,6 +84,7 @@
"3d13868593ae4eeb": "界面语言",
"3da0bf1610ff5db5": "推荐内容",
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
"3fd118e2ffe0b2b6": "取消全部测试",
"3fd47edce45b3603": "关闭",
"4125fc7ba333524c": "默认亮色",
"42655ed8e4108ae2": "输入(非缓存)",
@@ -98,6 +100,7 @@
"4927a53bcc886afb": "加载中…",
"497c85690c4cc0fc": "暂无数据",
"499c729eb09aa2a6": "上下文窗口 Token",
"49be72e6045c007d": "取消测试",
"4a8d6841b4023edf": "确认导入",
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
"4d0680f9efaef147": "未读",
@@ -119,6 +122,7 @@
"5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。",
"5a3bd99fa69a40c1": "使用公益服务",
"5b17f59d33bde39e": "错误:{error}",
"5ba65a74c4e792c5": "按类型",
"5c55a67935af8f45": "全部",
"5cae248525cb9140": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
"5d59857bf039cac9": "Cursor 助手 v{version}",
@@ -147,6 +151,7 @@
"6d1876364ac6457d": "代理方式",
"6e86570183c3cdd0": "当前已是最新版本",
"7005693f4f050bce": "缓存读写 {cost}",
"72644ec4389da2f7": "默认平铺",
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
"788db1cfec2a3db5": "主题",
@@ -162,6 +167,7 @@
"7f3c8312816fe26a": "刷新中…",
"7f68ebad19ba6bcd": "检查更新",
"811a3b22a5a7f2d5": "无法连接本地管理服务",
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
"83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动",
"843ac7e15a5047a7": "确认导入旧版模型配置",
@@ -202,6 +208,7 @@
"a363743025795ec7": "我已初始化,刷新",
"a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。",
"a49ffd73bc85333d": "平均",
"a4d222236dc1003d": "取消测试失败:{error}",
"a5fb6189a8ad011d": "打开教程",
"a621ab606db2a11f": "密码",
"a693d69af48bfe48": "保存并测试",
@@ -211,6 +218,7 @@
"a98585871c5313ff": "显示名称",
"ab9084a640fbb864": "全不选",
"abecab6701177721": "已开启开机启动",
"ac58d0f9a3f8d389": "请输入模型备注",
"ac69f68b7010ec79": "下载并安装",
"ad6a60ee93d3ba3e": "正在加载调用详情…",
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
@@ -227,6 +235,7 @@
"b8c9b486c83b5778": "不再显示广告",
"b9670c85a4ab939e": "路由",
"b9af2de88d903be7": "代理地址",
"ba5865fbc734e672": "例如:主力模型",
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
"bb2b7736433ae867": "Cursor 追踪",
"bb7efdcb6af6e805": "默认暗色",
@@ -251,11 +260,14 @@
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
"d3716cc5a2f5a810": "服务器地址",
"d3d21191f32e79a5": "处理中…",
"d60669bb26a22f5d": "留空使用默认值",
"d6b1f203680f5496": "留空使用 adaptive thinking",
"d86fa42c3848c680": "使用系统代理",
"d8c47e9776cf1082": "主菜单",
"da521d1c1cbd36af": "需要授权安装证书",
"da7ae985487c38e6": "近1小时",
"daede9881787abe7": "备注",
"db340a9896306d08": "测试已取消",
"dbd3596e4a86f3c2": "配置模型",
"ddde16f8839da3ce": "总请求",
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
@@ -156,11 +156,14 @@
.actions {
height: var(--app-toolbar-height);
min-width: var(--app-toolbar-height);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 2px 2px;
justify-content: center;
padding: 6px 6px;
gap: 3px;
box-shadow: 0 0px 6px 4px var(--vscode-editor-background);
transform: translateX(6px);
}
.pageActions {
+76 -22
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api, type Model, type ModelInput } from "../api";
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
import { CursorModelCards } from "../components/cursor/CursorModelCards";
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "../components/cursor/CursorModelCards";
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult";
import styles from "../components/cursor/CursorSettings.module.scss";
@@ -31,7 +31,19 @@ export function CursorSettingsPage() {
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
const [savingAndTesting, setSavingAndTesting] = useState(false);
const [batchTesting, setBatchTesting] = useState(false);
const [grouping, setGrouping] = useState<CursorModelGrouping>("flat");
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
const caReady = cursorHarness?.ca === "ready";
const providerGroups = cursorModelGroups(models, "provider");
const typeGroups = cursorModelGroups(models, "type");
const canGroupByProvider = providerGroups.length > 1;
const canGroupByType = typeGroups.length > 1;
useEffect(() => {
if ((grouping === "provider" && !canGroupByProvider) || (grouping === "type" && !canGroupByType)) {
setGrouping("flat");
}
}, [canGroupByProvider, canGroupByType, grouping]);
useEffect(() => {
if (caCommand) void api.copyCursorText(caCommand);
@@ -93,19 +105,45 @@ export function CursorSettingsPage() {
message(errorText(cause));
}
};
const testModel = async (model: Model, notify = true) => {
const cancelModelTest = async (modelHash: string) => {
const active = activeModelTests.current.get(modelHash);
if (!active || active.cancelling) return;
active.cancelling = true;
try {
await api.cancelModelTest(modelHash, active.testId);
active.controller.abort();
} catch (cause) {
active.cancelling = false;
message(t("取消测试失败:{error}", { error: errorText(cause) }), { duration: 5000 });
}
};
const cancelAllModelTests = async () => {
await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash)));
};
const testModel = async (model: Model, notify = true): Promise<"success" | "failure" | "cancelled"> => {
if (activeModelTests.current.has(model.model_hash)) {
await cancelModelTest(model.model_hash);
return "cancelled";
}
const active = { testId: crypto.randomUUID(), controller: new AbortController(), cancelling: false };
activeModelTests.current.set(model.model_hash, active);
setTestingModelHashes((current) => new Set(current).add(model.model_hash));
try {
const result = await api.testModel(model.model_hash);
const result = await api.testModel(model.model_hash, active.testId, active.controller.signal);
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;
return "success";
} catch (cause) {
if (active.cancelling || active.controller.signal.aborted) {
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "cancelled" }));
return "cancelled";
}
const error = errorText(cause);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error }));
if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 });
return false;
return "failure";
} finally {
if (activeModelTests.current.get(model.model_hash) === active) activeModelTests.current.delete(model.model_hash);
setTestingModelHashes((current) => {
const next = new Set(current);
next.delete(model.model_hash);
@@ -115,29 +153,33 @@ export function CursorSettingsPage() {
};
const saveAndTest = async () => {
setSavingAndTesting(true);
let saved: Model | null = null;
try {
const saved = await persist();
if (!saved) return;
setEditing(saved);
await testModel(saved);
await appStore.refresh();
saved = await persist();
} catch (cause) {
message(errorText(cause));
} finally {
setSavingAndTesting(false);
}
if (!saved) return;
setEditing(saved);
await testModel(saved);
await appStore.refresh();
};
const testAllModels = async () => {
if (!models.length || batchTesting) return;
setBatchTesting(true);
try {
const results = await Promise.all(models.map((model) => testModel(model, false)));
const successful = results.filter(Boolean).length;
const failed = models.length - successful;
message(failed === 0
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 ? 2400 : 5000 });
const successful = results.filter((result) => result === "success").length;
const failed = results.filter((result) => result === "failure").length;
const cancelled = results.filter((result) => result === "cancelled").length;
message(cancelled > 0
? t("连通性测试已取消:成功 {successful},失败 {failed}", { successful, failed })
: failed === 0
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
} finally {
setBatchTesting(false);
}
@@ -166,7 +208,8 @@ export function CursorSettingsPage() {
const list = <CursorModelCards
models={models}
disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting}
grouping={grouping}
disabled={cursorBusy}
testingModelHashes={testingModelHashes}
testResults={modelTestResults}
onTest={(model) => void testModel(model)}
@@ -194,13 +237,24 @@ export function CursorSettingsPage() {
</CursorCaGate></CursorCaProvider>;
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
const editorTesting = savingAndTesting || Boolean(editing && testingModelHashes.has(editing.model_hash));
const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash));
const activeGroups = grouping === "provider" ? providerGroups : typeGroups;
const estimatedModelHeight = grouping === "flat"
? Math.max(380, Math.ceil(models.length / 3) * 196)
: Math.max(380, activeGroups.reduce((height, group) => height + Math.ceil(group.models.length / 3) * 196 + 34, 0) + Math.max(0, activeGroups.length - 1) * 20);
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>}
{models.length > 0 && <PageActions position="left">
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
</div>
</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(380, Math.ceil(models.length / 3) * 196), content }]} />
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button>}>
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
{draft && <>
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
</>}
+1 -1
View File
@@ -90,7 +90,7 @@
sideBar-border: #00000024,
list-activeSelectionBackground: #0000000f,
list-activeSelectionForeground: #191919,
list-hoverBackground: #E6E6E67A,
list-hoverBackground: #f3f3f3,
input-background: #ffffff,
input-foreground: #191919,
input-border: #00000024,
+1 -1
View File
@@ -3,5 +3,5 @@ $font-size-xs: 13px;
$font-size-base: 14px;
$font-size-lg: 16px;
$font-size-xl: 18px;
$font-size-xxl: 24px;
$font-size-xxl: 20px;
$font-size-3xl: 30px;
@@ -0,0 +1,97 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
function readOptions(args) {
const options = new Map();
for (let index = 0; index < args.length; index += 2) {
const name = args[index];
const value = args[index + 1];
if (!name?.startsWith("--") || value === undefined) {
throw new Error(`invalid argument near ${name ?? "end of command"}`);
}
options.set(name.slice(2), value);
}
return options;
}
function required(options, name) {
const value = options.get(name)?.trim();
if (!value) throw new Error(`--${name} is required`);
return value;
}
export function normalizeTauriUpdate(manifest, release, repository, version) {
if (manifest.version !== version) {
throw new Error(
`updater manifest version ${manifest.version ?? "is missing"}; expected ${version}`,
);
}
if (release.tag_name !== `v${version}`) {
throw new Error(
`GitHub release tag ${release.tag_name ?? "is missing"}; expected v${version}`,
);
}
if (!manifest.platforms || typeof manifest.platforms !== "object") {
throw new Error("updater manifest has no platforms");
}
const assetsByApiUrl = new Map();
const publicAssetUrls = new Set();
for (const asset of release.assets ?? []) {
if (!asset?.id || !asset?.browser_download_url) continue;
assetsByApiUrl.set(
`https://api.github.com/repos/${repository}/releases/assets/${asset.id}`,
asset.browser_download_url,
);
publicAssetUrls.add(asset.browser_download_url);
}
for (const [platform, entry] of Object.entries(manifest.platforms)) {
if (!entry?.signature || !entry?.url) {
throw new Error(`updater platform ${platform} is missing its URL or signature`);
}
const publicUrl = assetsByApiUrl.get(entry.url) ?? entry.url;
if (!publicAssetUrls.has(publicUrl)) {
throw new Error(`updater platform ${platform} references an unknown release asset`);
}
entry.url = publicUrl;
}
return manifest;
}
async function main() {
const options = readOptions(process.argv.slice(2));
const manifestPath = resolve(required(options, "manifest"));
const releasePath = resolve(required(options, "release"));
const repository = required(options, "repository");
const version = required(options, "version").replace(/^v/, "");
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
throw new Error(`invalid GitHub repository: ${repository}`);
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`invalid semantic version: ${version}`);
}
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const release = JSON.parse(await readFile(releasePath, "utf8"));
const normalized = normalizeTauriUpdate(
manifest,
release,
repository,
version,
);
await writeFile(manifestPath, `${JSON.stringify(normalized, null, 2)}\n`);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
+10 -1
View File
@@ -1,10 +1,19 @@
use tokio::sync::oneshot;
use crate::model::{CanonicalMessage, RuntimeEvent, ToolResult};
#[derive(Clone, Debug, PartialEq)]
#[derive(Debug)]
pub struct MessageInsertion {
pub messages: Vec<CanonicalMessage>,
pub delivered: oneshot::Sender<()>,
}
#[derive(Debug)]
pub enum ClientCommand {
ToolResult(ToolResult),
RuntimeMessage(CanonicalMessage),
RuntimeEvent(RuntimeEvent),
InsertMessages(MessageInsertion),
ClientClosed { error: String },
Cancel,
}
+2 -2
View File
@@ -130,8 +130,8 @@ pub fn api_router(service: ControlService) -> Router {
put(models::update).delete(models::remove),
)
.route(
"/__byok-api__/api/models/{model_hash}/test",
post(models::test),
"/__byok-api__/api/models/{model_hash}/test/{test_id}",
post(models::test).delete(models::cancel),
)
.route("/__byok-api__/api/llm-calls", get(calls::list))
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
+10 -2
View File
@@ -64,9 +64,17 @@ pub async fn update(
pub async fn test(
State(service): State<ControlService>,
Path(model_hash): Path<String>,
Path((model_hash, test_id)): Path<(String, String)>,
) -> Result<Json<ModelConnectivityResult>> {
Ok(Json(service.test_model(&model_hash).await?))
Ok(Json(service.test_model(&model_hash, &test_id).await?))
}
pub async fn cancel(
State(service): State<ControlService>,
Path((_model_hash, test_id)): Path<(String, String)>,
) -> Result<StatusCode> {
service.cancel_model_test(&test_id);
Ok(StatusCode::NO_CONTENT)
}
pub async fn discover(
+133 -22
View File
@@ -1,4 +1,8 @@
use std::{collections::BTreeSet, sync::Arc, time::Instant};
use std::{
collections::{BTreeMap, BTreeSet},
sync::{Arc, Mutex},
time::Instant,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use futures_util::StreamExt;
@@ -33,6 +37,7 @@ pub struct ControlService {
store: Store,
cursor_harness: CursorHarness,
provider: Arc<dyn Provider>,
model_tests: Arc<Mutex<BTreeMap<String, CancellationToken>>>,
}
#[derive(Clone, Debug, Serialize)]
@@ -142,6 +147,7 @@ impl ControlService {
cursor_harness: CursorHarness::new(store.clone())?,
store,
provider,
model_tests: Arc::new(Mutex::new(BTreeMap::new())),
})
}
@@ -243,7 +249,49 @@ impl ControlService {
self.store.update_model(model_hash, input).await
}
pub async fn test_model(&self, model_hash: &str) -> Result<ModelConnectivityResult> {
pub async fn test_model(
&self,
model_hash: &str,
test_id: &str,
) -> Result<ModelConnectivityResult> {
let cancellation = CancellationToken::new();
let cancellation = {
let mut tests = self
.model_tests
.lock()
.expect("model test registry mutex poisoned");
tests
.entry(test_id.to_owned())
.or_insert_with(|| cancellation.clone())
.clone()
};
let result = self.run_model_test(model_hash, cancellation).await;
self.model_tests
.lock()
.expect("model test registry mutex poisoned")
.remove(test_id);
result
}
pub fn cancel_model_test(&self, test_id: &str) {
let cancellation = {
let mut tests = self
.model_tests
.lock()
.expect("model test registry mutex poisoned");
tests
.entry(test_id.to_owned())
.or_insert_with(CancellationToken::new)
.clone()
};
cancellation.cancel();
}
async fn run_model_test(
&self,
model_hash: &str,
cancellation: CancellationToken,
) -> 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.";
@@ -255,12 +303,11 @@ impl ControlService {
let mut model = ModelSpec::new(model_hash);
configured.configure(&mut model);
model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
let test_id = format!("model-test-{}", uuid::Uuid::new_v4());
let call_id = test_id.clone();
let call_id = format!("model-test-{}", uuid::Uuid::new_v4());
let invocation = ModelInvocation {
call_id: test_id.clone(),
run_id: test_id.clone(),
conversation_id: test_id,
call_id: call_id.clone(),
run_id: call_id.clone(),
conversation_id: call_id.clone(),
provider_call_index: 0,
request: ModelRequest {
prompt: PromptSpec {
@@ -277,7 +324,6 @@ impl ControlService {
}],
},
};
let cancellation = CancellationToken::new();
let started = Instant::now();
let mut first_text_at = None;
let mut output_tokens = None;
@@ -305,6 +351,9 @@ impl ControlService {
_ => {}
}
}
if cancellation.is_cancelled() {
return Err(Error::Cancelled);
}
if !finished {
return Err(Error::Protocol(
"provider stream ended without Done during connectivity test".into(),
@@ -792,7 +841,7 @@ mod tests {
use tokio_util::sync::CancellationToken;
use crate::{
model::{ModelConfigInput, ModelInvocation, ModelType, ProjectedContent},
model::{ModelConfig, ModelConfigInput, ModelInvocation, ModelType, ProjectedContent},
provider::{FinishReason, ModelEvent, Provider, ProviderStream},
store::Store,
};
@@ -803,6 +852,10 @@ mod tests {
invocation: Arc<Mutex<Option<ModelInvocation>>>,
}
struct CancellationProvider {
started: Arc<tokio::sync::Notify>,
}
impl Provider for TestProvider {
fn stream(
&self,
@@ -826,17 +879,23 @@ mod tests {
}
}
#[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 model = store
impl Provider for CancellationProvider {
fn stream(
&self,
_invocation: ModelInvocation,
cancellation: CancellationToken,
) -> ProviderStream {
let started = self.started.clone();
Box::pin(async_stream::try_stream! {
started.notify_one();
cancellation.cancelled().await;
if false { yield ModelEvent::TextStart; }
})
}
}
async fn create_test_model(store: &Store) -> ModelConfig {
store
.create_model(&ModelConfigInput {
model_id: "reasoning-model".into(),
display_name: "Reasoning Model".into(),
@@ -861,7 +920,20 @@ mod tests {
thinking_budget_tokens: None,
})
.await
.unwrap();
.unwrap()
}
#[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 model = create_test_model(&store).await;
let service = ControlService::new(
store,
Arc::new(TestProvider {
@@ -870,7 +942,10 @@ mod tests {
)
.unwrap();
let result = service.test_model(&model.model_hash).await.unwrap();
let result = service
.test_model(&model.model_hash, "test-id")
.await
.unwrap();
assert_eq!(result.output, "OK");
assert_eq!(result.output_tokens, 2);
@@ -892,6 +967,42 @@ mod tests {
));
}
#[tokio::test]
async fn connectivity_test_can_be_cancelled() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("cancel.db").display()
))
.await
.unwrap();
let model = create_test_model(&store).await;
let started = Arc::new(tokio::sync::Notify::new());
let service = ControlService::new(
store,
Arc::new(CancellationProvider {
started: started.clone(),
}),
)
.unwrap();
let running_service = service.clone();
let model_hash = model.model_hash.clone();
let task =
tokio::spawn(
async move { running_service.test_model(&model_hash, "cancel-test").await },
);
started.notified().await;
service.cancel_model_test("cancel-test");
assert!(matches!(task.await.unwrap(), Err(crate::Error::Cancelled)));
assert!(!service
.model_tests
.lock()
.unwrap()
.contains_key("cancel-test"));
}
#[test]
fn connectivity_output_token_estimate_handles_words_and_empty_text() {
assert_eq!(super::estimate_output_tokens("1 2 3"), 3);
+50 -14
View File
@@ -151,15 +151,39 @@ impl CursorActor {
context.dynamic_tools.keys().cloned().collect(),
context.turn_user.clone(),
);
if context.background_completion
&& dependencies
.run_registry
.insert_messages(
&prepared.conversation_id,
prepared.initial_messages.clone(),
)
.await
{
crate::cursor::lifecycle::finish_success(
&handle,
);
let _ = handle
.command(CursorCommand::Finished)
.await;
return;
}
let cancellation = handle.cancellation();
let (port, core) = crate::client::session(256);
let core_commands = core.commands.clone();
let actor = RunActor::new(
dependencies.store.clone(),
dependencies.provider,
dependencies.run_registry,
);
let core_run =
actor.spawn(prepared, port, cancellation).await;
let core_run = actor
.spawn(
prepared,
port,
core_commands,
cancellation,
)
.await;
let session = CursorSession::new(
handle.clone(),
dependencies.store,
@@ -181,7 +205,6 @@ impl CursorActor {
%error,
"Cursor session failed"
);
handle.cancel();
let _ = crate::cursor::lifecycle::fail(
&handle, &error,
);
@@ -235,12 +258,14 @@ impl CursorActor {
{
continue;
}
if tool_runtime.take_exec(close.id).await.is_some()
match codec::stream_closed(close.id, &tool_runtime)
.await
{
results_tx.send_error(crate::Error::Protocol(format!(
"Exec stream closed before result for id: {}",
close.id
)));
Ok(Some(completion)) => {
results_tx.send(completion)
}
Ok(None) => {}
Err(error) => results_tx.send_error(error),
}
}
Some(Message::Throw(throw)) => {
@@ -311,13 +336,12 @@ impl CursorActor {
//
// The remaining unimplemented Action variants are
// ShellCommandAction, StartPlanAction,
// AsyncAskQuestionCompletionAction, CancelSubagentAction,
// BackgroundShellAction, BackgroundSubagentAction,
// AsyncAskQuestionCompletionAction, BackgroundShellAction,
// BackgroundSubagentAction,
// SubscriptionNotificationAction and GoalContinuationAction.
// CancelSubagentAction must not start an LLM; variants whose wire
// behavior is not captured yet need evidence before assigning
// semantics. Every unsupported runtime Action must return an explicit
// Protocol Error rather than falling through silently.
// Variants whose wire behavior is not captured yet need evidence
// before assigning semantics. Every unsupported runtime Action must
// return an explicit Protocol Error rather than falling through silently.
Some(
pb::agent_client_message::Message::ConversationAction(
action,
@@ -341,6 +365,18 @@ impl CursorActor {
));
}
}
Some(
pb::conversation_action::Action::CancelSubagentAction(
action,
),
) => {
if let Some(id) = tool_runtime
.running_task_exec_id(&action.subagent_id)
.await
{
let _ = handle.emit(&codec::abort(id));
}
}
Some(action) => {
results_tx.send_error(crate::Error::Protocol(format!(
"unsupported runtime ConversationAction: {}",
+94 -6
View File
@@ -1,11 +1,35 @@
use crate::{store::BlobId, store::Store};
use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::{Duration, Instant},
};
use tokio::sync::Mutex;
use crate::store::{BlobId, BufferedCursorTraceChunk, Store};
#[derive(Clone)]
pub struct CursorTraceRecorder {
store: Store,
request_id: String,
chunks: Arc<Mutex<TraceChunkBuffer>>,
finished: Arc<AtomicBool>,
}
#[derive(Default)]
struct TraceChunkBuffer {
chunks: Vec<BufferedCursorTraceChunk>,
bytes: usize,
first_chunk_at: Option<Instant>,
generation: u64,
}
const MAX_BUFFERED_CHUNKS: usize = 32;
const MAX_BUFFERED_BYTES: usize = 256 * 1024;
const MAX_BUFFER_AGE: Duration = Duration::from_millis(50);
impl CursorTraceRecorder {
pub async fn begin(
store: Store,
@@ -21,6 +45,8 @@ impl CursorTraceRecorder {
Ok(true) => Some(Self {
store,
request_id: request_id.into(),
chunks: Arc::new(Mutex::new(TraceChunkBuffer::default())),
finished: Arc::new(AtomicBool::new(false)),
}),
Ok(false) => None,
Err(error) => {
@@ -35,6 +61,8 @@ impl CursorTraceRecorder {
Ok(true) => Some(Self {
store,
request_id: request_id.into(),
chunks: Arc::new(Mutex::new(TraceChunkBuffer::default())),
finished: Arc::new(AtomicBool::new(false)),
}),
Ok(false) => None,
Err(error) => {
@@ -115,16 +143,56 @@ impl CursorTraceRecorder {
}
pub async fn response_chunk(&self, source: &str, data: &[u8]) {
if let Err(error) = self
.store
.add_cursor_trace_response_chunk(&self.request_id, source, data)
.await
let mut buffer = self.chunks.lock().await;
if self.finished.load(Ordering::Acquire) {
return;
}
let schedule_flush = if buffer.chunks.is_empty() {
buffer.generation = buffer.generation.wrapping_add(1);
buffer.first_chunk_at = Some(Instant::now());
Some(buffer.generation)
} else {
None
};
buffer.bytes += data.len();
buffer
.chunks
.push(BufferedCursorTraceChunk::new(source, data));
let expired = buffer
.first_chunk_at
.is_some_and(|started| started.elapsed() >= MAX_BUFFER_AGE);
if buffer.chunks.len() >= MAX_BUFFERED_CHUNKS
|| buffer.bytes >= MAX_BUFFERED_BYTES
|| expired
{
tracing::warn!(request_id = self.request_id, %error, "failed to record Cursor response chunk");
if let Err(error) = self.flush_locked(&mut buffer).await {
tracing::warn!(request_id = self.request_id, %error, "failed to record Cursor response chunk");
}
}
drop(buffer);
if let Some(generation) = schedule_flush {
let recorder = self.clone();
tokio::spawn(async move {
tokio::time::sleep(MAX_BUFFER_AGE).await;
let mut buffer = recorder.chunks.lock().await;
if buffer.generation == generation {
if let Err(error) = recorder.flush_locked(&mut buffer).await {
tracing::warn!(request_id = recorder.request_id, %error, "failed to flush Cursor response chunks");
}
}
});
}
}
pub async fn finish(&self, error: Option<&str>) {
if self.finished.swap(true, Ordering::AcqRel) {
return;
}
let mut buffer = self.chunks.lock().await;
if let Err(store_error) = self.flush_locked(&mut buffer).await {
tracing::warn!(request_id = self.request_id, %store_error, "failed to flush Cursor response chunks");
}
drop(buffer);
if let Err(store_error) = self
.store
.finish_cursor_trace(&self.request_id, error)
@@ -133,4 +201,24 @@ impl CursorTraceRecorder {
tracing::warn!(request_id = self.request_id, %store_error, "failed to finish Cursor trace");
}
}
async fn flush_locked(&self, buffer: &mut TraceChunkBuffer) -> crate::Result<()> {
if buffer.chunks.is_empty() {
return Ok(());
}
let chunks = std::mem::take(&mut buffer.chunks);
buffer.bytes = 0;
buffer.first_chunk_at = None;
if let Err(error) = self
.store
.add_cursor_trace_response_chunks(&self.request_id, &chunks)
.await
{
buffer.bytes = chunks.iter().map(|chunk| chunk.data.len()).sum();
buffer.first_chunk_at = Some(Instant::now());
buffer.chunks = chunks;
return Err(error);
}
Ok(())
}
}
+76 -10
View File
@@ -1,4 +1,4 @@
use std::collections::BTreeSet;
use std::collections::BTreeMap;
use crate::{cursor::proto::agent::v1 as pb, Error, Result};
@@ -35,8 +35,7 @@ pub(super) fn project(
));
}
let mut identities = BTreeSet::new();
let mut contexts = Vec::with_capacity(action.completions.len());
let mut completions = BTreeMap::new();
let mut has_shell = false;
let mut has_subagent = false;
for completion in &action.completions {
@@ -88,17 +87,30 @@ pub(super) fn project(
}
pb::BackgroundTaskKind::Unspecified => unreachable!(),
};
let identity = agent_id.unwrap_or(&completion.task_id);
let identity = format!("{}:{identity}", kind.as_str_name());
if !identities.insert(identity.clone()) {
let tool_call_id = completion
.tool_call_id
.as_deref()
.filter(|id| !id.is_empty())
.ok_or_else(|| {
Error::Protocol("background task completion has no tool_call_id".into())
})?;
let task_identity = agent_id.unwrap_or(&completion.task_id);
let identity = format!("{}:{task_identity}:{tool_call_id}", kind.as_str_name());
let context = completion_context(completion, kind, agent_id)?;
if completions
.insert(identity.clone(), (completion, context))
.is_some()
{
return Err(Error::Protocol(format!(
"duplicate background task completion: {identity}"
)));
}
contexts.push(completion_context(completion, kind, agent_id)?);
}
let first = &action.completions[0];
let (first, _) = completions
.values()
.next()
.expect("background completion action was validated as non-empty");
let text = match (has_shell, has_subagent) {
(true, false) => SHELL_FOLLOW_UP.into(),
(false, true) => FOLLOW_UP.into(),
@@ -106,12 +118,16 @@ pub(super) fn project(
(false, false) => unreachable!(),
};
Ok(Projection {
context: contexts.join("\n\n"),
context: completions
.values()
.map(|(_, context)| context.as_str())
.collect::<Vec<_>>()
.join("\n\n"),
turn_user: pb::UserMessage {
text,
message_id: format!(
"background-completed:{}",
identities.into_iter().collect::<Vec<_>>().join(":")
completions.keys().cloned().collect::<Vec<_>>().join(":")
),
mode,
is_simulated_msg: Some(true),
@@ -272,6 +288,56 @@ mod tests {
assert!(projection.turn_user.text.contains(FOLLOW_UP));
}
#[test]
fn completion_batch_projection_is_independent_of_input_order() {
let first = completion();
let mut second = completion();
second.task_id = "child-id-2".into();
second.subagent_id = Some("child-id-2".into());
second.tool_call_id = Some("task-call-2".into());
let forward = project(
&pb::BackgroundTaskCompletionAction {
completions: vec![first.clone(), second.clone()],
},
pb::AgentMode::Multitask as i32,
)
.unwrap();
let reversed = project(
&pb::BackgroundTaskCompletionAction {
completions: vec![second, first],
},
pb::AgentMode::Multitask as i32,
)
.unwrap();
assert_eq!(forward.turn_user, reversed.turn_user);
assert_eq!(forward.context, reversed.context);
}
#[test]
fn resumed_subagent_completions_use_the_task_call_as_part_of_their_identity() {
let first = project(
&pb::BackgroundTaskCompletionAction {
completions: vec![completion()],
},
pb::AgentMode::Multitask as i32,
)
.unwrap();
let mut resumed = completion();
resumed.tool_call_id = Some("task-call-2".into());
let second = project(
&pb::BackgroundTaskCompletionAction {
completions: vec![resumed],
},
pb::AgentMode::Multitask as i32,
)
.unwrap();
assert_ne!(first.turn_user.message_id, second.turn_user.message_id);
assert!(first.turn_user.message_id.ends_with(":task-call"));
assert!(second.turn_user.message_id.ends_with(":task-call-2"));
}
#[test]
fn completion_requires_the_captured_subagent_identity_and_terminal_reason() {
let mut value = completion();
+83 -20
View File
@@ -41,6 +41,7 @@ pub struct CursorRunContext {
pub dynamic_tools: BTreeMap<String, pb::McpToolDefinition>,
pub checkpoint_prompt: PromptSpec,
pub compacting: bool,
pub background_completion: bool,
}
pub(crate) struct PrepareDependencies<'a> {
@@ -123,7 +124,7 @@ pub(crate) async fn prepare(
mode: mode_number,
mut turn_user,
action_context,
event_id,
mut event_id,
input_id,
starts_turn,
compacting,
@@ -172,16 +173,45 @@ pub(crate) async fn prepare(
}
Some(_) | None => store.ensure_conversation(&conversation_id).await?,
};
let base_revision_id = match input_id {
let base_revision_id = match input_id.as_deref() {
Some(input_id) => {
store
.anchor_input(&conversation_id, &input_id, proposed_base_revision_id)
.anchor_input(&conversation_id, input_id, proposed_base_revision_id)
.await?
}
None => proposed_base_revision_id,
};
let mut projected_user_context = if input_id.is_some() && !compacting && !background_completion
{
runtime::compile_request_context(
"identity",
&request_context,
base_messages.as_deref().unwrap_or_default(),
)?
} else {
None
};
if event_id.is_none() {
if let (Some(input_id), Some(user)) = (input_id.as_deref(), turn_user.as_ref()) {
event_id = Some(
runtime::user_event_id(
input_id,
checkpoint_mode,
user,
&request_context,
&action_context,
projected_user_context
.as_ref()
.map(|message| &message.content),
compiler,
blob_sync,
)
.await?,
);
}
}
let existing_runtime = match event_id.as_deref() {
Some(event_id) if !background_completion => {
Some(event_id) => {
store
.message(&conversation_id, &format!("runtime:{event_id}"))
.await?
@@ -193,6 +223,10 @@ pub(crate) async fn prepare(
let message_id = format!("request-context:{event_id}");
match store.message(&conversation_id, &message_id).await? {
Some(message) => Some(message),
None if input_id.is_some() => projected_user_context.take().map(|mut message| {
message.message_id = message_id;
message
}),
None => runtime::compile_request_context(
event_id,
&request_context,
@@ -202,19 +236,27 @@ pub(crate) async fn prepare(
}
_ => None,
};
let initial_messages = if compacting {
let mut initial_messages = if compacting {
Vec::new()
} else {
match (turn_user.clone(), event_id) {
(Some(mut user), Some(event_id)) if background_completion => {
let (message, text) = runtime::compile_background(
event_id,
&user,
&request_context,
&action_context,
blob_sync,
)
.await?;
let (message, text) = match existing_runtime {
Some(message) => {
let text = runtime_message_text(&message)?;
(message, text)
}
None => {
runtime::compile_background(
event_id,
&user,
&request_context,
&action_context,
blob_sync,
)
.await?
}
};
user.text = text;
turn_user = Some(user);
vec![message]
@@ -248,6 +290,10 @@ pub(crate) async fn prepare(
}
}
};
let (base_revision_id, reused) = store
.match_revision_prefix(&conversation_id, base_revision_id, &initial_messages)
.await?;
initial_messages.drain(..reused);
let action = if compacting {
RunAction::Compact
} else if starts_turn {
@@ -302,10 +348,25 @@ pub(crate) async fn prepare(
.collect(),
checkpoint_prompt,
compacting,
background_completion,
},
))
}
fn runtime_message_text(message: &CanonicalMessage) -> Result<String> {
let MessageContent::Parts { parts } = &message.content else {
return Err(Error::Protocol(
"stored runtime message does not contain parts".into(),
));
};
let Some(ContentPart::Text { text }) = parts.first() else {
return Err(Error::Protocol(
"stored runtime message does not start with text".into(),
));
};
Ok(text.clone())
}
fn run_kind(subagent_type_name: Option<&str>, parent: Option<(RunId, String)>) -> Result<RunKind> {
match (subagent_type_name, parent) {
(None | Some("side-chat"), _) => Ok(RunKind::Root),
@@ -412,13 +473,13 @@ fn action(request: &pb::AgentRunRequest) -> Result<ActionProjection> {
.filter(|text| !text.is_empty())
.cloned(),
);
let event_id = format!("cursor:user:{}", user.message_id);
let input_id = format!("cursor:user:{}", user.message_id);
Ok(ActionProjection {
mode,
turn_user: Some(user.clone()),
action_context: context.join("\n\n"),
event_id: Some(event_id.clone()),
input_id: Some(event_id),
event_id: None,
input_id: Some(input_id),
starts_turn: true,
compacting: false,
background_completion: false,
@@ -681,7 +742,7 @@ mod tests {
}
#[test]
fn queued_messages_reusing_a_request_id_keep_distinct_runtime_identities() {
fn queued_messages_keep_distinct_input_anchors_until_runtime_identity_is_compiled() {
let request = |message_id: &str| pb::AgentRunRequest {
action: Some(pb::ConversationAction {
action: Some(pb::conversation_action::Action::UserMessageAction(
@@ -703,9 +764,11 @@ mod tests {
let first = action(&request("message-one")).unwrap();
let second = action(&request("message-two")).unwrap();
assert_eq!(first.event_id.as_deref(), Some("cursor:user:message-one"));
assert_eq!(second.event_id.as_deref(), Some("cursor:user:message-two"));
assert_ne!(first.event_id, second.event_id);
assert_eq!(first.event_id, None);
assert_eq!(second.event_id, None);
assert_eq!(first.input_id.as_deref(), Some("cursor:user:message-one"));
assert_eq!(second.input_id.as_deref(), Some("cursor:user:message-two"));
assert_ne!(first.input_id, second.input_id);
}
#[test]
+58 -3
View File
@@ -10,6 +10,7 @@ use crate::{
proto::agent::v1 as pb,
},
model::{CanonicalMessage, MessageContent, Origin, Role},
store::BlobId,
Error, Result,
};
@@ -80,12 +81,66 @@ pub async fn compile(
compiler: &PromptCompiler,
blobs: &BlobSynchronizer,
) -> Result<CanonicalMessage> {
let time = Time::now(
let timestamp = Time::now(
request_context
.env
.as_ref()
.map(|env| env.time_zone.as_str()),
)?;
)?
.timestamp;
compile_with_timestamp(
event_id,
mode,
user,
request_context,
action_context,
timestamp,
compiler,
blobs,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn user_event_id(
input_id: &str,
mode: Mode,
user: &pb::UserMessage,
request_context: &pb::RequestContext,
action_context: &str,
projected_request_context: Option<&MessageContent>,
compiler: &PromptCompiler,
blobs: &BlobSynchronizer,
) -> Result<String> {
let runtime = compile_with_timestamp(
"identity".into(),
mode,
user,
request_context,
action_context,
String::new(),
compiler,
blobs,
)
.await?;
let semantic = serde_json::to_vec(&(projected_request_context, runtime.content))?;
Ok(format!(
"{input_id}:{}",
BlobId::digest(&semantic).to_base64()
))
}
#[allow(clippy::too_many_arguments)]
async fn compile_with_timestamp(
event_id: String,
mode: Mode,
user: &pb::UserMessage,
request_context: &pb::RequestContext,
action_context: &str,
timestamp: String,
compiler: &PromptCompiler,
blobs: &BlobSynchronizer,
) -> Result<CanonicalMessage> {
let mut values = BTreeMap::from([
("OPEN_FILES", section(open_files(user))),
(
@@ -98,7 +153,7 @@ pub async fn compile(
),
),
("ACTION_CONTEXT", section(action_context.to_string())),
("TIMESTAMP", time.timestamp),
("TIMESTAMP", timestamp),
("USER_QUERY", user.text.clone()),
("DEBUG_SERVER_ENDPOINT", String::new()),
("DEBUG_LOG_PATH", String::new()),
+93 -2
View File
@@ -9,7 +9,11 @@ use tokio_stream::StreamExt;
use tokio_util::sync::CancellationToken;
use crate::{
cursor::{connect::END_STREAM_FLAG, observability::CursorTraceRecorder, CursorSessionRegistry},
cursor::{
connect::{self, END_STREAM_FLAG},
observability::CursorTraceRecorder,
CursorSessionRegistry,
},
Result,
};
@@ -49,7 +53,7 @@ fn local_body_stream(
trace.chunk(&chunk);
if terminal {
guard.complete();
trace.finish(None);
trace.finish(end_stream_error(&chunk));
}
yield Ok::<Bytes, Infallible>(chunk);
if terminal {
@@ -67,6 +71,30 @@ fn is_end_stream_frame(frame: &Bytes) -> bool {
.is_some_and(|flags| flags & END_STREAM_FLAG != 0)
}
fn end_stream_error(frame: &Bytes) -> Option<String> {
connect::decode_frames(frame)
.ok()?
.into_iter()
.find_map(|(flags, payload)| {
if flags & END_STREAM_FLAG == 0 {
return None;
}
let value = serde_json::from_slice::<serde_json::Value>(&payload).ok()?;
let error = value.get("error")?;
let code = error.get("code").and_then(serde_json::Value::as_str);
let message = error
.get("message")
.and_then(serde_json::Value::as_str)
.filter(|message| !message.is_empty());
Some(match (code, message) {
(Some(code), Some(message)) => format!("{code}: {message}"),
(Some(code), None) => code.to_string(),
(None, Some(message)) => message.to_string(),
(None, None) => error.to_string(),
})
})
}
struct LocalRunGuard {
cancellation: CancellationToken,
completed: bool,
@@ -233,4 +261,67 @@ mod tests {
drop(stream);
assert!(!cancellation.is_cancelled());
}
#[test]
fn connect_error_end_stream_exposes_the_trace_error() {
let frame = connect::encode_error_end_stream(&connect::ConnectStreamError {
code: connect::ConnectCode::InvalidArgument,
message: "unsupported runtime action".into(),
details: Vec::new(),
})
.unwrap();
assert_eq!(
end_stream_error(&frame).as_deref(),
Some("invalid_argument: unsupported runtime action")
);
assert_eq!(end_stream_error(&connect::encode_end_stream()), None);
}
#[tokio::test]
async fn connect_error_end_stream_marks_the_local_trace_as_error() {
let store = crate::store::Store::connect("sqlite::memory:")
.await
.unwrap();
store.set_detailed_logging(true).await.unwrap();
let trace = CursorTraceRecorder::begin(
store.clone(),
"error-trace",
Some("conversation"),
"local_byok",
Some("model"),
)
.await
.unwrap();
let (sender, receiver) = mpsc::unbounded_channel();
let cancellation = CancellationToken::new();
sender
.send(
connect::encode_error_end_stream(&connect::ConnectStreamError {
code: connect::ConnectCode::InvalidArgument,
message: "unsupported runtime action".into(),
details: Vec::new(),
})
.unwrap(),
)
.unwrap();
let mut stream = Box::pin(local_body_stream(receiver, cancellation, Some(trace)));
stream.next().await.unwrap().unwrap();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
let trace = loop {
let trace = store.cursor_trace("error-trace").await.unwrap().unwrap();
if trace.status != "running" {
break trace;
}
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
};
assert_eq!(trace.status, "error");
assert_eq!(
trace.error_message.as_deref(),
Some("invalid_argument: unsupported runtime action")
);
}
}
+17
View File
@@ -88,6 +88,23 @@ impl CursorSession {
}
pub async fn run(mut self) -> Result<()> {
let result = self.run_inner().await;
if let Err(error) = &result {
self.abort_execs().await;
let error = match error {
Error::Protocol(message) => message.clone(),
error => error.to_string(),
};
let _ = self
.core
.commands
.send(ClientCommand::ClientClosed { error })
.await;
}
result
}
async fn run_inner(&mut self) -> Result<()> {
if self.context.compacting {
self.handle.emit(&interaction::summary_started())?;
}
+1 -1
View File
@@ -5,4 +5,4 @@ pub use request::{abort, mcp_request, mcp_state_request, request};
pub(crate) use request::{
await_read_request, edit_read_request, json_object_to_prost, mcp_meta_request,
};
pub use response::{client_event, ClientExecEvent};
pub use response::{client_event, stream_closed, ClientExecEvent};
+47
View File
@@ -130,6 +130,53 @@ pub async fn client_event(
Ok(event)
}
pub async fn stream_closed(id: u32, pending: &CursorToolRuntime) -> Result<Option<ToolCompletion>> {
let Some(entry) = pending.take_exec(id).await else {
return Ok(None);
};
let error = "Cursor Exec stream closed before returning a terminal result";
if entry.call.name.eq_ignore_ascii_case("Shell") {
let command = entry
.call
.arguments
.get("command")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let working_directory = entry
.call
.arguments
.get("working_directory")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
return Ok(Some(result::from_exec(
entry,
&pb::exec_client_message::Message::ShellResult(pb::ShellResult {
result: Some(pb::shell_result::Result::SpawnError(pb::ShellSpawnError {
command,
working_directory,
error: error.into(),
})),
..Default::default()
}),
)?));
}
let rendered = match &entry.stage {
ExecStage::DynamicMcp(definition) => {
interaction::render_dynamic_mcp(&entry.call, definition, false)
}
_ => interaction::render_tool_call(&entry.call, false)?,
};
Ok(Some(ToolCompletion::from_rendered(
&entry.call,
entry.started_at_ms,
error.into(),
true,
rendered,
)?))
}
async fn advance_await(
entry: PendingExec,
result: &pb::exec_client_message::Message,
+12
View File
@@ -338,6 +338,18 @@ impl CursorToolRuntime {
ids
}
pub async fn running_task_exec_id(&self, call_id: &str) -> Option<u32> {
self.execs
.lock()
.await
.iter()
.filter_map(|(id, entry)| {
(entry.call.call_id == call_id && entry.call.name.eq_ignore_ascii_case("Task"))
.then_some(*id)
})
.min()
}
fn next_id(&self) -> Result<u32> {
self.next_id
.fetch_add(1, Ordering::Relaxed)
+213 -15
View File
@@ -6,9 +6,11 @@ use std::{
time::Instant,
};
use tokio::sync::Mutex;
use crate::{
model::{NewLlmCall, Usage},
store::Store,
store::{BufferedLlmChunk, Store},
Result,
};
@@ -44,9 +46,23 @@ struct Inner {
started: Instant,
detailed: bool,
next_chunk: AtomicI64,
chunks: Mutex<ChunkBuffer>,
first_text_recorded: AtomicBool,
finished: AtomicBool,
}
#[derive(Default)]
struct ChunkBuffer {
chunks: Vec<BufferedLlmChunk>,
bytes: usize,
first_chunk_at: Option<Instant>,
generation: u64,
}
const MAX_BUFFERED_CHUNKS: usize = 32;
const MAX_BUFFERED_BYTES: usize = 256 * 1024;
const MAX_BUFFER_AGE: std::time::Duration = std::time::Duration::from_millis(50);
impl CallRecorder {
pub async fn start(store: Store, mut call: NewLlmCall) -> Result<Self> {
call.detailed = store.detailed_logging().await?;
@@ -58,6 +74,8 @@ impl CallRecorder {
started: Instant::now(),
detailed: call.detailed,
next_chunk: AtomicI64::new(0),
chunks: Mutex::new(ChunkBuffer::default()),
first_text_recorded: AtomicBool::new(false),
finished: AtomicBool::new(false),
}),
})
@@ -91,26 +109,67 @@ impl CallRecorder {
}
pub async fn response_chunk(&self, data: &[u8]) -> Result<()> {
let mut buffer = self.inner.chunks.lock().await;
if self.is_finished() {
return Ok(());
}
let seq = self.inner.next_chunk.fetch_add(1, Ordering::Relaxed);
self.inner
.store
.record_llm_chunk(
&self.inner.call_id,
seq,
self.elapsed_ms(),
data,
self.inner.detailed,
)
.await
let schedule_flush = if buffer.chunks.is_empty() {
buffer.generation = buffer.generation.wrapping_add(1);
buffer.first_chunk_at = Some(Instant::now());
Some(buffer.generation)
} else {
None
};
buffer.bytes += data.len();
buffer.chunks.push(if self.inner.detailed {
BufferedLlmChunk::new(seq, self.elapsed_ms(), data)
} else {
BufferedLlmChunk::metrics(seq, self.elapsed_ms(), data.len())
});
let expired = buffer
.first_chunk_at
.is_some_and(|started| started.elapsed() >= MAX_BUFFER_AGE);
if buffer.chunks.len() >= MAX_BUFFERED_CHUNKS
|| buffer.bytes >= MAX_BUFFERED_BYTES
|| expired
{
self.flush_locked(&mut buffer).await?;
}
drop(buffer);
if let Some(generation) = schedule_flush {
let recorder = self.clone();
tokio::spawn(async move {
tokio::time::sleep(MAX_BUFFER_AGE).await;
if let Err(error) = recorder.flush_generation(generation).await {
tracing::warn!(call_id = recorder.inner.call_id, %error, "failed to flush LLM response chunks");
}
});
}
Ok(())
}
pub async fn event(&self, event: &ModelEvent) -> Result<()> {
match event {
ModelEvent::TextDelta(_) => {
self.inner
.store
.record_llm_first_text(&self.inner.call_id, self.elapsed_ms())
.await?;
if self
.inner
.first_text_recorded
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
if let Err(error) = self
.inner
.store
.record_llm_first_text(&self.inner.call_id, self.elapsed_ms())
.await
{
self.inner
.first_text_recorded
.store(false, Ordering::Release);
return Err(error);
}
}
}
ModelEvent::Usage(usage) => self.usage(*usage).await?,
ModelEvent::Done(reason) => self.completed(*reason).await?,
@@ -155,6 +214,10 @@ impl CallRecorder {
if self.inner.finished.swap(true, Ordering::AcqRel) {
return Ok(());
}
if let Err(error) = self.flush_chunks().await {
self.inner.finished.store(false, Ordering::Release);
return Err(error);
}
self.inner
.store
.finish_llm_call(
@@ -168,6 +231,40 @@ impl CallRecorder {
.await
}
async fn flush_chunks(&self) -> Result<()> {
let mut buffer = self.inner.chunks.lock().await;
self.flush_locked(&mut buffer).await
}
async fn flush_generation(&self, generation: u64) -> Result<()> {
let mut buffer = self.inner.chunks.lock().await;
if buffer.generation != generation {
return Ok(());
}
self.flush_locked(&mut buffer).await
}
async fn flush_locked(&self, buffer: &mut ChunkBuffer) -> Result<()> {
if buffer.chunks.is_empty() {
return Ok(());
}
let chunks = std::mem::take(&mut buffer.chunks);
buffer.bytes = 0;
buffer.first_chunk_at = None;
if let Err(error) = self
.inner
.store
.record_llm_chunks(&self.inner.call_id, &chunks, self.inner.detailed)
.await
{
buffer.bytes = chunks.iter().map(|chunk| chunk.byte_count).sum();
buffer.first_chunk_at = Some(Instant::now());
buffer.chunks = chunks;
return Err(error);
}
Ok(())
}
fn elapsed_ms(&self) -> i64 {
self.inner
.started
@@ -193,3 +290,104 @@ fn error_kind(error: &crate::Error) -> &'static str {
_ => "internal",
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_recorder(store: &Store, call_id: &str, detailed: bool) -> CallRecorder {
sqlx::query(
"INSERT INTO llm_calls(
call_id, run_id, conversation_id, provider_call_index, provider_type,
provider_url, request_type, request_url, model_id, display_name, status,
created_at_ms, message_count, tool_count, detailed
) VALUES (?, 'run', 'conversation', 0, 'openai-chat',
'https://example.com', 'openai-chat', 'https://example.com',
'model', 'Model', 'running', 1, 0, 0, ?)",
)
.bind(call_id)
.bind(detailed)
.execute(store.pool())
.await
.unwrap();
CallRecorder {
inner: Arc::new(Inner {
store: store.clone(),
call_id: call_id.into(),
started: Instant::now(),
detailed,
next_chunk: AtomicI64::new(0),
chunks: Mutex::new(ChunkBuffer::default()),
first_text_recorded: AtomicBool::new(false),
finished: AtomicBool::new(false),
}),
}
}
#[tokio::test]
async fn a_partial_chunk_batch_flushes_after_the_deadline() {
let store = Store::connect("sqlite::memory:").await.unwrap();
let recorder = test_recorder(&store, "timed-flush-call", true).await;
recorder.response_chunk(b"chunk").await.unwrap();
assert_eq!(
store
.llm_call("timed-flush-call")
.await
.unwrap()
.unwrap()
.stream_event_count,
0
);
tokio::time::sleep(MAX_BUFFER_AGE + std::time::Duration::from_millis(100)).await;
let call = store.llm_call("timed-flush-call").await.unwrap().unwrap();
assert_eq!(call.response_bytes, 5);
assert_eq!(call.stream_event_count, 1);
assert_eq!(
store
.llm_call_chunks("timed-flush-call")
.await
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn first_text_is_persisted_only_once() {
let store = Store::connect("sqlite::memory:").await.unwrap();
let recorder = test_recorder(&store, "first-text-call", false).await;
sqlx::query("CREATE TABLE first_text_updates(count INTEGER NOT NULL)")
.execute(store.pool())
.await
.unwrap();
sqlx::query("INSERT INTO first_text_updates(count) VALUES (0)")
.execute(store.pool())
.await
.unwrap();
sqlx::query(
"CREATE TRIGGER count_first_text_updates
AFTER UPDATE OF first_text_at_ms ON llm_calls
BEGIN
UPDATE first_text_updates SET count = count + 1;
END",
)
.execute(store.pool())
.await
.unwrap();
for text in ["one", "two", "three"] {
recorder
.event(&ModelEvent::TextDelta(text.into()))
.await
.unwrap();
}
let count: i64 = sqlx::query_scalar("SELECT count FROM first_text_updates")
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(count, 1);
}
}
+8 -1
View File
@@ -2,7 +2,12 @@ use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use crate::{client::ClientPort, model::PreparedRun, provider::Provider, store::Store};
use crate::{
client::{ClientCommand, ClientPort},
model::PreparedRun,
provider::Provider,
store::Store,
};
use super::{RunEngine, RunOutcome, RunRegistry};
@@ -26,6 +31,7 @@ impl RunActor {
&self,
prepared: PreparedRun,
client: ClientPort,
commands: tokio::sync::mpsc::Sender<ClientCommand>,
cancellation: CancellationToken,
) -> tokio::task::JoinHandle<RunOutcome> {
let run_id = prepared.run_id.clone();
@@ -35,6 +41,7 @@ impl RunActor {
conversation_id.clone(),
run_id.clone(),
cancellation.clone(),
commands,
)
.await;
let actor = self.clone();
+81 -13
View File
@@ -4,7 +4,10 @@ use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use crate::{
client::{ClientEvent, ClientPort, CommitBarrier, CommitCause, StateCommitted},
client::{
ClientCommand, ClientEvent, ClientPort, CommitBarrier, CommitCause, MessageInsertion,
StateCommitted,
},
model::{
CanonicalMessage, MessageContent, Origin, PreparedRun, Role, RunAction, ToolRoundAssistant,
ToolRoundId, Usage,
@@ -158,6 +161,7 @@ impl RunEngine {
calls: round.calls.clone(),
recovered_started_at_ms: Some(round.started_at_ms),
},
Vec::new(),
)
.await
{
@@ -242,21 +246,27 @@ impl RunEngine {
&cycle_cancellation,
);
tokio::pin!(cycle);
let cycle = tokio::select! {
result = &mut cycle => result,
let mut pending_insertions = Vec::new();
let cycle = loop {
tokio::select! {
result = &mut cycle => break result,
command = client.commands.recv() => {
let message = match command {
Some(crate::client::ClientCommand::RuntimeMessage(message)) => message,
Some(crate::client::ClientCommand::RuntimeEvent(event)) => event.into_message(),
Some(crate::client::ClientCommand::Cancel) => {
Some(ClientCommand::InsertMessages(insertion)) => {
pending_insertions.push(insertion);
continue;
}
Some(ClientCommand::RuntimeMessage(message)) => message,
Some(ClientCommand::RuntimeEvent(event)) => event.into_message(),
Some(ClientCommand::Cancel) => {
cycle_cancellation.cancel();
return (RunOutcome::Cancelled, usage);
}
Some(crate::client::ClientCommand::ClientClosed { error }) => {
Some(ClientCommand::ClientClosed { error }) => {
cycle_cancellation.cancel();
return (RunOutcome::Failed(RunFailure::Client(error)), usage);
}
Some(crate::client::ClientCommand::ToolResult(_)) => {
Some(ClientCommand::ToolResult(_)) => {
cycle_cancellation.cancel();
return (
RunOutcome::Failed(RunFailure::Protocol(
@@ -284,6 +294,19 @@ impl RunEngine {
}
}
}
revision = match append_insertions(
&self.store,
prepared,
client,
cancellation,
revision,
std::mem::take(&mut pending_insertions),
)
.await
{
Ok((revision, _)) => revision,
Err(outcome) => return (outcome, usage),
};
revision = match append_runtime_message(
&self.store,
prepared,
@@ -294,11 +317,12 @@ impl RunEngine {
)
.await
{
Ok(revision) => revision,
Ok((revision, _)) => revision,
Err(outcome) => return (outcome, usage),
};
continue 'model;
}
}
};
let cycle = match cycle {
Ok(cycle) => cycle,
@@ -413,6 +437,27 @@ impl RunEngine {
Ok(revision) => revision,
Err(error) => return (RunOutcome::Failed(error.into()), usage),
};
if !pending_insertions.is_empty() {
let inserted = match append_insertions(
&self.store,
prepared,
client,
cancellation,
revision,
pending_insertions,
)
.await
{
Ok((next, inserted)) => {
revision = next;
inserted
}
Err(outcome) => return (outcome, usage),
};
if inserted {
continue 'model;
}
}
let (barrier, ready) = CommitBarrier::before_continue();
if emit(
client,
@@ -453,6 +498,7 @@ impl RunEngine {
calls: cycle.calls,
recovered_started_at_ms: None,
},
pending_insertions,
)
.await
{
@@ -683,14 +729,36 @@ fn fallback_summary(messages: &[CanonicalMessage]) -> String {
)
}
async fn append_runtime_message(
pub(super) async fn append_insertions(
store: &Store,
prepared: &PreparedRun,
client: &mut ClientPort,
cancellation: &CancellationToken,
mut revision: crate::model::RevisionId,
insertions: Vec<MessageInsertion>,
) -> std::result::Result<(crate::model::RevisionId, bool), RunOutcome> {
let mut inserted_any = false;
for insertion in insertions {
for message in insertion.messages {
let (next, inserted) =
append_runtime_message(store, prepared, client, cancellation, revision, message)
.await?;
revision = next;
inserted_any |= inserted;
}
let _ = insertion.delivered.send(());
}
Ok((revision, inserted_any))
}
pub(super) async fn append_runtime_message(
store: &Store,
prepared: &PreparedRun,
client: &mut ClientPort,
cancellation: &CancellationToken,
revision: crate::model::RevisionId,
message: CanonicalMessage,
) -> std::result::Result<crate::model::RevisionId, RunOutcome> {
) -> std::result::Result<(crate::model::RevisionId, bool), RunOutcome> {
let event_id = message.runtime_event_id.clone().ok_or_else(|| {
RunOutcome::Failed(RunFailure::Protocol(
"runtime message has no event identity".into(),
@@ -706,7 +774,7 @@ async fn append_runtime_message(
.await
.map_err(|error| RunOutcome::Failed(error.into()))?;
if !inserted {
return Ok(revision);
return Ok((revision, false));
}
let (barrier, ready) = CommitBarrier::before_continue();
emit(
@@ -721,7 +789,7 @@ async fn append_runtime_message(
.await
.map_err(|_| client_failure())?;
wait_for_state_ready(ready, cancellation).await?;
Ok(revision)
Ok((revision, true))
}
async fn hydrate_tool_images(
+38 -1
View File
@@ -3,7 +3,10 @@ use std::{collections::HashMap, sync::Arc};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use crate::model::{ConversationId, RunId};
use crate::{
client::{ClientCommand, MessageInsertion},
model::{CanonicalMessage, ConversationId, RunId},
};
#[derive(Clone, Default)]
pub struct RunRegistry {
@@ -13,6 +16,7 @@ pub struct RunRegistry {
struct ActiveRun {
run_id: RunId,
cancellation: CancellationToken,
commands: tokio::sync::mpsc::Sender<ClientCommand>,
}
impl RunRegistry {
@@ -21,12 +25,14 @@ impl RunRegistry {
conversation_id: ConversationId,
run_id: RunId,
cancellation: CancellationToken,
commands: tokio::sync::mpsc::Sender<ClientCommand>,
) {
let previous = self.active.lock().await.insert(
conversation_id,
ActiveRun {
run_id: run_id.clone(),
cancellation,
commands,
},
);
if let Some(previous) = previous.filter(|previous| previous.run_id != run_id) {
@@ -34,6 +40,37 @@ impl RunRegistry {
}
}
pub async fn insert_messages(
&self,
conversation_id: &ConversationId,
messages: Vec<CanonicalMessage>,
) -> bool {
if messages.is_empty() {
return true;
}
let commands = self
.active
.lock()
.await
.get(conversation_id)
.map(|run| run.commands.clone());
let Some(commands) = commands else {
return false;
};
let (delivered, delivery) = tokio::sync::oneshot::channel();
if commands
.send(ClientCommand::InsertMessages(MessageInsertion {
messages,
delivered,
}))
.await
.is_err()
{
return false;
}
delivery.await.is_ok()
}
pub async fn release(&self, conversation_id: &ConversationId, run_id: &RunId) {
let mut active = self.active.lock().await;
if active
+45 -33
View File
@@ -1,7 +1,10 @@
use tokio_util::sync::CancellationToken;
use crate::{
client::{ClientCommand, ClientEvent, ClientPort, CommitBarrier, CommitCause, StateCommitted},
client::{
ClientCommand, ClientEvent, ClientPort, CommitBarrier, CommitCause, MessageInsertion,
StateCommitted,
},
model::{PreparedRun, RevisionId, ToolCall, ToolRoundAssistant, ToolRoundId},
store::Store,
};
@@ -22,6 +25,7 @@ pub(super) async fn execute(
cancellation: &CancellationToken,
mut revision: RevisionId,
round: ToolRound,
insertions: Vec<MessageInsertion>,
) -> std::result::Result<RevisionId, RunOutcome> {
let ToolRound {
id: round_id,
@@ -66,7 +70,10 @@ pub(super) async fn execute(
.await?;
let mut remaining = calls.len();
let mut pending_runtime_messages = Vec::new();
let mut pending_runtime_messages = insertions
.into_iter()
.map(PendingRuntimeMessage::Insertion)
.collect::<Vec<_>>();
while remaining > 0 {
let command = tokio::select! {
_ = cancellation.cancelled() => return Err(RunOutcome::Cancelled),
@@ -116,10 +123,13 @@ pub(super) async fn execute(
}
}
Some(ClientCommand::RuntimeEvent(event)) => {
pending_runtime_messages.push(event.into_message());
pending_runtime_messages.push(PendingRuntimeMessage::Message(event.into_message()));
}
Some(ClientCommand::RuntimeMessage(message)) => {
pending_runtime_messages.push(message);
pending_runtime_messages.push(PendingRuntimeMessage::Message(message));
}
Some(ClientCommand::InsertMessages(insertion)) => {
pending_runtime_messages.push(PendingRuntimeMessage::Insertion(insertion))
}
Some(ClientCommand::Cancel) => return Err(RunOutcome::Cancelled),
Some(ClientCommand::ClientClosed { error }) => {
@@ -128,40 +138,42 @@ pub(super) async fn execute(
None => return Err(client_failure()),
}
}
for message in pending_runtime_messages {
let event_id = message.runtime_event_id.clone().ok_or_else(|| {
RunOutcome::Failed(RunFailure::Protocol(
"runtime message has no event identity".into(),
))
})?;
let (next, inserted) = store
.append_message_once(
&prepared.conversation_id,
&prepared.run_id,
revision,
&message,
)
.await
.map_err(failed)?;
revision = next;
if inserted {
let (barrier, ready) = CommitBarrier::before_continue();
send(
client,
ClientEvent::StateCommitted(StateCommitted {
revision_id: revision,
tool_round_version: 0,
cause: CommitCause::RuntimeEvent { event_id },
barrier,
}),
)
.await?;
super::engine::wait_for_state_ready(ready, cancellation).await?;
for pending in pending_runtime_messages {
match pending {
PendingRuntimeMessage::Message(message) => {
revision = super::engine::append_runtime_message(
store,
prepared,
client,
cancellation,
revision,
message,
)
.await?
.0;
}
PendingRuntimeMessage::Insertion(insertion) => {
revision = super::engine::append_insertions(
store,
prepared,
client,
cancellation,
revision,
vec![insertion],
)
.await?
.0;
}
}
}
Ok(revision)
}
enum PendingRuntimeMessage {
Message(crate::model::CanonicalMessage),
Insertion(MessageInsertion),
}
async fn send(client: &ClientPort, event: ClientEvent) -> std::result::Result<(), RunOutcome> {
client
.events
+16 -5
View File
@@ -1,6 +1,6 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use sha2::{Digest, Sha256};
use sqlx::Row;
use sqlx::{Row, Sqlite, Transaction};
use crate::{Error, Result};
@@ -44,13 +44,25 @@ pub struct BlobEdge {
impl Store {
pub async fn put_blob(&self, data: &[u8], edges: &[BlobEdge]) -> Result<BlobId> {
let _write = self.writes.lock().await;
let blob_id = BlobId::digest(data);
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
Self::put_blob_tx(&mut tx, &blob_id, data, edges).await?;
tx.commit().await?;
Ok(blob_id)
}
pub(crate) async fn put_blob_tx(
tx: &mut Transaction<'_, Sqlite>,
blob_id: &BlobId,
data: &[u8],
edges: &[BlobEdge],
) -> Result<()> {
sqlx::query("INSERT OR IGNORE INTO blobs(blob_id, data, created_at_ms) VALUES (?, ?, ?)")
.bind(blob_id.as_bytes().as_slice())
.bind(data)
.bind(now_ms())
.execute(&mut *tx)
.execute(&mut **tx)
.await?;
for edge in edges {
sqlx::query(
@@ -59,11 +71,10 @@ impl Store {
.bind(blob_id.as_bytes().as_slice())
.bind(edge.child.as_bytes().as_slice())
.bind(&edge.field_name)
.execute(&mut *tx)
.execute(&mut **tx)
.await?;
}
tx.commit().await?;
Ok(blob_id)
Ok(())
}
pub async fn get_blob(&self, blob_id: &BlobId) -> Result<Option<Vec<u8>>> {
+156 -17
View File
@@ -1,4 +1,4 @@
use sqlx::Row;
use sqlx::{Row, Sqlite, Transaction};
use crate::{
model::{CursorRunTraceArtifact, CursorRunTraceSummary},
@@ -7,6 +7,21 @@ use crate::{
use super::{now_ms, BlobId, Store};
#[derive(Clone, Debug)]
pub(crate) struct BufferedCursorTraceChunk {
pub(crate) source: String,
pub(crate) data: Vec<u8>,
}
impl BufferedCursorTraceChunk {
pub(crate) fn new(source: &str, data: &[u8]) -> Self {
Self {
source: source.into(),
data: data.to_vec(),
}
}
}
impl Store {
pub async fn start_cursor_trace_if_detailed(
&self,
@@ -21,6 +36,7 @@ impl Store {
if !self.detailed_logging().await? {
return Ok(false);
}
let _write = self.writes.lock().await;
sqlx::query(
"INSERT OR IGNORE INTO cursor_run_traces(
request_id, conversation_id, route, model_id, status, received_at_ms
@@ -53,9 +69,22 @@ impl Store {
data: &[u8],
metadata: &serde_json::Value,
) -> Result<()> {
let blob_id = self.put_blob(data, &[]).await?;
self.link_cursor_trace_artifact(request_id, artifact_type, source, &blob_id, metadata)
.await
let metadata_json = serde_json::to_string(metadata)?;
let blob_id = BlobId::digest(data);
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
Self::put_blob_tx(&mut tx, &blob_id, data, &[]).await?;
Self::link_cursor_trace_artifact_tx(
&mut tx,
request_id,
artifact_type,
source,
&blob_id,
&metadata_json,
)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn link_cursor_trace_artifact(
@@ -66,13 +95,36 @@ impl Store {
blob_id: &BlobId,
metadata: &serde_json::Value,
) -> Result<()> {
let metadata_json = serde_json::to_string(metadata)?;
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
Self::link_cursor_trace_artifact_tx(
&mut tx,
request_id,
artifact_type,
source,
blob_id,
&metadata_json,
)
.await?;
tx.commit().await?;
Ok(())
}
async fn link_cursor_trace_artifact_tx(
tx: &mut Transaction<'_, Sqlite>,
request_id: &str,
artifact_type: &str,
source: &str,
blob_id: &BlobId,
metadata_json: &str,
) -> Result<()> {
let next: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(seq), -1) + 1
FROM cursor_run_trace_artifacts WHERE request_id = ?",
)
.bind(request_id)
.fetch_one(&mut *tx)
.fetch_one(&mut **tx)
.await?;
sqlx::query(
"INSERT INTO cursor_run_trace_artifacts(
@@ -84,11 +136,10 @@ impl Store {
.bind(artifact_type)
.bind(source)
.bind(blob_id.as_bytes().as_slice())
.bind(serde_json::to_string(metadata)?)
.bind(metadata_json)
.bind(now_ms())
.execute(&mut *tx)
.execute(&mut **tx)
.await?;
tx.commit().await?;
Ok(())
}
@@ -97,6 +148,7 @@ impl Store {
request_id: &str,
bytes: usize,
) -> Result<()> {
let _write = self.writes.lock().await;
sqlx::query(
"UPDATE cursor_run_traces
SET request_bytes = request_bytes + ? WHERE request_id = ?",
@@ -110,6 +162,7 @@ impl Store {
pub async fn start_cursor_trace_response(&self, request_id: &str, status: u16) -> Result<()> {
let now = now_ms();
let _write = self.writes.lock().await;
sqlx::query(
"UPDATE cursor_run_traces
SET status = 'running', http_status = ?,
@@ -131,30 +184,58 @@ impl Store {
source: &str,
data: &[u8],
) -> Result<()> {
self.append_cursor_trace_artifact(
self.add_cursor_trace_response_chunks(
request_id,
"run_sse_chunk",
source,
data,
&serde_json::json!({"byte_count": data.len()}),
&[BufferedCursorTraceChunk::new(source, data)],
)
.await?;
.await
}
pub(crate) async fn add_cursor_trace_response_chunks(
&self,
request_id: &str,
chunks: &[BufferedCursorTraceChunk],
) -> Result<()> {
if chunks.is_empty() {
return Ok(());
}
let response_bytes = chunks.iter().map(|chunk| chunk.data.len()).sum::<usize>();
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
for chunk in chunks {
let metadata_json =
serde_json::to_string(&serde_json::json!({"byte_count": chunk.data.len()}))?;
let blob_id = BlobId::digest(&chunk.data);
Self::put_blob_tx(&mut tx, &blob_id, &chunk.data, &[]).await?;
Self::link_cursor_trace_artifact_tx(
&mut tx,
request_id,
"run_sse_chunk",
&chunk.source,
&blob_id,
&metadata_json,
)
.await?;
}
sqlx::query(
"UPDATE cursor_run_traces
SET response_bytes = response_bytes + ?,
response_event_count = response_event_count + 1,
response_event_count = response_event_count + ?,
first_response_at_ms = COALESCE(first_response_at_ms, ?)
WHERE request_id = ?",
)
.bind(as_i64(data.len()))
.bind(as_i64(response_bytes))
.bind(chunks.len() as i64)
.bind(now_ms())
.bind(request_id)
.execute(&self.pool)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn finish_cursor_trace(&self, request_id: &str, error: Option<&str>) -> Result<()> {
let _write = self.writes.lock().await;
sqlx::query(
"UPDATE cursor_run_traces
SET status = ?, finished_at_ms = ?, error_message = ?
@@ -244,3 +325,61 @@ fn trace_from_row(row: sqlx::sqlite::SqliteRow) -> Result<CursorRunTraceSummary>
fn as_i64(value: usize) -> i64 {
value.min(i64::MAX as usize) as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn records_a_batch_of_trace_chunks_with_one_summary_update() {
let store = Store::connect("sqlite::memory:").await.unwrap();
store.set_detailed_logging(true).await.unwrap();
store
.start_cursor_trace_if_detailed("trace", None, "cursor_official", None)
.await
.unwrap();
sqlx::query("CREATE TABLE trace_summary_updates(count INTEGER NOT NULL)")
.execute(store.pool())
.await
.unwrap();
sqlx::query("INSERT INTO trace_summary_updates(count) VALUES (0)")
.execute(store.pool())
.await
.unwrap();
sqlx::query(
"CREATE TRIGGER count_trace_summary_updates
AFTER UPDATE OF response_bytes ON cursor_run_traces
BEGIN
UPDATE trace_summary_updates SET count = count + 1;
END",
)
.execute(store.pool())
.await
.unwrap();
store
.add_cursor_trace_response_chunks(
"trace",
&[
BufferedCursorTraceChunk::new("cursor_official", b"one"),
BufferedCursorTraceChunk::new("cursor_official", b"two"),
BufferedCursorTraceChunk::new("cursor_official", b"three"),
],
)
.await
.unwrap();
let trace = store.cursor_trace("trace").await.unwrap().unwrap();
assert_eq!(trace.response_bytes, 11);
assert_eq!(trace.response_event_count, 3);
assert_eq!(
store.cursor_trace_artifacts("trace").await.unwrap().len(),
3
);
let updates: i64 = sqlx::query_scalar("SELECT count FROM trace_summary_updates")
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(updates, 1);
}
}
+1
View File
@@ -12,6 +12,7 @@ impl Store {
input_id: &str,
base_revision_id: RevisionId,
) -> Result<RevisionId> {
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
sqlx::query(
"INSERT INTO input_anchors
+208 -17
View File
@@ -12,6 +12,34 @@ use crate::{
use super::{now_ms, Store};
#[derive(Clone, Debug)]
pub(crate) struct BufferedLlmChunk {
pub(crate) seq: i64,
pub(crate) elapsed_ms: i64,
pub(crate) data: Option<Vec<u8>>,
pub(crate) byte_count: usize,
}
impl BufferedLlmChunk {
pub(crate) fn new(seq: i64, elapsed_ms: i64, data: &[u8]) -> Self {
Self {
seq,
elapsed_ms,
data: Some(data.to_vec()),
byte_count: data.len(),
}
}
pub(crate) fn metrics(seq: i64, elapsed_ms: i64, byte_count: usize) -> Self {
Self {
seq,
elapsed_ms,
data: None,
byte_count,
}
}
}
impl Store {
pub async fn detailed_logging(&self) -> Result<bool> {
let value: String = sqlx::query_scalar(
@@ -23,6 +51,7 @@ impl Store {
}
pub async fn set_detailed_logging(&self, enabled: bool) -> Result<()> {
let _write = self.writes.lock().await;
sqlx::query(
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES ('llm_detailed_logging', ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
)
@@ -34,6 +63,7 @@ impl Store {
}
pub async fn start_llm_call(&self, call: &NewLlmCall) -> Result<()> {
let _write = self.writes.lock().await;
let now = now_ms();
sqlx::query(
r#"INSERT INTO llm_calls(
@@ -74,21 +104,27 @@ impl Store {
detailed: bool,
) -> Result<()> {
let body_json = serde_json::to_string(body)?;
let headers_json = detailed
.then(|| serde_json::to_string(headers))
.transpose()?;
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?;
if detailed {
sqlx::query("INSERT INTO llm_call_requests(call_id, headers_json, body_json, byte_count) SELECT ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM llm_calls WHERE call_id = ?)")
.bind(call_id)
.bind(serde_json::to_string(headers)?)
.bind(headers_json)
.bind(&body_json)
.bind(body_json.len() as i64)
.bind(call_id)
.execute(&self.pool)
.execute(&mut *transaction)
.await?;
}
sqlx::query("UPDATE llm_calls SET request_bytes = ? WHERE call_id = ?")
.bind(body_json.len() as i64)
.bind(call_id)
.execute(&self.pool)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
}
@@ -98,6 +134,7 @@ impl Store {
elapsed_ms: i64,
http_status: u16,
) -> Result<()> {
let _write = self.writes.lock().await;
sqlx::query("UPDATE llm_calls SET response_headers_at_ms = ?, ttfb_ms = ?, http_status = ? WHERE call_id = ?")
.bind(now_ms())
.bind(elapsed_ms)
@@ -116,21 +153,50 @@ impl Store {
data: &[u8],
detailed: bool,
) -> Result<()> {
let mut transaction = self.pool.begin().await?;
if detailed {
sqlx::query("INSERT INTO llm_call_response_chunks(call_id, seq, received_offset_ms, data, byte_count) SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM llm_calls WHERE call_id = ?)")
.bind(call_id)
.bind(seq)
.bind(elapsed_ms)
.bind(data)
.bind(data.len() as i64)
.bind(call_id)
.execute(&mut *transaction)
.await?;
let chunk = if detailed {
BufferedLlmChunk::new(seq, elapsed_ms, data)
} else {
BufferedLlmChunk::metrics(seq, elapsed_ms, data.len())
};
self.record_llm_chunks(call_id, &[chunk], detailed).await
}
pub(crate) async fn record_llm_chunks(
&self,
call_id: &str,
chunks: &[BufferedLlmChunk],
detailed: bool,
) -> Result<()> {
if chunks.is_empty() {
return Ok(());
}
sqlx::query("UPDATE llm_calls SET first_event_at_ms = COALESCE(first_event_at_ms, ?), response_bytes = response_bytes + ?, stream_event_count = stream_event_count + 1 WHERE call_id = ?")
let byte_count = chunks
.iter()
.map(|chunk| chunk.byte_count as i64)
.sum::<i64>();
let event_count = chunks.len() as i64;
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?;
if detailed {
for chunk in chunks {
let data = chunk.data.as_deref().ok_or_else(|| {
crate::Error::Store("detailed LLM chunk is missing payload data".into())
})?;
sqlx::query("INSERT INTO llm_call_response_chunks(call_id, seq, received_offset_ms, data, byte_count) SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM llm_calls WHERE call_id = ?)")
.bind(call_id)
.bind(chunk.seq)
.bind(chunk.elapsed_ms)
.bind(data)
.bind(chunk.byte_count as i64)
.bind(call_id)
.execute(&mut *transaction)
.await?;
}
}
sqlx::query("UPDATE llm_calls SET first_event_at_ms = COALESCE(first_event_at_ms, ?), response_bytes = response_bytes + ?, stream_event_count = stream_event_count + ? WHERE call_id = ?")
.bind(now_ms())
.bind(data.len() as i64)
.bind(byte_count)
.bind(event_count)
.bind(call_id)
.execute(&mut *transaction)
.await?;
@@ -139,6 +205,7 @@ impl Store {
}
pub async fn record_llm_first_text(&self, call_id: &str, elapsed_ms: i64) -> Result<()> {
let _write = self.writes.lock().await;
sqlx::query("UPDATE llm_calls SET first_text_at_ms = COALESCE(first_text_at_ms, ?), ttft_ms = COALESCE(ttft_ms, ?) WHERE call_id = ?")
.bind(now_ms())
.bind(elapsed_ms)
@@ -149,6 +216,8 @@ impl Store {
}
pub async fn record_llm_usage(&self, call_id: &str, usage: Usage) -> Result<()> {
let usage_json = serde_json::to_string(&usage)?;
let _write = self.writes.lock().await;
sqlx::query("UPDATE llm_calls SET input_tokens = ?, output_tokens = ?, total_tokens = ?, cache_read_tokens = ?, cache_write_tokens = ?, reasoning_tokens = ?, usage_json = ? WHERE call_id = ?")
.bind(as_i64(usage.input_tokens))
.bind(as_i64(usage.output_tokens))
@@ -156,7 +225,7 @@ impl Store {
.bind(as_i64(usage.cache_read_tokens))
.bind(as_i64(usage.cache_write_tokens))
.bind(as_i64(usage.reasoning_tokens))
.bind(serde_json::to_string(&usage)?)
.bind(usage_json)
.bind(call_id)
.execute(&self.pool)
.await?;
@@ -172,6 +241,7 @@ impl Store {
error_kind: Option<&str>,
error_message: Option<&str>,
) -> Result<()> {
let _write = self.writes.lock().await;
sqlx::query("UPDATE llm_calls SET status = ?, finish_reason = ?, finished_at_ms = ?, duration_ms = ?, error_kind = ?, error_message = ? WHERE call_id = ? AND status = 'running'")
.bind(status)
.bind(finish_reason)
@@ -327,8 +397,129 @@ fn summary_from_row(row: sqlx::sqlite::SqliteRow) -> Result<LlmCallSummary> {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::model::{ModelConfigInput, ModelType};
use tokio::sync::Barrier;
#[tokio::test]
async fn concurrent_writes_are_serialized_without_sqlite_busy_retries() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("concurrent-writes.db").display()
))
.await
.unwrap();
sqlx::query(
"INSERT INTO llm_calls(
call_id, run_id, conversation_id, provider_call_index, provider_type,
provider_url, request_type, request_url, model_id, display_name, status,
created_at_ms, message_count, tool_count, detailed
) VALUES (
'concurrent-call', 'run', 'conversation', 0, 'openai-chat',
'https://example.com', 'openai-chat', 'https://example.com',
'model', 'Model', 'running', 1, 0, 0, 0
)",
)
.execute(store.pool())
.await
.unwrap();
let mut connections = Vec::new();
for _ in 0..8 {
connections.push(store.pool().acquire().await.unwrap());
}
for connection in &mut connections {
sqlx::query("PRAGMA busy_timeout = 0")
.execute(&mut **connection)
.await
.unwrap();
}
drop(connections);
let writers = 32;
let barrier = Arc::new(Barrier::new(writers));
let mut tasks = Vec::with_capacity(writers);
for seq in 0..writers {
let store = store.clone();
let barrier = barrier.clone();
tasks.push(tokio::spawn(async move {
barrier.wait().await;
store
.record_llm_chunk("concurrent-call", seq as i64, 1, b"x", false)
.await
}));
}
for task in tasks {
task.await.unwrap().unwrap();
}
let call = store.llm_call("concurrent-call").await.unwrap().unwrap();
assert_eq!(call.response_bytes, writers as i64);
assert_eq!(call.stream_event_count, writers as i64);
}
#[tokio::test]
async fn records_a_batch_of_response_chunks_with_one_summary_update() {
let store = Store::connect("sqlite::memory:").await.unwrap();
sqlx::query(
"INSERT INTO llm_calls(
call_id, run_id, conversation_id, provider_call_index, provider_type,
provider_url, request_type, request_url, model_id, display_name, status,
created_at_ms, message_count, tool_count, detailed
) VALUES (
'batch-call', 'run', 'conversation', 0, 'openai-chat',
'https://example.com', 'openai-chat', 'https://example.com',
'model', 'Model', 'running', 1, 0, 0, 1
)",
)
.execute(store.pool())
.await
.unwrap();
sqlx::query("CREATE TABLE llm_call_summary_updates(count INTEGER NOT NULL)")
.execute(store.pool())
.await
.unwrap();
sqlx::query("INSERT INTO llm_call_summary_updates(count) VALUES (0)")
.execute(store.pool())
.await
.unwrap();
sqlx::query(
"CREATE TRIGGER count_llm_call_summary_updates
AFTER UPDATE OF response_bytes ON llm_calls
BEGIN
UPDATE llm_call_summary_updates SET count = count + 1;
END",
)
.execute(store.pool())
.await
.unwrap();
store
.record_llm_chunks(
"batch-call",
&[
BufferedLlmChunk::new(0, 1, b"one"),
BufferedLlmChunk::new(1, 2, b"two"),
BufferedLlmChunk::new(2, 3, b"three"),
],
true,
)
.await
.unwrap();
let call = store.llm_call("batch-call").await.unwrap().unwrap();
assert_eq!(call.response_bytes, 11);
assert_eq!(call.stream_event_count, 3);
assert_eq!(store.llm_call_chunks("batch-call").await.unwrap().len(), 3);
let updates: i64 = sqlx::query_scalar("SELECT count FROM llm_call_summary_updates")
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(updates, 1);
}
#[tokio::test]
async fn latest_usage_anchor_uses_the_latest_completed_call_for_the_same_conversation_and_model(
+3
View File
@@ -13,8 +13,11 @@ mod settings;
mod sqlite;
mod storage;
mod tool_rounds;
mod writer;
pub use cas::*;
pub(crate) use cursor_traces::BufferedCursorTraceChunk;
pub(crate) use llm_calls::BufferedLlmChunk;
pub use runs::*;
pub use settings::*;
pub(crate) use sqlite::now_ms;
+5
View File
@@ -60,6 +60,7 @@ impl Store {
normalized.push((hash, input));
}
let now = now_ms();
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
for (hash, input) in &normalized {
insert_model(&mut transaction, hash, input, now).await?;
@@ -87,6 +88,7 @@ impl Store {
}
}
let now = now_ms();
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
let mut inserted = 0;
for (hash, input) in &normalized {
@@ -110,6 +112,7 @@ impl Store {
let input = normalize_model_input(input)?;
let next_hash = model_hash(&input)?;
let now = now_ms();
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
if next_hash != current.model_hash {
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
@@ -165,6 +168,7 @@ impl Store {
}
pub async fn delete_model(&self, hash: &str) -> Result<()> {
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
.bind(hash)
@@ -201,6 +205,7 @@ impl Store {
}
let now = now_ms();
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
for (index, hash) in model_hashes.iter().enumerate() {
sqlx::query(
+70 -5
View File
@@ -13,6 +13,7 @@ impl Store {
&self,
conversation_id: &ConversationId,
) -> Result<RevisionId> {
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let revision = Self::ensure_conversation_tx(&mut tx, conversation_id).await?;
tx.commit().await?;
@@ -57,14 +58,47 @@ impl Store {
self.load_revision_messages(RevisionId(revision_id)).await
}
pub async fn match_revision_prefix(
&self,
conversation_id: &ConversationId,
base_revision_id: RevisionId,
additions: &[CanonicalMessage],
) -> Result<(RevisionId, usize)> {
let mut revision = base_revision_id;
let mut messages = self.load_revision_messages(revision).await?;
for (index, addition) in additions.iter().enumerate() {
messages.push(addition.clone());
let digest = message_digest(&messages)?;
let child = sqlx::query_scalar::<_, i64>(
"SELECT revision_id FROM conversation_revisions
WHERE conversation_id = ? AND parent_revision_id = ? AND state_digest = ?",
)
.bind(conversation_id.as_str())
.bind(revision.0)
.bind(digest.as_slice())
.fetch_optional(&self.pool)
.await?
.map(RevisionId);
let Some(child) = child else {
return Ok((revision, index));
};
if self.load_revision_messages(child).await? != messages {
return Ok((revision, index));
}
revision = child;
}
Ok((revision, additions.len()))
}
pub async fn import_revision(
&self,
conversation_id: &ConversationId,
messages: &[CanonicalMessage],
) -> Result<RevisionId> {
let digest = message_digest(messages)?;
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let current = Self::ensure_conversation_tx(&mut tx, conversation_id).await?;
let digest = message_digest(messages)?;
if let Some(existing) = sqlx::query_scalar::<_, i64>(
"SELECT revision_id FROM conversation_revisions
WHERE conversation_id = ? AND state_digest = ?",
@@ -107,9 +141,20 @@ impl Store {
if additions.is_empty() {
return Ok(expected);
}
let mut full = self.load_revision_messages(expected).await?;
full.extend_from_slice(additions);
let digest = message_digest(&full)?;
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let revision =
Self::append_revision_tx(&mut tx, conversation_id, run_id, expected, additions).await?;
let revision = Self::append_revision_with_digest_tx(
&mut tx,
conversation_id,
run_id,
expected,
additions,
digest,
)
.await?;
tx.commit().await?;
Ok(revision)
}
@@ -121,6 +166,8 @@ impl Store {
expected: RevisionId,
messages: &[CanonicalMessage],
) -> Result<RevisionId> {
let digest = message_digest(messages)?;
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
Self::require_active_head_tx(&mut tx, conversation_id, run_id, expected).await?;
let root: i64 = sqlx::query_scalar(
@@ -130,7 +177,6 @@ impl Store {
.bind(conversation_id.as_str())
.fetch_one(&mut *tx)
.await?;
let digest = message_digest(messages)?;
let revision =
Self::insert_revision_tx(&mut tx, conversation_id, RevisionId(root), messages, digest)
.await?;
@@ -202,10 +248,29 @@ impl Store {
expected: RevisionId,
additions: &[CanonicalMessage],
) -> Result<RevisionId> {
Self::require_active_head_tx(tx, conversation_id, run_id, expected).await?;
let mut full = Self::load_revision_messages_tx(tx, expected.0).await?;
full.extend_from_slice(additions);
let digest = message_digest(&full)?;
Self::append_revision_with_digest_tx(
tx,
conversation_id,
run_id,
expected,
additions,
digest,
)
.await
}
async fn append_revision_with_digest_tx(
tx: &mut Transaction<'_, Sqlite>,
conversation_id: &ConversationId,
run_id: &RunId,
expected: RevisionId,
additions: &[CanonicalMessage],
digest: [u8; 32],
) -> Result<RevisionId> {
Self::require_active_head_tx(tx, conversation_id, run_id, expected).await?;
if sqlx::query_scalar::<_, i64>(
"SELECT revision_id FROM conversation_revisions
WHERE conversation_id = ? AND state_digest = ?",
+5 -1
View File
@@ -36,6 +36,7 @@ pub struct ClaimedRun {
impl Store {
pub async fn claim_run(&self, prepared: &PreparedRun) -> Result<ClaimedRun> {
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let now = now_ms();
Self::ensure_conversation_tx(&mut tx, &prepared.conversation_id).await?;
@@ -146,6 +147,7 @@ impl Store {
}
pub async fn begin_provider_call(&self, run_id: &RunId) -> Result<u64> {
let _write = self.writes.lock().await;
let index: Option<i64> = sqlx::query_scalar(
"UPDATE runs SET provider_call_index = provider_call_index + 1, updated_at_ms = ?
WHERE run_id = ? AND status = 'running'
@@ -167,6 +169,8 @@ impl Store {
usage: Option<Usage>,
failure: Option<(&str, &str)>,
) -> Result<bool> {
let usage_json = serde_json::to_string(&usage)?;
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let row = sqlx::query(
"SELECT conversation_id, status, failure_category, failure_summary
@@ -200,7 +204,7 @@ impl Store {
WHERE run_id = ? AND status = 'running'",
)
.bind(status.as_str())
.bind(serde_json::to_string(&usage)?)
.bind(usage_json)
.bind(category)
.bind(summary)
.bind(now)
+13 -4
View File
@@ -93,6 +93,7 @@ pub(crate) struct ProxySettingsSecret {
impl Store {
pub(crate) async fn installation_id(&self) -> Result<String> {
let generated = uuid::Uuid::new_v4().to_string();
let _write = self.writes.lock().await;
sqlx::query(
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO NOTHING",
)
@@ -165,9 +166,11 @@ impl Store {
username: input.username.trim().to_owned(),
password,
};
let value_json = serde_json::to_string(&settings)?;
let _write = self.writes.lock().await;
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(PROXY_SETTINGS_KEY)
.bind(serde_json::to_string(&settings)?)
.bind(value_json)
.bind(now_ms())
.execute(&self.pool)
.await?;
@@ -206,9 +209,11 @@ impl Store {
));
}
}
let value_json = serde_json::to_string(&settings)?;
let _write = self.writes.lock().await;
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(value_json)
.bind(now_ms())
.execute(&self.pool)
.await?;
@@ -228,11 +233,13 @@ impl Store {
}
pub async fn set_port_settings(&self, settings: PortSettings) -> Result<()> {
let value_json = serde_json::to_string(&settings)?;
let _write = self.writes.lock().await;
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(PORT_SETTINGS_KEY)
.bind(serde_json::to_string(&settings)?)
.bind(value_json)
.bind(now_ms())
.execute(&self.pool)
.await?;
@@ -264,11 +271,13 @@ impl Store {
}
pub async fn set_desktop_settings(&self, settings: DesktopSettings) -> Result<()> {
let value_json = serde_json::to_string(&settings)?;
let _write = self.writes.lock().await;
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(DESKTOP_SETTINGS_KEY)
.bind(serde_json::to_string(&settings)?)
.bind(value_json)
.bind(now_ms())
.execute(&self.pool)
.await?;
+7 -1
View File
@@ -7,9 +7,12 @@ use sqlx::{
use crate::Result;
use super::writer::WriteCoordinator;
#[derive(Clone)]
pub struct Store {
pub(crate) pool: SqlitePool,
pub(crate) writes: WriteCoordinator,
}
impl Store {
@@ -25,7 +28,10 @@ impl Store {
.connect_with(options)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(Self { pool })
Ok(Self {
pool,
writes: WriteCoordinator::default(),
})
}
pub fn pool(&self) -> &SqlitePool {
+1
View File
@@ -54,6 +54,7 @@ impl Store {
}
pub async fn clear_statistics_storage(&self) -> Result<StatisticsStorage> {
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
sqlx::query("DELETE FROM llm_calls")
.execute(&mut *transaction)
+4 -1
View File
@@ -50,6 +50,8 @@ impl Store {
if calls.is_empty() {
return Err(Error::Store("cannot persist an empty tool round".into()));
}
let assistant_json = serde_json::to_string(assistant)?;
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let ownership: bool = sqlx::query_scalar(
"SELECT EXISTS(
@@ -82,7 +84,7 @@ impl Store {
.bind(round_id.as_str())
.bind(run_id.as_str())
.bind(base_revision_id.0)
.bind(serde_json::to_string(assistant)?)
.bind(assistant_json)
.bind(created_at_ms)
.bind(now)
.execute(&mut *tx)
@@ -113,6 +115,7 @@ impl Store {
round_id: &ToolRoundId,
result: &ToolResult,
) -> Result<ToolCommit> {
let _write = self.writes.lock().await;
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let round = sqlx::query(
"SELECT assistant_json, status, version, next_completion_seq
+14
View File
@@ -0,0 +1,14 @@
use std::sync::Arc;
use tokio::sync::{Mutex, MutexGuard};
#[derive(Clone, Default)]
pub(crate) struct WriteCoordinator {
lock: Arc<Mutex<()>>,
}
impl WriteCoordinator {
pub(crate) async fn lock(&self) -> MutexGuard<'_, ()> {
self.lock.lock().await
}
}
+229 -4
View File
@@ -76,7 +76,7 @@ async fn background_subagent_completion_starts_a_simulated_parent_turn() {
.unwrap();
assert!(messages.iter().any(|message| {
message.runtime_event_id.as_deref()
== Some("background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id")
== Some("background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id:task-call")
&& matches!(&message.content, MessageContent::Parts { parts } if !parts.is_empty())
}));
@@ -131,12 +131,137 @@ async fn background_subagent_completion_starts_a_simulated_parent_turn() {
assert_eq!(
runtime_ids,
[
"runtime:background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id",
"runtime:background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id-2"
"runtime:background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id:task-call",
"runtime:background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id-2:task-call"
]
);
}
#[tokio::test]
async fn background_completion_joins_the_active_run_instead_of_replacing_it() {
let (_directory, store) = fixtures::temp_store().await;
let provider = fake_provider::FakeProvider::default();
let first_ready = provider.push_gated(stop_response("model-call-1", "first response"));
provider.push(stop_response("model-call-2", "processed both completions"));
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("prompt/cursor")
.as_path(),
)
.unwrap();
let registry = CursorSessionRegistry::new(
store.clone(),
Arc::new(provider.clone()),
PromptCompiler::new(assets),
Default::default(),
);
let first = registry.get_or_create("active-completion-1").await.unwrap();
let first_run = tokio::spawn(async move {
drive_completion(
&first,
completion_run(
"child-1",
"parent-run-1",
pb::ConversationStateStructure::default(),
),
)
.await
});
while provider.requests().is_empty() {
tokio::task::yield_now().await;
}
let second = registry.get_or_create("active-completion-2").await.unwrap();
let second_run = tokio::spawn(async move {
drive_forwarded_completion(
&second,
completion_run(
"child-2",
"parent-run-2",
pb::ConversationStateStructure::default(),
),
)
.await
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
first_ready.notify_one();
second_run.await.unwrap();
first_run.await.unwrap();
let requests = provider.requests();
assert_eq!(requests.len(), 2);
let history = serde_json::to_string(&requests[1].history).unwrap();
assert!(history.contains("child-1"));
assert!(history.contains("first response"));
assert!(history.contains("child-2"));
let statuses: Vec<String> = sqlx::query_scalar(
"SELECT status FROM runs WHERE conversation_id = 'parent-conversation' ORDER BY created_at_ms",
)
.fetch_all(store.pool())
.await
.unwrap();
assert_eq!(statuses, ["completed"]);
}
#[tokio::test]
async fn retrying_one_background_completion_reuses_its_runtime_message() {
let (_directory, store) = fixtures::temp_store().await;
let provider = fake_provider::FakeProvider::default();
provider.push(stop_response("model-call", "followed up"));
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("prompt/cursor")
.as_path(),
)
.unwrap();
let registry = CursorSessionRegistry::new(
store.clone(),
Arc::new(provider.clone()),
PromptCompiler::new(assets),
Default::default(),
);
let first = registry.get_or_create("completion-retry-1").await.unwrap();
let (checkpoint, _) = drive_completion(
&first,
completion_run(
"retry-child",
"completion-retry-run-1",
pb::ConversationStateStructure {
mode: Some(pb::AgentMode::Multitask as i32),
..Default::default()
},
),
)
.await;
provider.push(stop_response("model-call-2", "followed up again"));
let second = registry.get_or_create("completion-retry-2").await.unwrap();
drive_completion(
&second,
completion_run("retry-child", "completion-retry-run-2", checkpoint),
)
.await;
let messages = store
.load_current_messages(&cursor_server::model::ConversationId::new(
"parent-conversation",
))
.await
.unwrap();
assert_eq!(
messages
.iter()
.filter(|message| {
message.runtime_event_id.as_deref()
== Some(
"background-completed:BACKGROUND_TASK_KIND_SUBAGENT:retry-child:task-call",
)
})
.count(),
1
);
}
#[tokio::test]
async fn background_shell_completion_wakes_the_parent_with_the_captured_notification() {
let (_directory, store) = fixtures::temp_store().await;
@@ -335,10 +460,110 @@ async fn drive_completion(
)
}
async fn drive_forwarded_completion(handle: &CursorSessionHandle, message: pb::AgentClientMessage) {
let mut output = handle.subscribe();
handle
.command(CursorCommand::Append {
seqno: 0,
message: Box::new(message),
})
.await
.unwrap();
let mut append_seqno = 1;
loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.unwrap();
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
if flags & connect::END_STREAM_FLAG != 0 {
assert_eq!(payload.as_ref(), b"{}");
return;
}
let server = pb::AgentServerMessage::decode(payload).unwrap();
match server.message {
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => {
assert_eq!(exec.id, 0);
handle
.command(CursorCommand::Append {
seqno: append_seqno,
message: Box::new(pb::AgentClientMessage {
message: Some(
pb::agent_client_message::Message::ExecClientControlMessage(
pb::ExecClientControlMessage {
message: Some(
pb::exec_client_control_message::Message::StreamClose(
pb::ExecClientStreamClose { id: 0 },
),
),
},
),
),
}),
})
.await
.unwrap();
append_seqno += 1;
handle
.command(CursorCommand::Append {
seqno: append_seqno,
message: Box::new(pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::ExecClientMessage(
pb::ExecClientMessage {
id: 0,
message: Some(
pb::exec_client_message::Message::RequestContextResult(
pb::RequestContextResult {
result: Some(
pb::request_context_result::Result::Success(
pb::RequestContextSuccess {
request_context: Some(
pb::RequestContext::default(),
),
..Default::default()
},
),
),
},
),
),
..Default::default()
},
)),
}),
})
.await
.unwrap();
append_seqno += 1;
}
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
handle
.command(CursorCommand::Append {
seqno: append_seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
append_seqno += 1;
}
_ => {}
}
}
}
fn completion_run(
child_id: &str,
run_id: &str,
conversation_state: pb::ConversationStateStructure,
) -> pb::AgentClientMessage {
completion_run_with_detail(child_id, run_id, conversation_state, "child result")
}
fn completion_run_with_detail(
child_id: &str,
run_id: &str,
conversation_state: pb::ConversationStateStructure,
detail: &str,
) -> pb::AgentClientMessage {
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::RunRequest(
@@ -352,7 +577,7 @@ fn completion_run(
kind: pb::BackgroundTaskKind::Subagent as i32,
status: pb::BackgroundTaskStatus::Success as i32,
title: "Inspect protocol".into(),
detail: Some("child result".into()),
detail: Some(detail.into()),
output_path: Some("/tmp/child.jsonl".into()),
reason: pb::BackgroundTaskCompletionReason::TaskFinished as i32,
subagent_id: Some(child_id.into()),
+81
View File
@@ -14,6 +14,7 @@ use cursor_server::{
provider::{FinishReason, ModelEvent},
run::{RunEngine, RunOutcome},
};
use tokio::{sync::oneshot, time::Duration};
use tokio_util::sync::CancellationToken;
#[tokio::test]
@@ -54,6 +55,86 @@ async fn a_client_without_checkpoint_protocol_runs_the_same_text_loop() {
assert_eq!(run.await.unwrap(), RunOutcome::Completed);
}
#[tokio::test]
async fn inserted_messages_wait_for_the_next_model_call_without_interrupting_the_active_call() {
let (_directory, store) = fixtures::temp_store().await;
let provider = fake_provider::FakeProvider::default();
let first_ready = provider.push_gated(vec![
ModelEvent::Start {
model_call_id: "call-1".into(),
},
ModelEvent::TextStart,
ModelEvent::TextDelta("first answer".into()),
ModelEvent::TextEnd,
ModelEvent::Done(FinishReason::Stop),
]);
provider.push(vec![
ModelEvent::Start {
model_call_id: "call-2".into(),
},
ModelEvent::TextStart,
ModelEvent::TextDelta("followed up".into()),
ModelEvent::TextEnd,
ModelEvent::Done(FinishReason::Stop),
]);
let prepared = prepared(&store).await;
let (port, mut client) = session(32);
let commands = client.commands.clone();
let engine = RunEngine::new(store, Arc::new(provider.clone()));
let run =
tokio::spawn(async move { engine.run(prepared, port, CancellationToken::new()).await });
while provider.requests().is_empty() {
if let Ok(Some(ClientEvent::StateCommitted(state))) =
tokio::time::timeout(Duration::from_millis(20), client.events.recv()).await
{
state.barrier.complete(Ok(()));
}
}
let (delivered, mut delivery) = oneshot::channel();
commands
.send(ClientCommand::InsertMessages(
cursor_server::client::MessageInsertion {
messages: vec![cursor_server::model::RuntimeEvent {
event_id: "background:finished".into(),
text: "background work finished".into(),
}
.into_message()],
delivered,
},
))
.await
.unwrap();
assert!(
tokio::time::timeout(Duration::from_millis(20), &mut delivery)
.await
.is_err()
);
first_ready.notify_one();
while let Some(event) = client.events.recv().await {
match event {
ClientEvent::StateCommitted(state) => state.barrier.complete(Ok(())),
ClientEvent::Ended(outcome) => {
assert_eq!(outcome, RunOutcome::Completed);
break;
}
_ => {}
}
}
assert_eq!(run.await.unwrap(), RunOutcome::Completed);
delivery.await.unwrap();
let requests = provider.requests();
assert_eq!(requests.len(), 2);
let history = &requests[1].history;
assert!(matches!(
history[1].role,
cursor_server::model::Role::Assistant
));
assert_eq!(history[2].message_id, "runtime:background:finished");
}
#[tokio::test]
async fn a_failed_claim_cannot_overwrite_the_existing_run() {
let (_directory, store) = fixtures::temp_store().await;
+24 -1
View File
@@ -160,7 +160,7 @@ async fn runtime_protocol_failure_returns_connect_error_end_stream_and_closes()
)
.unwrap();
let registry = CursorSessionRegistry::new(
store,
store.clone(),
Arc::new(provider),
PromptCompiler::new(assets),
Default::default(),
@@ -249,6 +249,29 @@ async fn runtime_protocol_failure_returns_connect_error_end_stream_and_closes()
.unwrap(),
None
);
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
let (status, failure_summary) = loop {
let row: (String, Option<String>) =
sqlx::query_as("SELECT status, failure_summary FROM runs WHERE cursor_request_id = ?")
.bind("protocol-failed-request")
.fetch_one(store.pool())
.await
.unwrap();
if row.0 != "running" {
break row;
}
assert!(
tokio::time::Instant::now() < deadline,
"Run remained running after the Cursor session failed"
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
};
assert_eq!(status, "failed");
assert_eq!(
failure_summary.as_deref(),
Some("unknown ExecClientMessage id: 1001")
);
}
#[tokio::test]
+217
View File
@@ -28,6 +28,7 @@ async fn generic_run_registry_cancels_the_previous_client_for_a_conversation() {
conversation.clone(),
cursor_server::model::RunId::new("first"),
first.clone(),
cursor_server::client::session(1).1.commands,
)
.await;
registry
@@ -35,6 +36,7 @@ async fn generic_run_registry_cancels_the_previous_client_for_a_conversation() {
conversation.clone(),
cursor_server::model::RunId::new("second"),
second.clone(),
cursor_server::client::session(1).1.commands,
)
.await;
@@ -426,6 +428,187 @@ async fn injected_user_context_restarts_only_the_active_model_cycle() {
);
}
#[tokio::test]
async fn cancel_subagent_action_aborts_the_target_task_and_keeps_the_parent_running() {
let (_directory, store) = fixtures::temp_store().await;
let provider = fake_provider::FakeProvider::default();
provider.push(vec![
ModelEvent::Start {
model_call_id: "task-cycle".into(),
},
ModelEvent::ToolCallStart {
index: 0,
call_id: "task-call".into(),
name: "Task".into(),
},
ModelEvent::ToolCallArgumentsDelta {
index: 0,
delta: serde_json::json!({
"description": "Inspect protocol",
"prompt": "Inspect the protocol",
"subagent_type": "generalPurpose",
"run_in_background": false
})
.to_string(),
},
ModelEvent::ToolCallEnd { index: 0 },
ModelEvent::Done(FinishReason::ToolUse),
]);
provider.push(vec![
ModelEvent::Start {
model_call_id: "continued".into(),
},
ModelEvent::TextStart,
ModelEvent::TextDelta("continued after subagent cancellation".into()),
ModelEvent::TextEnd,
ModelEvent::Done(FinishReason::Stop),
]);
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(provider.clone()),
PromptCompiler::new(assets),
Default::default(),
);
let handle = registry
.get_or_create("cancel-subagent-request")
.await
.unwrap();
let mut output = handle.subscribe();
handle
.command(CursorCommand::Append {
seqno: 0,
message: Box::new(client_run_for(
"cancel-subagent-request",
"cancel-subagent-conversation",
)),
})
.await
.unwrap();
let mut append_seqno = 1;
let exec_id = loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.expect("RunSSE closed before Task exec");
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
assert_eq!(flags & connect::END_STREAM_FLAG, 0);
let server = pb::AgentServerMessage::decode(payload).unwrap();
match server.message {
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
handle
.command(CursorCommand::Append {
seqno: append_seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
append_seqno += 1;
}
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => {
let Some(pb::exec_server_message::Message::SubagentArgs(args)) = exec.message
else {
continue;
};
assert_eq!(args.tool_call_id, "task-call");
break exec.id;
}
_ => {}
}
};
handle
.command(CursorCommand::Append {
seqno: append_seqno,
message: Box::new(runtime_cancel_subagent("task-call")),
})
.await
.unwrap();
append_seqno += 1;
loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.expect("RunSSE closed before Task abort");
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
assert_eq!(flags & connect::END_STREAM_FLAG, 0);
let server = pb::AgentServerMessage::decode(payload).unwrap();
match server.message {
Some(pb::agent_server_message::Message::ExecServerControlMessage(control)) => {
let Some(pb::exec_server_control_message::Message::Abort(abort)) = control.message
else {
continue;
};
assert_eq!(abort.id, exec_id);
break;
}
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
handle
.command(CursorCommand::Append {
seqno: append_seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
append_seqno += 1;
}
_ => {}
}
}
handle
.command(CursorCommand::Append {
seqno: append_seqno,
message: Box::new(subagent_aborted(exec_id)),
})
.await
.unwrap();
append_seqno += 1;
let mut saw_continued = false;
loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.expect("RunSSE closed before successful EndStream");
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
if flags & connect::END_STREAM_FLAG != 0 {
assert_eq!(payload.as_ref(), b"{}");
break;
}
let server = pb::AgentServerMessage::decode(payload).unwrap();
match server.message {
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
handle
.command(CursorCommand::Append {
seqno: append_seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
append_seqno += 1;
}
Some(pb::agent_server_message::Message::InteractionUpdate(update)) => {
if let Some(pb::interaction_update::Message::TextDelta(delta)) = update.message {
saw_continued |= delta.text.contains("continued after subagent cancellation");
}
}
_ => {}
}
}
assert!(saw_continued);
assert!(!handle.cancellation().is_cancelled());
assert_eq!(provider.requests().len(), 2);
}
fn client_run() -> pb::AgentClientMessage {
client_run_for("cancel-request", "cancel-conversation")
}
@@ -541,3 +724,37 @@ fn runtime_injection() -> pb::AgentClientMessage {
)),
}
}
fn runtime_cancel_subagent(tool_call_id: &str) -> pb::AgentClientMessage {
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::ConversationAction(
pb::ConversationAction {
action: Some(pb::conversation_action::Action::CancelSubagentAction(
pb::CancelSubagentAction {
subagent_id: tool_call_id.into(),
},
)),
..Default::default()
},
)),
}
}
fn subagent_aborted(id: u32) -> pb::AgentClientMessage {
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::ExecClientMessage(
pb::ExecClientMessage {
id,
message: Some(pb::exec_client_message::Message::SubagentResult(
pb::SubagentResult {
result: Some(pb::subagent_result::Result::Error(pb::SubagentError {
agent_id: None,
error: "Subagent was aborted by the user".into(),
})),
},
)),
..Default::default()
},
)),
}
}
+42
View File
@@ -102,6 +102,48 @@ async fn cursor_trace_links_detailed_artifacts_to_the_logical_run() {
assert_eq!(artifacts[1].data, b"response");
}
#[tokio::test]
async fn cursor_trace_artifact_and_blob_are_written_atomically() {
let (_directory, store) = test_store("cursor-trace-atomic.db").await;
store.set_detailed_logging(true).await.unwrap();
store
.start_cursor_trace_if_detailed(
"request-atomic",
Some("conversation"),
"cursor_official",
Some("model"),
)
.await
.unwrap();
sqlx::query(
"CREATE TRIGGER reject_trace_artifact
BEFORE INSERT ON cursor_run_trace_artifacts
BEGIN
SELECT RAISE(ABORT, 'rejected artifact');
END",
)
.execute(store.pool())
.await
.unwrap();
assert!(store
.append_cursor_trace_artifact(
"request-atomic",
"run_sse_chunk",
"cursor_official",
b"must-rollback",
&serde_json::json!({}),
)
.await
.is_err());
let blob_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blobs")
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(blob_count, 0);
}
#[tokio::test]
async fn records_one_summary_and_raw_payloads_for_one_provider_request() {
let app = Router::new().route(
+92
View File
@@ -218,3 +218,95 @@ async fn editing_a_logical_input_discards_its_active_suffix() {
vec![original, suffix]
);
}
#[tokio::test]
async fn retry_reuses_only_the_matching_initial_child_chain() {
let (_directory, store) = fixtures::temp_store().await;
let conversation_id = ConversationId::new("retry-conversation");
let root = store.ensure_conversation(&conversation_id).await.unwrap();
let first = prepared("first-run", &conversation_id, root);
store.claim_run(&first).await.unwrap();
let context = fixtures::user("request-context:event", "context");
let context_revision = store
.append_revision(
&conversation_id,
&first.run_id,
root,
std::slice::from_ref(&context),
)
.await
.unwrap();
let runtime = cursor_server::model::RuntimeEvent {
event_id: "cursor:user:stable-id:version".into(),
text: "query".into(),
}
.into_message();
let (partial_revision, partial_count) = store
.match_revision_prefix(&conversation_id, root, &[context.clone(), runtime.clone()])
.await
.unwrap();
assert_eq!(partial_revision, context_revision);
assert_eq!(partial_count, 1);
let runtime_revision = store
.append_revision(
&conversation_id,
&first.run_id,
context_revision,
std::slice::from_ref(&runtime),
)
.await
.unwrap();
let suffix = fixtures::user("old-answer", "old answer");
let old_head = store
.append_revision(
&conversation_id,
&first.run_id,
runtime_revision,
std::slice::from_ref(&suffix),
)
.await
.unwrap();
let (retry_base, reused) = store
.match_revision_prefix(&conversation_id, root, &[context.clone(), runtime.clone()])
.await
.unwrap();
assert_eq!(retry_base, runtime_revision);
assert_eq!(reused, 2);
assert_eq!(
store.load_revision_messages(retry_base).await.unwrap(),
vec![context.clone(), runtime.clone()]
);
let retry = prepared("retry-run", &conversation_id, retry_base);
let claimed = store.claim_run(&retry).await.unwrap();
assert_eq!(claimed.replaced_run_id.as_ref(), Some(&first.run_id));
let first_status: String = sqlx::query_scalar("SELECT status FROM runs WHERE run_id = ?")
.bind(first.run_id.as_str())
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(first_status, "cancelled");
let changed = cursor_server::model::RuntimeEvent {
event_id: "cursor:user:stable-id:changed-version".into(),
text: "edited query".into(),
}
.into_message();
let (changed_base, reused) = store
.match_revision_prefix(&conversation_id, root, &[context, changed])
.await
.unwrap();
assert_eq!(changed_base, context_revision);
assert_eq!(reused, 1);
assert_eq!(
store.load_revision_messages(old_head).await.unwrap(),
vec![
fixtures::user("request-context:event", "context"),
runtime,
suffix
]
);
}
+147 -4
View File
@@ -18,6 +18,150 @@ use cursor_server::{
};
use prost::Message;
#[tokio::test]
async fn retrying_the_same_edited_input_reuses_its_initial_branch() {
let (_directory, store) = fixtures::temp_store().await;
let provider = fake_provider::FakeProvider::default();
for (call, answer) in [
("model", "answer"),
("model-retry", "retry answer"),
("model-edit", "edited answer"),
("model-context-edit", "context edited answer"),
] {
provider.push(vec![
ModelEvent::Start {
model_call_id: call.into(),
},
ModelEvent::TextStart,
ModelEvent::TextDelta(answer.into()),
ModelEvent::TextEnd,
ModelEvent::Done(FinishReason::Stop),
]);
}
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("prompt/cursor")
.as_path(),
)
.unwrap();
let registry = CursorSessionRegistry::new(
store.clone(),
Arc::new(provider.clone()),
PromptCompiler::new(assets),
Default::default(),
);
for (request_id, text, visible_file) in [
("edited-input", "explain this", "/workspace/src/main.rs"),
(
"edited-input-retry",
"explain this",
"/workspace/src/main.rs",
),
(
"edited-input-changed",
"explain the edited version",
"/workspace/src/main.rs",
),
(
"edited-input-context-changed",
"explain this",
"/workspace/src/edited.rs",
),
] {
let handle = registry.get_or_create(request_id).await.unwrap();
let mut output = handle.subscribe();
let mut request = run_request(references(&store).await);
let Some(pb::agent_client_message::Message::RunRequest(run)) = request.message.as_mut()
else {
unreachable!("run_request always returns a RunRequest")
};
let Some(pb::conversation_action::Action::UserMessageAction(action)) = run
.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")
.text = text.into();
let user = action
.user_message
.as_mut()
.expect("run_request always contains a UserMessage");
let Some(pb::invocation_context::Data::IdeState(ide)) = user
.selected_context
.as_mut()
.and_then(|selected| selected.invocation_context.as_mut())
.and_then(|invocation| invocation.data.as_mut())
else {
unreachable!("run_request always contains IDE state")
};
ide.visible_files[0].path = visible_file.into();
handle
.command(CursorCommand::Append {
seqno: 0,
message: Box::new(request),
})
.await
.unwrap();
let mut seqno = 1;
loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.expect("retry must finish without closing the stream early");
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
if flags & connect::END_STREAM_FLAG != 0 {
let end = serde_json::from_slice::<serde_json::Value>(&payload).unwrap();
assert_eq!(end, serde_json::json!({}));
break;
}
let message = pb::AgentServerMessage::decode(payload).unwrap();
if let Some(pb::agent_server_message::Message::KvServerMessage(kv)) = message.message {
handle
.command(CursorCommand::Append {
seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
seqno += 1;
}
}
}
let requests = provider.requests();
assert_eq!(requests.len(), 4);
assert_eq!(requests[1].history, requests[0].history);
assert_eq!(requests[2].history.len(), requests[0].history.len());
assert_ne!(
requests[2].history.last().unwrap().message_id,
requests[0].history.last().unwrap().message_id
);
let ProjectedContent::Parts(parts) = &requests[2].history.last().unwrap().content else {
panic!("edited runtime message must use typed parts")
};
let [ContentPart::Text { text }] = parts.as_slice() else {
panic!("this fixture has no images")
};
assert!(text.contains("<user_query>\nexplain the edited version\n</user_query>"));
assert_ne!(
requests[3].history.last().unwrap().message_id,
requests[0].history.last().unwrap().message_id
);
let ProjectedContent::Parts(parts) = &requests[3].history.last().unwrap().content else {
panic!("context-edited runtime message must use typed parts")
};
let [ContentPart::Text { text }] = parts.as_slice() else {
panic!("this fixture has no images")
};
assert!(text.contains("/workspace/src/edited.rs"));
}
#[tokio::test]
async fn unchanged_request_context_is_not_repeated_and_preserves_the_provider_prefix() {
let (_directory, store) = fixtures::temp_store().await;
@@ -115,10 +259,9 @@ async fn unchanged_request_context_is_not_repeated_and_preserves_the_provider_pr
let [ContentPart::Text { text: context_text }] = context_parts.as_slice() else {
panic!("request context message must contain one text part")
};
assert_eq!(
request.history[1].message_id,
"runtime:cursor:user:wire-user"
);
assert!(request.history[1]
.message_id
.starts_with("runtime:cursor:user:wire-user:"));
assert!(!request.prompt.instructions.contains("workspace rule"));
assert!(!request.prompt.instructions.contains("<mcp_meta_tools>"));
let ProjectedContent::Parts(parts) = &request.history[1].content else {
+9 -2
View File
@@ -89,7 +89,11 @@ async fn selected_image_bytes_flow_from_run_request_to_history_providers_and_che
let user = requests[0]
.history
.iter()
.find(|message| message.message_id == "runtime:cursor:user:image-user")
.find(|message| {
message
.message_id
.starts_with("runtime:cursor:user:image-user:")
})
.unwrap();
let ProjectedContent::Parts(parts) = &user.content else {
panic!("runtime user message must retain typed parts")
@@ -128,7 +132,10 @@ async fn selected_image_bytes_flow_from_run_request_to_history_providers_and_che
let id = BlobId::from_bytes(raw_id).unwrap();
let bytes = store.get_blob(&id).await.unwrap().unwrap();
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if value["id"] == "runtime:cursor:user:image-user" {
if value["id"]
.as_str()
.is_some_and(|id| id.starts_with("runtime:cursor:user:image-user:"))
{
user_root = Some(value);
break;
}
+23 -1
View File
@@ -10,11 +10,15 @@ use cursor_server::{
provider::{ModelEvent, Provider, ProviderStream},
Error,
};
use futures_util::stream;
use futures_util::{stream, StreamExt};
use tokio_util::sync::CancellationToken;
enum FakeResponse {
Events(Vec<Result<ModelEvent, Error>>),
Gated {
ready: Arc<tokio::sync::Notify>,
events: Vec<Result<ModelEvent, Error>>,
},
Pending,
}
@@ -43,6 +47,17 @@ impl FakeProvider {
.unwrap()
.push_back(FakeResponse::Pending);
}
pub fn push_gated(&self, events: Vec<ModelEvent>) -> Arc<tokio::sync::Notify> {
let ready = Arc::new(tokio::sync::Notify::new());
self.responses
.lock()
.unwrap()
.push_back(FakeResponse::Gated {
ready: ready.clone(),
events: events.into_iter().map(Ok).collect(),
});
ready
}
pub fn requests(&self) -> Vec<ModelRequest> {
self.requests.lock().unwrap().clone()
}
@@ -63,6 +78,13 @@ impl Provider for FakeProvider {
.expect("fake response configured");
match events {
FakeResponse::Events(events) => Box::pin(stream::iter(events)),
FakeResponse::Gated { ready, events } => Box::pin(
stream::once(async move {
ready.notified().await;
events
})
.flat_map(stream::iter),
),
FakeResponse::Pending => Box::pin(stream::pending()),
}
}
+3 -1
View File
@@ -283,7 +283,9 @@ async fn text_turn_runs_from_bidi_request_through_checkpoint_and_end_stream() {
.unwrap();
assert!(messages[0].message_id.starts_with("request-context:"));
assert_eq!(messages[0].role, Role::User);
assert_eq!(messages[1].message_id, "runtime:cursor:user:user");
assert!(messages[1]
.message_id
.starts_with("runtime:cursor:user:user:"));
assert_eq!(messages[1].role, Role::User);
assert_eq!(
messages.len(),
+30
View File
@@ -563,6 +563,36 @@ async fn empty_exec_client_message_is_not_a_terminal_result() {
);
}
#[tokio::test]
async fn exec_stream_close_without_a_terminal_result_becomes_a_tool_error() {
let pending = CursorToolRuntime::default();
let mut shell = call("call-1", "Shell");
shell.arguments = json!({"command": "git status"});
let id = pending.reserve_exec(&shell, &exec_context()).await.unwrap();
let completion = codec::stream_closed(id, &pending)
.await
.unwrap()
.expect("a running Exec should complete when its stream closes");
assert_eq!(completion.result().call_id, "call-1");
assert!(completion.result().is_error);
assert_eq!(
completion.result().content,
"Cursor Exec stream closed before returning a terminal result"
);
let Some(pb::tool_call::Tool::ShellToolCall(shell)) = &completion.tool_call().tool else {
panic!("expected typed Shell completion")
};
assert!(matches!(
shell.result.as_ref().and_then(|result| result.result.as_ref()),
Some(pb::shell_result::Result::SpawnError(error))
if error.error == "Cursor Exec stream closed before returning a terminal result"
));
assert!(pending.exec_call(id).await.is_none());
assert!(codec::stream_closed(id, &pending).await.unwrap().is_none());
}
#[tokio::test]
async fn tool_success_is_not_inferred_from_debug_text() {
let pending = CursorToolRuntime::default();