mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-07 06:04:53 +08:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4234da4b4b | ||
|
|
6c758f2b44 | ||
|
|
9cb16e116b | ||
|
|
c5d578c5b1 | ||
|
|
661ce11207 | ||
|
|
477750860f | ||
|
|
02a190489a | ||
|
|
ee915ee760 | ||
|
|
aa68205735 | ||
|
|
7914f24d83 | ||
|
|
b85cb37898 | ||
|
|
cfc333dde4 |
@@ -1,16 +1,20 @@
|
|||||||
LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key
|
LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key
|
||||||
|
|
||||||
.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker
|
.PHONY: check dev-web dev-docs dev-server dev-desktop build-web build-docs build-server build-desktop build-docker
|
||||||
|
|
||||||
check:
|
check:
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
cargo test --workspace --all-targets
|
cargo test --workspace --all-targets
|
||||||
npm --prefix apps/desktop run check
|
npm --prefix apps/desktop run check
|
||||||
|
npm --prefix apps/docs run check
|
||||||
|
|
||||||
dev-web:
|
dev-web:
|
||||||
npm --prefix apps/desktop run dev:web
|
npm --prefix apps/desktop run dev:web
|
||||||
|
|
||||||
|
dev-docs:
|
||||||
|
npm --prefix apps/docs run dev
|
||||||
|
|
||||||
dev-server:
|
dev-server:
|
||||||
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
|
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
|
||||||
|
|
||||||
@@ -20,6 +24,9 @@ dev-desktop:
|
|||||||
build-web:
|
build-web:
|
||||||
npm --prefix apps/desktop run build
|
npm --prefix apps/desktop run build
|
||||||
|
|
||||||
|
build-docs:
|
||||||
|
npm --prefix apps/docs run build
|
||||||
|
|
||||||
build-server:
|
build-server:
|
||||||
cargo build --release --package cursor-server --bin cursor-server
|
cargo build --release --package cursor-server --bin cursor-server
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#141414" />
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: #141414;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<title>Cursor BYOK 产品演示</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/src/demo/index.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||||
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
|
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts",
|
||||||
"check": "npm run typecheck && npm run typecheck:node && npm run build",
|
"check": "npm run typecheck && npm run typecheck:node && npm run build",
|
||||||
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
|
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import type {
|
||||||
|
CallDetail,
|
||||||
|
CursorHarnessStatus,
|
||||||
|
LlmCall,
|
||||||
|
Model,
|
||||||
|
Overview,
|
||||||
|
OverviewTokenUsageBucket,
|
||||||
|
ProxySettings,
|
||||||
|
StatisticsStorage,
|
||||||
|
TabSettings,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
|
const API_ROOT = "/__byok-api__/api";
|
||||||
|
const FIXED_NOW = Date.UTC(2026, 7, 27, 8, 0, 0);
|
||||||
|
|
||||||
|
const models: Model[] = [
|
||||||
|
createModel({ hash: "mock-claude-sonnet", order: 1, name: "Claude Sonnet 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-sonnet-4-20250514" }),
|
||||||
|
createModel({ hash: "mock-claude-opus", order: 2, name: "Claude Opus 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-opus-4-20250514" }),
|
||||||
|
createModel({ hash: "mock-gpt", order: 3, name: "GPT-5.2", type: "openai", url: "https://api.openai.com", modelId: "gpt-5.2" }),
|
||||||
|
createModel({ hash: "mock-o3", order: 4, name: "o3", type: "openai", url: "https://api.openai.com", modelId: "o3" }),
|
||||||
|
createModel({ hash: "mock-deepseek-v3", order: 5, name: "DeepSeek V3.2", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-chat", endpoint: "/v1/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-deepseek-r1", order: 6, name: "DeepSeek R1", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-reasoner", endpoint: "/v1/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-gemini-pro", order: 7, name: "Gemini 2.5 Pro", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-pro", endpoint: "/v1beta/openai/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-gemini-flash", order: 8, name: "Gemini 2.5 Flash", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-flash", endpoint: "/v1beta/openai/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-qwen-max", order: 9, name: "Qwen3 Max", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen3-max", endpoint: "/v1/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-qwen-plus", order: 10, name: "Qwen Plus", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen-plus", endpoint: "/v1/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-kimi-k2", order: 11, name: "Kimi K2", type: "openai", url: "https://api.moonshot.cn", modelId: "kimi-k2-0711-preview", endpoint: "/v1/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-kimi-128k", order: 12, name: "Moonshot V1 128K", type: "openai", url: "https://api.moonshot.cn", modelId: "moonshot-v1-128k", endpoint: "/v1/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-glm-45", order: 13, name: "GLM-4.5", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5", endpoint: "/api/paas/v4/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-glm-air", order: 14, name: "GLM-4.5-Air", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5-air", endpoint: "/api/paas/v4/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-mistral-large", order: 15, name: "Mistral Large", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-large-latest", endpoint: "/v1/chat/completions" }),
|
||||||
|
createModel({ hash: "mock-mistral-small", order: 16, name: "Mistral Small", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-small-latest", endpoint: "/v1/chat/completions" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const calls: LlmCall[] = Array.from({ length: 24 }, (_, index) => {
|
||||||
|
const model = models[index % models.length];
|
||||||
|
const failed = index === 7 || index === 19;
|
||||||
|
return {
|
||||||
|
call_kind: "provider_llm",
|
||||||
|
route: "local_byok",
|
||||||
|
call_id: `mock-call-${String(index + 1).padStart(3, "0")}`,
|
||||||
|
run_id: `mock-run-${Math.floor(index / 3) + 1}`,
|
||||||
|
conversation_id: `mock-conversation-${Math.floor(index / 4) + 1}`,
|
||||||
|
provider_call_index: index + 1,
|
||||||
|
model_hash: model.model_hash,
|
||||||
|
provider_type: model.type,
|
||||||
|
provider_url: model.base_url,
|
||||||
|
request_type: model.type === "anthropic" ? "messages" : "responses",
|
||||||
|
request_url: model.type === "anthropic" ? `${model.base_url}/v1/messages` : `${model.base_url}${model.openai_endpoint}`,
|
||||||
|
model_id: model.model_id,
|
||||||
|
display_name: model.display_name,
|
||||||
|
reasoning_effort: index % 2 === 0 ? "high" : null,
|
||||||
|
fast: index % 3 === 0,
|
||||||
|
status: failed ? "failed" : "completed",
|
||||||
|
finish_reason: failed ? null : "stop",
|
||||||
|
created_at_ms: FIXED_NOW - index * 3 * 60_000,
|
||||||
|
ttfb_ms: 210 + index * 13,
|
||||||
|
ttft_ms: 370 + index * 17,
|
||||||
|
duration_ms: failed ? 812 : 1_420 + index * 71,
|
||||||
|
input_tokens: 4_800 + index * 337,
|
||||||
|
output_tokens: failed ? 0 : 820 + index * 43,
|
||||||
|
total_tokens: failed ? 4_800 + index * 337 : 5_620 + index * 380,
|
||||||
|
cache_read_tokens: 3_100 + index * 251,
|
||||||
|
cache_write_tokens: 320 + index * 19,
|
||||||
|
reasoning_tokens: index % 2 === 0 ? 420 + index * 11 : null,
|
||||||
|
message_count: 14 + (index % 8),
|
||||||
|
tool_count: 3 + (index % 5),
|
||||||
|
http_status: failed ? 429 : 200,
|
||||||
|
error_kind: failed ? "provider_rate_limit" : null,
|
||||||
|
error_message: failed ? "Mock provider rate limit" : null,
|
||||||
|
detailed: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const harnessStatus: CursorHarnessStatus = {
|
||||||
|
platform: "macos",
|
||||||
|
ca: "ready",
|
||||||
|
configured_models: models.length,
|
||||||
|
enabled_models: models.length,
|
||||||
|
integration: "enabled",
|
||||||
|
proxy_url: "http://127.0.0.1:54321",
|
||||||
|
ca_install_command: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let detailed = true;
|
||||||
|
let portSettings = { proxy_port: 0, service_port: 0 };
|
||||||
|
let proxySettings: ProxySettings = {
|
||||||
|
mode: "system",
|
||||||
|
address: "",
|
||||||
|
auth_enabled: false,
|
||||||
|
username: "",
|
||||||
|
has_password: false,
|
||||||
|
};
|
||||||
|
let tabSettings: TabSettings = { mode: "public", address: "" };
|
||||||
|
let storage: StatisticsStorage = { bytes: 26_004_480, call_count: calls.length, trace_count: calls.length };
|
||||||
|
|
||||||
|
export function installDemoApi() {
|
||||||
|
const nativeFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const requestUrl = input instanceof Request ? input.url : input instanceof URL ? input.href : input;
|
||||||
|
const url = new URL(requestUrl, window.location.href);
|
||||||
|
if (!url.pathname.startsWith(API_ROOT)) return nativeFetch(input, init);
|
||||||
|
|
||||||
|
const path = url.pathname.slice(API_ROOT.length) || "/";
|
||||||
|
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
||||||
|
const body = await readBody(input, init);
|
||||||
|
|
||||||
|
if (path === "/ads") return json({ slots: [] });
|
||||||
|
if (path === "/models" && method === "GET") return json(models);
|
||||||
|
if (path === "/models" && method === "POST") return json(models);
|
||||||
|
if (path === "/models/order") return json(models);
|
||||||
|
if (path === "/models/discover") return json({ models: models.map((model) => model.model_id) });
|
||||||
|
if (path === "/models/import-v0049" && method === "GET") {
|
||||||
|
return json({ source: "demo", total: 0, new_models: 0, existing_models: 0, models: [] });
|
||||||
|
}
|
||||||
|
if (path === "/models/import-v0049") return json({ imported: 0, skipped: 0, total: 0 });
|
||||||
|
if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) && method === "POST") {
|
||||||
|
return json({ duration_ms: 1_284, first_text_ms: 418, output_tokens: 42, tokens_per_second: 38.6, tokens_estimated: false, output: "Mock connectivity test passed." });
|
||||||
|
}
|
||||||
|
if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) || /^\/models\/[^/]+$/.test(path)) {
|
||||||
|
return method === "DELETE" ? empty() : json(models[0]);
|
||||||
|
}
|
||||||
|
if (path === "/overview") return json(createOverview(url.searchParams));
|
||||||
|
if (path === "/llm-calls") return json(calls);
|
||||||
|
if (path.startsWith("/llm-calls/")) return json(createCallDetail(path.slice("/llm-calls/".length)));
|
||||||
|
if (path === "/harness/cursor/status") return json(harnessStatus);
|
||||||
|
if (path === "/harness/cursor/ca/initialize" || path === "/harness/cursor/enabled") return json(harnessStatus);
|
||||||
|
if (path === "/settings/observability" && method === "GET") return json({ detailed });
|
||||||
|
if (path === "/settings/observability") {
|
||||||
|
detailed = Boolean((body as { detailed?: unknown } | null)?.detailed);
|
||||||
|
return json({ detailed });
|
||||||
|
}
|
||||||
|
if (path === "/settings/ports" && method === "GET") return json(portSettings);
|
||||||
|
if (path === "/settings/ports") {
|
||||||
|
portSettings = body as typeof portSettings;
|
||||||
|
return json(portSettings);
|
||||||
|
}
|
||||||
|
if (path === "/settings/storage/statistics" && method === "GET") return json(storage);
|
||||||
|
if (path === "/settings/storage/statistics") {
|
||||||
|
storage = { bytes: 0, call_count: 0, trace_count: 0 };
|
||||||
|
return json(storage);
|
||||||
|
}
|
||||||
|
if (path === "/settings/proxy" && method === "GET") return json(proxySettings);
|
||||||
|
if (path === "/settings/proxy") {
|
||||||
|
const next = body as Partial<ProxySettings>;
|
||||||
|
proxySettings = { ...proxySettings, ...next, has_password: Boolean(next.has_password) };
|
||||||
|
return json(proxySettings);
|
||||||
|
}
|
||||||
|
if (path === "/settings/tab" && method === "GET") return json(tabSettings);
|
||||||
|
if (path === "/settings/tab") {
|
||||||
|
tabSettings = body as TabSettings;
|
||||||
|
return json(tabSettings);
|
||||||
|
}
|
||||||
|
if (path === "/settings/desktop" && method === "GET") return json({ silent_start: false, show_dock_icon: true });
|
||||||
|
if (path === "/settings/desktop") return json(body);
|
||||||
|
if (path === "/desktop/open-external-url") {
|
||||||
|
const target = (body as { url?: string } | null)?.url;
|
||||||
|
if (target) {
|
||||||
|
const next = new URL(target, window.location.href);
|
||||||
|
if (next.origin === window.location.origin && next.hash) window.location.hash = next.hash;
|
||||||
|
}
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
if (path.endsWith("/dismissals")) return empty();
|
||||||
|
|
||||||
|
return json({ message: `Unhandled demo endpoint: ${method} ${path}` }, 404);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModel({ hash, order, name, type, url, modelId, endpoint = "/v1/responses" }: {
|
||||||
|
hash: string;
|
||||||
|
order: number;
|
||||||
|
name: string;
|
||||||
|
type: Model["type"];
|
||||||
|
url: string;
|
||||||
|
modelId: string;
|
||||||
|
endpoint?: string;
|
||||||
|
}): Model {
|
||||||
|
return {
|
||||||
|
model_hash: hash,
|
||||||
|
sort_order: order,
|
||||||
|
display_name: name,
|
||||||
|
type,
|
||||||
|
base_url: url,
|
||||||
|
use_full_url: false,
|
||||||
|
api_key: "demo-key",
|
||||||
|
tooltip_data: `${name} Mock 通道`,
|
||||||
|
model_id: modelId,
|
||||||
|
reasoning_effort: type === "openai" ? "high" : null,
|
||||||
|
openai_endpoint: type === "openai" ? endpoint : "",
|
||||||
|
openai_extra_params_enabled: false,
|
||||||
|
openai_extra_params: {},
|
||||||
|
custom_headers_enabled: false,
|
||||||
|
custom_headers: {},
|
||||||
|
anthropic_extra_params_enabled: false,
|
||||||
|
anthropic_extra_params: {},
|
||||||
|
context_window_tokens: 200_000,
|
||||||
|
max_completion_tokens: type === "openai" ? 32_000 : null,
|
||||||
|
anthropic_max_tokens: type === "anthropic" ? 32_000 : null,
|
||||||
|
anthropic_thinking_effort: type === "anthropic" ? "high" : null,
|
||||||
|
thinking_budget_tokens: null,
|
||||||
|
created_at_ms: FIXED_NOW - order * 86_400_000,
|
||||||
|
updated_at_ms: FIXED_NOW,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOverview(params: URLSearchParams): Overview {
|
||||||
|
const start = Number(params.get("start_ms"));
|
||||||
|
const end = Number(params.get("end_ms"));
|
||||||
|
const duration = Number.isFinite(start) && Number.isFinite(end) && end > start ? end - start : 365 * 86_400_000;
|
||||||
|
const granularity = duration <= 2 * 60 * 60_000 ? "minute" : duration <= 2 * 86_400_000 ? "hour" : "day";
|
||||||
|
const step = granularity === "minute" ? 60_000 : granularity === "hour" ? 3_600_000 : 86_400_000;
|
||||||
|
const count = granularity === "minute" ? Math.min(60, Math.max(10, Math.ceil(duration / step))) : granularity === "hour" ? Math.min(24, Math.max(8, Math.ceil(duration / step))) : Math.min(365, Math.max(7, Math.ceil(duration / step)));
|
||||||
|
const series = createSeries(count, step, Number.isFinite(end) && end > 0 ? end : FIXED_NOW);
|
||||||
|
const totals = series.reduce((sum, bucket) => ({
|
||||||
|
input: sum.input + bucket.input_tokens,
|
||||||
|
cacheRead: sum.cacheRead + bucket.cache_read_tokens,
|
||||||
|
cacheWrite: sum.cacheWrite + bucket.cache_write_tokens,
|
||||||
|
output: sum.output + bucket.output_tokens,
|
||||||
|
}), { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 });
|
||||||
|
const llmCalls = Math.max(12, Math.round(count * 5.4));
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: {
|
||||||
|
llm_calls: llmCalls,
|
||||||
|
successful_calls: llmCalls - Math.max(1, Math.floor(llmCalls * 0.008)),
|
||||||
|
failed_calls: Math.max(1, Math.floor(llmCalls * 0.008)),
|
||||||
|
token_usage: totals.input + totals.cacheRead + totals.cacheWrite + totals.output,
|
||||||
|
prompt_tokens: totals.input + totals.cacheRead + totals.cacheWrite,
|
||||||
|
input_tokens: totals.input,
|
||||||
|
cache_read_tokens: totals.cacheRead,
|
||||||
|
cache_write_tokens: totals.cacheWrite,
|
||||||
|
output_tokens: totals.output,
|
||||||
|
},
|
||||||
|
token_usage_granularity: granularity,
|
||||||
|
token_usage_series: series,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSeries(count: number, step: number, end: number): OverviewTokenUsageBucket[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const wave = 0.72 + ((index * 17) % 31) / 50;
|
||||||
|
return {
|
||||||
|
bucket_start_ms: end - (count - index) * step,
|
||||||
|
input_tokens: Math.round(18_000 * wave),
|
||||||
|
cache_read_tokens: Math.round(62_000 * wave),
|
||||||
|
cache_write_tokens: Math.round(7_500 * wave),
|
||||||
|
output_tokens: Math.round(12_500 * wave),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCallDetail(id: string): CallDetail {
|
||||||
|
const call = calls.find((item) => item.call_id === decodeURIComponent(id)) ?? calls[0];
|
||||||
|
return {
|
||||||
|
call,
|
||||||
|
request: {
|
||||||
|
headers: { authorization: "Bearer sk-demo-••••", "content-type": "application/json" },
|
||||||
|
body: { model: call.model_id, stream: true, messages: [{ role: "user", content: "Mock Agent request" }] },
|
||||||
|
byte_count: 8_426,
|
||||||
|
},
|
||||||
|
response_chunks: [
|
||||||
|
{ seq: 1, received_offset_ms: 418, data: "event: response.created", byte_count: 128 },
|
||||||
|
{ seq: 2, received_offset_ms: 512, data: "event: response.output_text.delta", byte_count: 256 },
|
||||||
|
],
|
||||||
|
cursor_trace: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(input: RequestInfo | URL, init?: RequestInit): Promise<unknown> {
|
||||||
|
const raw = init?.body ?? (input instanceof Request ? await input.clone().text() : null);
|
||||||
|
if (typeof raw !== "string" || raw.length === 0) return null;
|
||||||
|
try { return JSON.parse(raw) as unknown; }
|
||||||
|
catch { return raw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(value: unknown, status = 200) {
|
||||||
|
return new Response(JSON.stringify(value), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function empty() {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { installDemoApi } from "./api";
|
||||||
|
|
||||||
|
installDemoApi();
|
||||||
|
document.documentElement.dataset.platform = "macos";
|
||||||
|
localStorage.setItem("cursor-byok.locale", "zh-CN");
|
||||||
|
localStorage.setItem("cursor-byok.theme", "default-dark");
|
||||||
|
|
||||||
|
void import("../index");
|
||||||
@@ -3,6 +3,7 @@ import appIcon from "../../src-tauri/icons/32x32.png";
|
|||||||
import { currentAppVersion } from "../native/appLifecycle";
|
import { currentAppVersion } from "../native/appLifecycle";
|
||||||
import type { DesktopPlatform } from "../native/platform";
|
import type { DesktopPlatform } from "../native/platform";
|
||||||
import { WindowControls } from "./WindowControls";
|
import { WindowControls } from "./WindowControls";
|
||||||
|
import { MacTrafficLights } from "./MacTrafficLights";
|
||||||
import styles from "./AppHeader.module.scss";
|
import styles from "./AppHeader.module.scss";
|
||||||
|
|
||||||
type AppHeaderProps = {
|
type AppHeaderProps = {
|
||||||
@@ -11,6 +12,7 @@ type AppHeaderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
|
export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
|
||||||
|
const showMacTrafficLights = !nativeDesktop && platform === "macos";
|
||||||
const showNativeUi = nativeDesktop && platform !== "macos";
|
const showNativeUi = nativeDesktop && platform !== "macos";
|
||||||
const [version, setVersion] = useState("…");
|
const [version, setVersion] = useState("…");
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
|
|||||||
return <header className={styles.root}>
|
return <header className={styles.root}>
|
||||||
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
|
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
|
||||||
<div className={styles.uiLayer}>
|
<div className={styles.uiLayer}>
|
||||||
|
{showMacTrafficLights && <MacTrafficLights />}
|
||||||
{showNativeUi && <>
|
{showNativeUi && <>
|
||||||
<div className={styles.identity} aria-label="Cursor BYOK">
|
<div className={styles.identity} aria-label="Cursor BYOK">
|
||||||
<img src={appIcon} alt="" />
|
<img src={appIcon} alt="" />
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
flex: 0 0 12px;
|
||||||
|
border: 0.5px solid rgb(0 0 0 / 18%);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
background: #ff5f57;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimize {
|
||||||
|
background: #febc2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom {
|
||||||
|
background: #28c840;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import styles from "./MacTrafficLights.module.scss";
|
||||||
|
|
||||||
|
export function MacTrafficLights() {
|
||||||
|
return <div className={styles.root} aria-hidden="true">
|
||||||
|
<span className={[styles.light, styles.close].join(" ")} />
|
||||||
|
<span className={[styles.light, styles.minimize].join(" ")} />
|
||||||
|
<span className={[styles.light, styles.zoom].join(" ")} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
export type DesktopPlatform = "macos" | "windows" | "linux";
|
export type DesktopPlatform = "macos" | "windows" | "linux";
|
||||||
|
|
||||||
export function desktopPlatform(): DesktopPlatform {
|
export function desktopPlatform(): DesktopPlatform {
|
||||||
|
const configured = document.documentElement.dataset.platform;
|
||||||
|
if (configured === "macos" || configured === "windows" || configured === "linux") {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
const agent = navigator.userAgent;
|
const agent = navigator.userAgent;
|
||||||
if (/Macintosh|Mac OS X/.test(agent)) return "macos";
|
if (/Macintosh|Mac OS X/.test(agent)) return "macos";
|
||||||
if (/Windows/.test(agent)) return "windows";
|
if (/Windows/.test(agent)) return "windows";
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts", "plugins/**/*.ts"]
|
"include": ["vite*.config.ts", "plugins/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { staticI18nPlugin } from "./plugins/static-i18n-plugin.ts";
|
||||||
|
|
||||||
|
const desktopRoot = fileURLToPath(new URL("./", import.meta.url));
|
||||||
|
const demoInput = fileURLToPath(new URL("./demo/index.html", import.meta.url));
|
||||||
|
const demoOutput = fileURLToPath(new URL("../docs/public/product-demo", import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: desktopRoot,
|
||||||
|
base: "/product-demo/",
|
||||||
|
plugins: [staticI18nPlugin(), react()],
|
||||||
|
publicDir: false,
|
||||||
|
build: {
|
||||||
|
outDir: demoOutput,
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: demoInput,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# deps
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# generated desktop product demo
|
||||||
|
/public/product-demo
|
||||||
|
|
||||||
|
# generated content
|
||||||
|
.source
|
||||||
|
|
||||||
|
# test & build
|
||||||
|
/coverage
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
/build
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# others
|
||||||
|
.env*.local
|
||||||
|
.vercel
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# cursor-byok 文档站
|
||||||
|
|
||||||
|
基于 [Fumadocs](https://fumadocs.dev) 和 Next.js 的独立内容应用。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/docs/
|
||||||
|
├── app/
|
||||||
|
│ ├── (home)/ # Hero 首页与开发者博客
|
||||||
|
│ └── docs/ # 用户文档布局和页面
|
||||||
|
├── components/ # MDX 组件
|
||||||
|
├── content/
|
||||||
|
│ ├── docs/ # 用户文档
|
||||||
|
│ └── blog/ # 开发者博客
|
||||||
|
└── lib/ # 内容源、站点信息与布局配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
先安装依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix apps/docs install
|
||||||
|
```
|
||||||
|
|
||||||
|
从仓库根目录启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-docs
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器打开 <http://localhost:3000>。
|
||||||
|
|
||||||
|
## 修改内容
|
||||||
|
|
||||||
|
- 在 `content/docs` 中维护面向使用者的 `.mdx` 文档。
|
||||||
|
- 在 `content/docs/meta.json` 中维护文档侧边栏标题与页面顺序。
|
||||||
|
- 在 `content/blog` 中维护开发者文章,文件名以 `YYYY-MM-DD-` 开头用于排序和展示日期。
|
||||||
|
- 在 `lib/shared.ts` 中维护站点名称、仓库和发布地址。
|
||||||
|
|
||||||
|
## 检查与构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix apps/docs run check
|
||||||
|
```
|
||||||
|
|
||||||
|
生产构建输出由 Next.js 管理。部署环境可通过 `NEXT_PUBLIC_SITE_URL` 设置站点公开地址;未设置时使用 `https://docs.leokun.cn`。
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
|
||||||
|
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||||
|
import { getMDXComponents } from '@/components/mdx';
|
||||||
|
import { blogSource, formatBlogDate } from '@/lib/blog';
|
||||||
|
|
||||||
|
export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) {
|
||||||
|
const { slug } = await props.params;
|
||||||
|
const page = blogSource.getPage([slug]);
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
const MDX = page.data.body;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-3xl px-6 py-12 sm:py-20">
|
||||||
|
<article>
|
||||||
|
<Link
|
||||||
|
href="/blog"
|
||||||
|
className="mb-10 inline-flex items-center gap-2 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
返回开发者博客
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="border-b pb-8">
|
||||||
|
<time className="text-sm text-fd-muted-foreground">{formatBlogDate(page.path)}</time>
|
||||||
|
<h1 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">{page.data.title}</h1>
|
||||||
|
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">{page.data.description}</p>
|
||||||
|
<p className="mt-5 text-sm font-medium">cursor-byok 开发团队</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="prose mt-10 min-w-0">
|
||||||
|
<InlineTOC items={page.data.toc} />
|
||||||
|
<MDX
|
||||||
|
components={getMDXComponents({
|
||||||
|
a: createRelativeLink(blogSource, page),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return blogSource.getPages().map((page) => ({ slug: page.slugs[0] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: PageProps<'/blog/[slug]'>): Promise<Metadata> {
|
||||||
|
const { slug } = await props.params;
|
||||||
|
const page = blogSource.getPage([slug]);
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: page.data.title,
|
||||||
|
description: page.data.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: '开发者博客',
|
||||||
|
description: 'cursor-byok 的架构决策、协议实现与开发进展。',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BlogPage() {
|
||||||
|
const posts = sortBlogPages(blogSource.getPages());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-16 sm:py-24">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<p className="font-mono text-sm font-medium text-fd-primary">DEVELOPER BLOG</p>
|
||||||
|
<h1 className="mt-4 text-4xl font-bold tracking-tight">开发者博客</h1>
|
||||||
|
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">
|
||||||
|
记录 cursor-byok 的架构决策、协议实现和开发进展。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 divide-y border-y">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<Link
|
||||||
|
key={post.url}
|
||||||
|
href={post.url}
|
||||||
|
className="group grid gap-3 py-7 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
|
||||||
|
>
|
||||||
|
<time className="text-sm text-fd-muted-foreground">{formatBlogDate(post.path)}</time>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">{post.data.title}</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-fd-muted-foreground">
|
||||||
|
{post.data.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||||
|
import { baseOptions } from '@/lib/layout.shared';
|
||||||
|
|
||||||
|
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||||
|
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
BookOpen,
|
||||||
|
Download,
|
||||||
|
Settings2,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { DesktopDemo } from '@/components/hero/DesktopDemo';
|
||||||
|
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
|
||||||
|
import { appDescription, releaseUrl } from '@/lib/shared';
|
||||||
|
|
||||||
|
const docs = [
|
||||||
|
{
|
||||||
|
icon: BookOpen,
|
||||||
|
title: '快速开始',
|
||||||
|
description: '完成安装、初始化和第一次模型调用。',
|
||||||
|
href: '/docs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Settings2,
|
||||||
|
title: '模型配置',
|
||||||
|
description: '配置协议、服务地址、凭据和生成参数。',
|
||||||
|
href: '/docs/model-configuration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Wrench,
|
||||||
|
title: '故障排查',
|
||||||
|
description: '解决证书、连接和模型测试问题。',
|
||||||
|
href: '/docs/troubleshooting',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const posts = sortBlogPages(blogSource.getPages()).slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-1 flex-col">
|
||||||
|
<section className="border-b px-4 pb-16 pt-20 sm:px-6 sm:pb-24 sm:pt-28">
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
<div className="mx-auto max-w-3xl text-center">
|
||||||
|
<p className="font-mono text-sm font-medium text-fd-primary">开源 · 本地运行 · 自由接入</p>
|
||||||
|
<h1 className="mt-5 text-4xl font-bold tracking-tight sm:text-6xl">
|
||||||
|
{appDescription}
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-fd-muted-foreground">
|
||||||
|
在本机运行自己的模型网关,接入 OpenAI、Anthropic 等兼容服务,继续使用 Cursor Agent 的工具调用、Skills 和 MCP。
|
||||||
|
</p>
|
||||||
|
<div className="mt-10 flex flex-wrap justify-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/docs"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-fd-primary px-5 py-3 font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
阅读文档
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={releaseUrl}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
下载最新版
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DesktopDemo />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-b px-6 py-16 sm:py-20">
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<div className="flex items-end justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm font-medium text-fd-primary">DOCUMENTATION</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-bold tracking-tight">文档</h2>
|
||||||
|
</div>
|
||||||
|
<Link href="/docs" className="hidden items-center gap-2 text-sm font-medium sm:flex">
|
||||||
|
查看全部
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||||
|
{docs.map(({ icon: Icon, title, description, href }) => (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className="group rounded-xl border bg-fd-card p-6 transition-colors hover:bg-fd-accent"
|
||||||
|
>
|
||||||
|
<Icon className="mb-5 size-5 text-fd-primary" />
|
||||||
|
<h3 className="flex items-center justify-between font-semibold">
|
||||||
|
{title}
|
||||||
|
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-fd-muted-foreground">{description}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="px-6 py-16 sm:py-20">
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<div className="flex items-end justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm font-medium text-fd-primary">DEVELOPER BLOG</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-bold tracking-tight">开发者博客</h2>
|
||||||
|
</div>
|
||||||
|
<Link href="/blog" className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
查看全部
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 divide-y border-y">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<Link
|
||||||
|
key={post.url}
|
||||||
|
href={post.url}
|
||||||
|
className="group grid gap-3 py-6 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
|
||||||
|
>
|
||||||
|
<time className="text-sm text-fd-muted-foreground">{formatBlogDate(post.path)}</time>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{post.data.title}</h3>
|
||||||
|
<p className="mt-1 text-sm text-fd-muted-foreground">{post.data.description}</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { source } from '@/lib/source';
|
||||||
|
import { createFromSource } from 'fumadocs-core/search/server';
|
||||||
|
|
||||||
|
export const { GET } = createFromSource(source);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { getPageImageUrl, getPageMarkdownUrl, source } from '@/lib/source';
|
||||||
|
import {
|
||||||
|
DocsBody,
|
||||||
|
DocsDescription,
|
||||||
|
DocsPage,
|
||||||
|
DocsTitle,
|
||||||
|
MarkdownCopyButton,
|
||||||
|
ViewOptionsPopover,
|
||||||
|
} from 'fumadocs-ui/layouts/docs/page';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getMDXComponents } from '@/components/mdx';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||||
|
import { gitConfig } from '@/lib/shared';
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
|
||||||
|
const params = await props.params;
|
||||||
|
const page = source.getPage(params.slug);
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
const MDX = page.data.body;
|
||||||
|
const markdownUrl = getPageMarkdownUrl(page).url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||||
|
<DocsTitle>{page.data.title}</DocsTitle>
|
||||||
|
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
|
||||||
|
<div className="flex flex-row gap-2 items-center border-b pb-6">
|
||||||
|
<MarkdownCopyButton markdownUrl={markdownUrl} />
|
||||||
|
<ViewOptionsPopover
|
||||||
|
markdownUrl={markdownUrl}
|
||||||
|
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DocsBody>
|
||||||
|
<MDX
|
||||||
|
components={getMDXComponents({
|
||||||
|
// this allows you to link to other pages with relative file paths
|
||||||
|
a: createRelativeLink(source, page),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</DocsBody>
|
||||||
|
</DocsPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return source.generateParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const page = source.getPage(params.slug);
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: page.data.title,
|
||||||
|
description: page.data.description,
|
||||||
|
openGraph: {
|
||||||
|
images: getPageImageUrl(page).url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { source } from '@/lib/source';
|
||||||
|
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||||
|
import { baseOptions } from '@/lib/layout.shared';
|
||||||
|
|
||||||
|
export default function Layout({ children }: LayoutProps<'/docs'>) {
|
||||||
|
return (
|
||||||
|
<DocsLayout tree={source.getPageTree()} {...baseOptions()}>
|
||||||
|
{children}
|
||||||
|
</DocsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'fumadocs-ui/css/neutral.css';
|
||||||
|
@import 'fumadocs-ui/css/preset.css';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
html > body[data-scroll-locked] {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
--removed-body-scroll-bar-size: 0px !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||||
|
import { i18nProvider } from 'fumadocs-ui/i18n';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { translations } from '@/lib/layout.shared';
|
||||||
|
import './global.css';
|
||||||
|
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.leokun.cn';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(siteUrl),
|
||||||
|
title: {
|
||||||
|
default: 'cursor-byok 文档',
|
||||||
|
template: '%s | cursor-byok',
|
||||||
|
},
|
||||||
|
description: 'cursor-byok 的安装、模型配置与故障排查指南。',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
|
<body className="flex min-h-screen flex-col">
|
||||||
|
<RootProvider i18n={i18nProvider(translations)}>{children}</RootProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getLLMText, source } from '@/lib/source';
|
||||||
|
|
||||||
|
export const revalidate = false;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const scan = source.getPages().map(getLLMText);
|
||||||
|
const scanned = await Promise.all(scan);
|
||||||
|
|
||||||
|
return new Response(scanned.join('\n\n'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
export const revalidate = false;
|
||||||
|
|
||||||
|
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const page = source.getPage(slug?.slice(0, -1));
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
return new Response(await getLLMText(page), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/markdown',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return source.getPages().map((page) => ({
|
||||||
|
lang: page.locale,
|
||||||
|
slug: getPageMarkdownUrl(page).segments,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { source } from '@/lib/source';
|
||||||
|
import { llms } from 'fumadocs-core/source';
|
||||||
|
|
||||||
|
export const revalidate = false;
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return new Response(llms(source).index());
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { getPageImageUrl, source } from '@/lib/source';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
import { generate as DefaultImage } from 'fumadocs-ui/og';
|
||||||
|
import { appName } from '@/lib/shared';
|
||||||
|
|
||||||
|
export const revalidate = false;
|
||||||
|
|
||||||
|
export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const page = source.getPage(slug.slice(0, -1));
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<DefaultImage title={page.data.title} description={page.data.description} site={appName} />,
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return source.getPages().map((page) => ({
|
||||||
|
lang: page.locale,
|
||||||
|
slug: getPageImageUrl(page).segments,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
.stage {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 64px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 960 / 620;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid rgb(255 255 255 / 10%);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow:
|
||||||
|
0 42px 90px -34px rgb(0 0 0 / 50%),
|
||||||
|
0 12px 32px -16px rgb(0 0 0 / 42%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #141414;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage p {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
color: var(--color-fd-muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.stage {
|
||||||
|
margin-top: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import styles from './DesktopDemo.module.css';
|
||||||
|
|
||||||
|
export function DesktopDemo() {
|
||||||
|
return (
|
||||||
|
<div className={styles.stage}>
|
||||||
|
<div className={styles.viewport}>
|
||||||
|
<iframe
|
||||||
|
title="Cursor BYOK 真实产品界面演示"
|
||||||
|
src="/product-demo/demo/index.html"
|
||||||
|
className={styles.demo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p>真实桌面端组件 · 使用隔离的 Mock 数据,可直接操作菜单、筛选和设置</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||||
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
|
import type { MDXComponents } from 'mdx/types';
|
||||||
|
|
||||||
|
export function getMDXComponents(components?: MDXComponents) {
|
||||||
|
return {
|
||||||
|
...defaultMdxComponents,
|
||||||
|
Step,
|
||||||
|
Steps,
|
||||||
|
...components,
|
||||||
|
} satisfies MDXComponents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMDXComponents = getMDXComponents;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: 为什么我们建立新的文档站
|
||||||
|
description: 将用户指南和开发过程放回代码仓库,让文档与产品一起演进。
|
||||||
|
---
|
||||||
|
|
||||||
|
cursor-byok 的功能已经从单一模型转发扩展到多协议模型配置、工具调用、会话观测和跨平台桌面应用。散落在发布说明与讨论区中的信息,已经不足以支持第一次使用产品的人,也不利于开发者理解系统边界。
|
||||||
|
|
||||||
|
## 文档也是产品的一部分
|
||||||
|
|
||||||
|
新的文档站与应用代码放在同一个仓库中:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/
|
||||||
|
├── desktop/ # 桌面应用
|
||||||
|
└── docs/ # 文档站
|
||||||
|
├── content/docs/
|
||||||
|
└── content/blog/
|
||||||
|
```
|
||||||
|
|
||||||
|
用户文档负责回答“如何使用”,开发者博客负责记录“为什么这样设计”。两类内容分开维护,但使用同一套构建和审查流程。
|
||||||
|
|
||||||
|
## 为什么选择 Fumadocs
|
||||||
|
|
||||||
|
Fumadocs 提供了文档布局、全文搜索、代码高亮、目录和 MDX 内容层,我们只需要维护产品信息与视觉样式,不必重新实现通用文档能力。
|
||||||
|
|
||||||
|
文档应用保持独立,桌面端不会引入 Next.js 或 Fumadocs 依赖。开发、构建和部署也可以分别进行。
|
||||||
|
|
||||||
|
## 接下来会记录什么
|
||||||
|
|
||||||
|
开发者博客将持续记录:
|
||||||
|
|
||||||
|
- Cursor 协议适配与模型兼容性设计。
|
||||||
|
- Agent 工具调用和多轮会话的实现取舍。
|
||||||
|
- 本地存储、可观测性与性能优化。
|
||||||
|
- 桌面端跨平台开发和发布过程。
|
||||||
|
|
||||||
|
这些文章以当前代码为准,不为已经删除的旧实现保留兼容说明。
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
title: 快速开始
|
||||||
|
description: 安装 cursor-byok,并让 Cursor 使用你自己的模型 API。
|
||||||
|
icon: Rocket
|
||||||
|
---
|
||||||
|
|
||||||
|
cursor-byok 是运行在本机的 Cursor 模型网关。它接收 Cursor Agent 请求,将请求转换后发送到你配置的 OpenAI 或 Anthropic 兼容服务。
|
||||||
|
|
||||||
|
<Callout type="warn" title="使用前须知">
|
||||||
|
cursor-byok 是独立开源项目,与 Cursor 及其开发者没有关联。软件本身免费,但模型服务商可能按用量收费。
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## 三步开始使用
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### 下载并启动
|
||||||
|
|
||||||
|
从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载与你的操作系统对应的最新版本,然后启动 cursor-byok。
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### 初始化并配置模型
|
||||||
|
|
||||||
|
打开 **Cursor 配置**,按界面提示初始化本地 CA,然后添加模型。填写服务地址、API Key 和模型名称,并运行连通性测试。
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### 在 Cursor 中使用
|
||||||
|
|
||||||
|
保持 cursor-byok 运行,打开 Cursor,在模型列表中选择刚刚配置的模型,然后开始使用 Agent。
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## 接下来
|
||||||
|
|
||||||
|
<Cards>
|
||||||
|
<Card title="安装指南" description="下载、初始化和首次运行。" href="/docs/installation" />
|
||||||
|
<Card title="模型配置" description="选择协议并填写上游模型参数。" href="/docs/model-configuration" />
|
||||||
|
<Card title="故障排查" description="处理证书、连接与模型测试问题。" href="/docs/troubleshooting" />
|
||||||
|
<Card title="查看源码" description="了解实现或参与项目开发。" href="https://github.com/leookun/cursor-byok" external />
|
||||||
|
</Cards>
|
||||||
|
|
||||||
|
## 数据如何流转
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cursor 客户端
|
||||||
|
│ Agent 请求与工具结果
|
||||||
|
▼
|
||||||
|
cursor-byok 本地服务
|
||||||
|
│ OpenAI / Anthropic 兼容请求
|
||||||
|
▼
|
||||||
|
你配置的模型 API
|
||||||
|
```
|
||||||
|
|
||||||
|
API Key、模型配置和应用设置保存在本机。模型请求仍会发送到你选择的上游服务商。
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
title: 安装指南
|
||||||
|
description: 下载 cursor-byok,完成本地初始化并验证运行状态。
|
||||||
|
icon: Download
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载应用
|
||||||
|
|
||||||
|
前往 [最新版本页面](https://github.com/leookun/cursor-byok/releases/latest),下载适用于 macOS、Windows 或 Linux 的安装包。
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
优先使用最新正式版本。发行页面会列出该版本包含的安装包和更新说明。
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## 首次启动
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### 打开 Cursor 配置
|
||||||
|
|
||||||
|
启动应用后进入 **Cursor 配置**。如果本地 CA 尚未初始化,页面会显示初始化入口。
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### 初始化本地 CA
|
||||||
|
|
||||||
|
点击 **初始化 CA**。本地 CA 用于在你的设备上解析 Cursor 发出的 HTTPS 请求,其文件只保存在本机。
|
||||||
|
|
||||||
|
系统要求授权时,按照应用显示的说明在终端中完成信任操作,然后返回应用点击 **我已初始化,刷新**。
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### 添加第一个模型
|
||||||
|
|
||||||
|
点击 **添加模型**,选择 OpenAI 或 Anthropic 类型,填写上游服务参数并保存。详细字段说明见[模型配置](./model-configuration.mdx)。
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### 运行连通性测试
|
||||||
|
|
||||||
|
点击模型的 **测试**。测试成功后,该模型即可出现在 Cursor 的模型列表中。
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## 验证安装
|
||||||
|
|
||||||
|
完成配置后,请确认:
|
||||||
|
|
||||||
|
- Cursor 配置页面不再显示 CA 初始化提示。
|
||||||
|
- 至少一个模型的连通性测试成功。
|
||||||
|
- cursor-byok 保持运行。
|
||||||
|
- Cursor 模型列表中能看到配置的显示名称。
|
||||||
|
|
||||||
|
如果其中任何一步失败,请前往[故障排查](./troubleshooting.mdx)。
|
||||||
|
|
||||||
|
## 更新
|
||||||
|
|
||||||
|
在应用设置中检查新版本,或直接访问 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest)。更新前无需删除现有模型配置。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"title": "使用指南",
|
||||||
|
"root": true,
|
||||||
|
"pages": ["index", "installation", "model-configuration", "troubleshooting"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
title: 模型配置
|
||||||
|
description: 配置模型协议、服务地址、凭据与生成参数。
|
||||||
|
icon: Settings2
|
||||||
|
---
|
||||||
|
|
||||||
|
每个模型配置都是一个独立的上游通道,可以使用不同服务商、协议、凭据和生成参数。
|
||||||
|
|
||||||
|
## 必填字段
|
||||||
|
|
||||||
|
### 模型类型
|
||||||
|
|
||||||
|
选择上游接口格式:
|
||||||
|
|
||||||
|
- **OpenAI**:支持 Responses API 和 Chat Completions API。
|
||||||
|
- **Anthropic**:支持 Messages API 兼容服务。
|
||||||
|
|
||||||
|
### 请求协议
|
||||||
|
|
||||||
|
OpenAI 类型需要继续选择 **Responses API** 或 **Chat Completions API**。该选项决定请求和响应格式,不会单独改变你填写的服务地址。
|
||||||
|
|
||||||
|
### 服务器地址
|
||||||
|
|
||||||
|
可以填写服务商的基础地址,也可以选择使用完整请求 URL:
|
||||||
|
|
||||||
|
- 使用基础地址时,cursor-byok 会根据协议追加标准端点路径。
|
||||||
|
- 使用完整请求 URL 时,cursor-byok 会原样使用该地址。
|
||||||
|
|
||||||
|
优先使用界面中的常用服务商预设,减少协议与端点不匹配的情况。
|
||||||
|
|
||||||
|
### API Key
|
||||||
|
|
||||||
|
填写上游服务要求的访问密钥。密钥保存在本机,用于发送模型请求。
|
||||||
|
|
||||||
|
### 模型名称
|
||||||
|
|
||||||
|
填写服务商接口接受的模型标识。你也可以点击 **获取模型** 读取接口返回的模型列表。
|
||||||
|
|
||||||
|
## 展示信息
|
||||||
|
|
||||||
|
- **显示名称**:Cursor 模型列表中看到的名称,不会改变发送给上游的模型标识。
|
||||||
|
- **备注**:显示在 Cursor 的模型说明中。
|
||||||
|
|
||||||
|
## 可选参数
|
||||||
|
|
||||||
|
按模型能力填写以下字段;留空时使用应用或上游的默认值:
|
||||||
|
|
||||||
|
- 上下文窗口 Token
|
||||||
|
- 最大输出 Token
|
||||||
|
- 推理强度或思考强度
|
||||||
|
- 自定义 Headers
|
||||||
|
- OpenAI 或 Anthropic 额外参数
|
||||||
|
|
||||||
|
<Callout type="warn" title="额外参数格式">
|
||||||
|
自定义 Headers 和额外参数必须是 JSON 对象。额外参数会直接影响上游请求,只添加服务商明确支持的字段。
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## 测试配置
|
||||||
|
|
||||||
|
保存后运行 **测试**。结果会显示首字延迟、生成速度、总耗时和模型输出,便于确认:
|
||||||
|
|
||||||
|
1. 地址和协议是否匹配。
|
||||||
|
2. API Key 是否有效。
|
||||||
|
3. 模型标识是否存在且可访问。
|
||||||
|
4. 上游是否能正常返回流式内容。
|
||||||
|
|
||||||
|
测试通过后再前往 Cursor 使用该模型。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
title: 故障排查
|
||||||
|
description: 处理本地 CA、管理服务、模型连接和 Cursor 模型列表问题。
|
||||||
|
icon: Wrench
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需要先初始化本地 CA
|
||||||
|
|
||||||
|
进入 **Cursor 配置**,点击 **初始化 CA**。本地 CA 只保存在本机,用于解析 Cursor 发出的 HTTPS 请求。
|
||||||
|
|
||||||
|
## 需要在系统中信任本地 CA
|
||||||
|
|
||||||
|
点击 **打开终端安装 CA**,按照终端提示完成系统授权。完成后返回应用,点击 **我已初始化,刷新**。
|
||||||
|
|
||||||
|
如果状态没有更新,请完全退出后重新启动 cursor-byok,再次打开 Cursor 配置页面。
|
||||||
|
|
||||||
|
## 无法连接本地管理服务
|
||||||
|
|
||||||
|
1. 完全退出并重新启动 cursor-byok。
|
||||||
|
2. 检查是否有安全软件拦截本地回环连接。
|
||||||
|
3. 如果修改过管理服务端口,将端口恢复为 `0`,让应用在启动时自动选择可用端口。
|
||||||
|
4. 再次启动应用并刷新页面。
|
||||||
|
|
||||||
|
## 模型连通性测试失败
|
||||||
|
|
||||||
|
按测试错误逐项检查:
|
||||||
|
|
||||||
|
- **认证错误**:确认 API Key 有效,且账户有权访问目标模型。
|
||||||
|
- **未找到接口**:确认模型类型、请求协议与服务器地址匹配。
|
||||||
|
- **模型不存在**:使用 **获取模型** 检查上游返回的模型标识。
|
||||||
|
- **参数错误**:暂时关闭自定义 Headers 和额外参数,再重新测试。
|
||||||
|
- **连接超时**:检查网络、系统代理和上游服务状态。
|
||||||
|
|
||||||
|
## Cursor 中看不到模型
|
||||||
|
|
||||||
|
确认以下条件全部满足:
|
||||||
|
|
||||||
|
1. 本地 CA 状态正常。
|
||||||
|
2. 已保存至少一个模型配置。
|
||||||
|
3. 模型连通性测试成功。
|
||||||
|
4. cursor-byok 正在运行。
|
||||||
|
5. 重启 Cursor 后重新打开模型列表。
|
||||||
|
|
||||||
|
## 请求失败但测试成功
|
||||||
|
|
||||||
|
模型测试只验证基础连接。Agent 请求还会包含更长上下文、工具定义和流式响应。请检查:
|
||||||
|
|
||||||
|
- 上游模型是否支持工具调用。
|
||||||
|
- 上下文窗口和最大输出 Token 是否符合模型限制。
|
||||||
|
- 自定义参数是否与实际协议兼容。
|
||||||
|
- 调用详情中的上游状态码和响应内容。
|
||||||
|
|
||||||
|
## 继续反馈
|
||||||
|
|
||||||
|
如果问题仍然存在,请在 [GitHub Issues](https://github.com/leookun/cursor-byok/issues) 提交问题,并附上:
|
||||||
|
|
||||||
|
- 操作系统与 cursor-byok 版本。
|
||||||
|
- 选择的模型类型和请求协议。
|
||||||
|
- 已脱敏的服务地址与错误信息。
|
||||||
|
- 复现步骤。
|
||||||
|
|
||||||
|
请勿公开 API Key 或其他凭据。
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
globalIgnores([
|
||||||
|
'.next/**',
|
||||||
|
'out/**',
|
||||||
|
'build/**',
|
||||||
|
'next-env.d.ts',
|
||||||
|
'.source/**',
|
||||||
|
'public/product-demo/**',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { loader } from 'fumadocs-core/source';
|
||||||
|
import { pageSchema } from 'fumadocs-core/source/schema';
|
||||||
|
import { defineCollections } from 'fumadocs-mdx/macro';
|
||||||
|
|
||||||
|
const blog = defineCollections({
|
||||||
|
type: 'doc',
|
||||||
|
dir: 'content/blog',
|
||||||
|
schema: pageSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const blogSource = loader({
|
||||||
|
baseUrl: '/blog',
|
||||||
|
source: blog.toFumadocsSource(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getBlogDate(path: string): Date {
|
||||||
|
const fileName = path.split('/').at(-1) ?? '';
|
||||||
|
const match = /^(\d{4}-\d{2}-\d{2})/.exec(fileName);
|
||||||
|
return match ? new Date(`${match[1]}T00:00:00Z`) : new Date(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBlogDate(path: string): string {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
}).format(getBlogDate(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortBlogPages<T extends { path: string }>(pages: T[]): T[] {
|
||||||
|
return [...pages].sort((left, right) => {
|
||||||
|
return getBlogDate(right.path).getTime() - getBlogDate(left.path).getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { cn } from 'cnfast';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { zhCN } from '@fumadocs/language/zh-cn';
|
||||||
|
import { defineTranslations } from 'fumadocs-core/i18n';
|
||||||
|
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||||
|
import { uiTranslations } from 'fumadocs-ui/i18n';
|
||||||
|
import { appName, gitConfig, releaseUrl } from './shared';
|
||||||
|
|
||||||
|
export const translations = defineTranslations().extend(uiTranslations()).preset(zhCN());
|
||||||
|
|
||||||
|
export function baseOptions(): BaseLayoutProps {
|
||||||
|
return {
|
||||||
|
nav: {
|
||||||
|
title: appName,
|
||||||
|
},
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
text: '文档',
|
||||||
|
url: '/docs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '开发者博客',
|
||||||
|
url: '/blog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '下载',
|
||||||
|
url: releaseUrl,
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
githubUrl: `https://github.com/${gitConfig.user}/${gitConfig.repo}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const appName = 'cursor-byok';
|
||||||
|
export const appDescription = 'Cursor 服务端的开源替代';
|
||||||
|
export const docsRoute = '/docs';
|
||||||
|
export const docsImageRoute = '/og/docs';
|
||||||
|
export const docsContentRoute = '/llms.mdx/docs';
|
||||||
|
export const repositoryUrl = 'https://github.com/leookun/cursor-byok';
|
||||||
|
export const releaseUrl = `${repositoryUrl}/releases/latest`;
|
||||||
|
|
||||||
|
export const gitConfig = {
|
||||||
|
user: 'leookun',
|
||||||
|
repo: 'cursor-byok',
|
||||||
|
branch: 'main',
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { loader } from 'fumadocs-core/source';
|
||||||
|
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||||
|
import { docsContentRoute, docsImageRoute, docsRoute } from './shared';
|
||||||
|
import { defineDocs } from 'fumadocs-mdx/macro';
|
||||||
|
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
|
||||||
|
|
||||||
|
const docs = defineDocs({
|
||||||
|
dir: 'content/docs',
|
||||||
|
docs: {
|
||||||
|
schema: pageSchema,
|
||||||
|
postprocess: {
|
||||||
|
includeProcessedMarkdown: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
schema: metaSchema,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||||
|
export const source = loader({
|
||||||
|
baseUrl: docsRoute,
|
||||||
|
source: docs.toFumadocsSource(),
|
||||||
|
plugins: [lucideIconsPlugin()],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getPageImageUrl(page: (typeof source)['$inferPage']) {
|
||||||
|
const segments = [...page.slugs, 'image.png'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
segments,
|
||||||
|
url: '/' + [page.locale, ...docsImageRoute.split('/'), ...segments].filter(Boolean).join('/'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) {
|
||||||
|
const segments = [...page.slugs, 'content.md'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
segments,
|
||||||
|
url: '/' + [page.locale, ...docsContentRoute.split('/'), ...segments].filter(Boolean).join('/'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLLMText(page: (typeof source)['$inferPage']) {
|
||||||
|
const processed = await page.data.getText('processed');
|
||||||
|
|
||||||
|
return `# ${page.data.title} (${page.url})
|
||||||
|
|
||||||
|
${processed}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createMDX } from 'fumadocs-mdx/next';
|
||||||
|
|
||||||
|
const withMDX = createMDX();
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const config = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withMDX(config);
|
||||||
Generated
+10518
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "cursor-byok-docs",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build:product-demo": "node scripts/build-product-demo.mjs",
|
||||||
|
"prebuild": "npm run build:product-demo",
|
||||||
|
"build": "next build",
|
||||||
|
"predev": "npm run build:product-demo",
|
||||||
|
"dev": "next dev",
|
||||||
|
"start": "next start",
|
||||||
|
"types:check": "next typegen && tsc --noEmit",
|
||||||
|
"lint": "eslint",
|
||||||
|
"check": "npm run types:check && npm run lint && npm run build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fumadocs/language": "^0.2.4",
|
||||||
|
"cnfast": "^0.1.0",
|
||||||
|
"fumadocs-core": "16.15.2",
|
||||||
|
"fumadocs-mdx": "15.3.1",
|
||||||
|
"fumadocs-ui": "npm:@fumadocs/base-ui@16.15.2",
|
||||||
|
"lucide-react": "^1.34.0",
|
||||||
|
"next": "16.3.2",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/mdx": "^2.0.14",
|
||||||
|
"@types/node": "^26.3.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.5",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-config-next": "16.3.2",
|
||||||
|
"postcss": "^8.5.26",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isMarkdownPreferred, rewritePath } from 'fumadocs-core/negotiation';
|
||||||
|
import { docsContentRoute, docsRoute } from '@/lib/shared';
|
||||||
|
|
||||||
|
const { rewrite: rewriteDocs } = rewritePath(
|
||||||
|
`${docsRoute}{/*path}`,
|
||||||
|
`${docsContentRoute}{/*path}/content.md`,
|
||||||
|
);
|
||||||
|
const { rewrite: rewriteSuffix } = rewritePath(
|
||||||
|
`${docsRoute}{/*path}.md`,
|
||||||
|
`${docsContentRoute}{/*path}/content.md`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function proxy(request: NextRequest) {
|
||||||
|
const result = rewriteSuffix(request.nextUrl.pathname);
|
||||||
|
if (result) {
|
||||||
|
return NextResponse.rewrite(new URL(result, request.nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMarkdownPreferred(request)) {
|
||||||
|
const result = rewriteDocs(request.nextUrl.pathname);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return NextResponse.rewrite(new URL(result, request.nextUrl), {
|
||||||
|
// this URL has two representations, selected by `Accept`
|
||||||
|
headers: { Vary: 'Accept' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const docsRoot = path.dirname(fileURLToPath(new URL('../package.json', import.meta.url)));
|
||||||
|
const desktopRoot = path.resolve(docsRoot, '../desktop');
|
||||||
|
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||||
|
const vite = path.join(desktopRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'vite.cmd' : 'vite');
|
||||||
|
|
||||||
|
if (!existsSync(vite)) run(['ci'], desktopRoot);
|
||||||
|
run(['run', 'build:demo'], desktopRoot);
|
||||||
|
|
||||||
|
function run(args, cwd) {
|
||||||
|
const result = spawnSync(npm, args, { cwd, stdio: 'inherit' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"tools": [
|
"tools": [
|
||||||
"Shell", "Grep", "Delete", "WebSearch", "WebFetch", "GenerateImage",
|
"Shell", "Grep", "Delete", "WebSearch", "WebFetch", "GenerateImage",
|
||||||
"EditNotebook", "TodoWrite", "StrReplace", "Write", "Read", "ReadLints",
|
"EditNotebook", "TodoWrite", "StrReplace", "Write", "Read", "ReadLints",
|
||||||
"Glob", "AskQuestion", "Task", "AwaitShell", "GetMcpTools",
|
"Glob", "AskQuestion", "Task", "GetMcpTools",
|
||||||
"FetchMcpResource", "SwitchMode", "CallMcpTool", "SembleSearch",
|
"FetchMcpResource", "SwitchMode", "CallMcpTool", "SembleSearch",
|
||||||
"SembleFindRelated"
|
"SembleFindRelated"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"tools": [
|
"tools": [
|
||||||
"Shell", "Grep", "Delete", "WebSearch", "WebFetch", "GenerateImage",
|
"Shell", "Grep", "Delete", "WebSearch", "WebFetch", "GenerateImage",
|
||||||
"ReadLints", "EditNotebook", "TodoWrite", "StrReplace", "Write", "Read",
|
"ReadLints", "EditNotebook", "TodoWrite", "StrReplace", "Write", "Read",
|
||||||
"Glob", "AwaitShell", "GetMcpTools", "FetchMcpResource", "SwitchMode",
|
"Glob", "GetMcpTools", "FetchMcpResource", "SwitchMode",
|
||||||
"UpdateCurrentStep", "CallMcpTool", "SembleSearch", "SembleFindRelated"
|
"UpdateCurrentStep", "CallMcpTool", "SembleSearch", "SembleFindRelated"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+4
-1
@@ -35,7 +35,10 @@ impl App {
|
|||||||
.set_port(store.port_settings().await?.service_port);
|
.set_port(store.port_settings().await?.service_port);
|
||||||
}
|
}
|
||||||
let assets = PromptAssets::embedded()?;
|
let assets = PromptAssets::embedded()?;
|
||||||
let compiler = PromptCompiler::new(assets);
|
let compiler = PromptCompiler::with_compaction_prompt_path(
|
||||||
|
assets,
|
||||||
|
crate::config::compaction_prompt_path()?,
|
||||||
|
);
|
||||||
let provider = std::sync::Arc::new(ProviderRouter::new(
|
let provider = std::sync::Arc::new(ProviderRouter::new(
|
||||||
store.clone(),
|
store.clone(),
|
||||||
config.provider_request_timeout,
|
config.provider_request_timeout,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub struct MessageInsertion {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ClientCommand {
|
pub enum ClientCommand {
|
||||||
ToolResult(ToolResult),
|
ToolResult(ToolResult),
|
||||||
RuntimeMessage(CanonicalMessage),
|
InterruptWithMessage(CanonicalMessage),
|
||||||
RuntimeEvent(RuntimeEvent),
|
RuntimeEvent(RuntimeEvent),
|
||||||
InsertMessages(MessageInsertion),
|
InsertMessages(MessageInsertion),
|
||||||
ClientClosed { error: String },
|
ClientClosed { error: String },
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::run::RunOutcome;
|
|||||||
pub enum CommitCause {
|
pub enum CommitCause {
|
||||||
InitialMessages,
|
InitialMessages,
|
||||||
ToolRoundStarted(ToolRoundId),
|
ToolRoundStarted(ToolRoundId),
|
||||||
ToolResult { call_id: String },
|
ToolResult { call_id: String, interrupted: bool },
|
||||||
FinalTurn,
|
FinalTurn,
|
||||||
Compaction { summary: String },
|
Compaction { summary: String },
|
||||||
RuntimeEvent { event_id: String },
|
RuntimeEvent { event_id: String },
|
||||||
|
|||||||
+39
-2
@@ -9,6 +9,8 @@ const DATA_DIR_NAME: &str = ".cursor-byok-v3";
|
|||||||
const DATABASE_FILE_NAME: &str = "cursor-byok.db";
|
const DATABASE_FILE_NAME: &str = "cursor-byok.db";
|
||||||
const V0049_DATA_DIR_NAME: &str = ".cursor-local-assistant-v2";
|
const V0049_DATA_DIR_NAME: &str = ".cursor-local-assistant-v2";
|
||||||
const V0049_CONFIG_FILE_NAME: &str = "config.yaml";
|
const V0049_CONFIG_FILE_NAME: &str = "config.yaml";
|
||||||
|
const COMPACTION_PROMPT_PATH: &str = "prompts/compaction.md";
|
||||||
|
const DEFAULT_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(3000);
|
||||||
|
|
||||||
pub fn managed_data_dir() -> Result<PathBuf> {
|
pub fn managed_data_dir() -> Result<PathBuf> {
|
||||||
let home_dir = dirs::home_dir()
|
let home_dir = dirs::home_dir()
|
||||||
@@ -28,6 +30,26 @@ pub fn v0049_config_path() -> Result<PathBuf> {
|
|||||||
.join(V0049_CONFIG_FILE_NAME))
|
.join(V0049_CONFIG_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn compaction_prompt_path() -> Result<PathBuf> {
|
||||||
|
Ok(managed_data_dir()?.join(COMPACTION_PROMPT_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compaction_prompt_override() -> Result<Option<String>> {
|
||||||
|
compaction_prompt_override_at(&compaction_prompt_path()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn compaction_prompt_override_at(path: &std::path::Path) -> Result<Option<String>> {
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(prompt) if prompt.trim().is_empty() => Ok(None),
|
||||||
|
Ok(prompt) => Ok(Some(prompt)),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(error) => Err(Error::Config(format!(
|
||||||
|
"cannot read compaction prompt at {}: {error}",
|
||||||
|
path.display()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum ProviderKind {
|
pub enum ProviderKind {
|
||||||
OpenAiChat,
|
OpenAiChat,
|
||||||
@@ -70,7 +92,7 @@ impl Config {
|
|||||||
Ok(value) => Duration::from_secs(value.parse().map_err(|error| {
|
Ok(value) => Duration::from_secs(value.parse().map_err(|error| {
|
||||||
Error::Config(format!("invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"))
|
Error::Config(format!("invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"))
|
||||||
})?),
|
})?),
|
||||||
Err(env::VarError::NotPresent) => Duration::from_secs(300),
|
Err(env::VarError::NotPresent) => DEFAULT_PROVIDER_REQUEST_TIMEOUT,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return Err(Error::Config(format!(
|
return Err(Error::Config(format!(
|
||||||
"invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"
|
"invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"
|
||||||
@@ -111,7 +133,7 @@ impl Config {
|
|||||||
.parse()
|
.parse()
|
||||||
.expect("desktop listen address is static"),
|
.expect("desktop listen address is static"),
|
||||||
database_url: default_database_url()?,
|
database_url: default_database_url()?,
|
||||||
provider_request_timeout: Duration::from_secs(300),
|
provider_request_timeout: DEFAULT_PROVIDER_REQUEST_TIMEOUT,
|
||||||
console: None,
|
console: None,
|
||||||
use_persisted_ports: true,
|
use_persisted_ports: true,
|
||||||
})
|
})
|
||||||
@@ -156,6 +178,21 @@ fn database_url_for_dir(data_dir: &std::path::Path) -> Result<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compaction_prompt_override_is_optional_and_reloaded() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let path = directory.path().join("compaction.md");
|
||||||
|
|
||||||
|
assert_eq!(compaction_prompt_override_at(&path).unwrap(), None);
|
||||||
|
fs::write(&path, " \n").unwrap();
|
||||||
|
assert_eq!(compaction_prompt_override_at(&path).unwrap(), None);
|
||||||
|
fs::write(&path, "custom prompt").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
compaction_prompt_override_at(&path).unwrap().as_deref(),
|
||||||
|
Some("custom prompt")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn managed_database_supports_home_paths_with_spaces() {
|
async fn managed_database_supports_home_paths_with_spaces() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -285,6 +285,10 @@ impl CursorActor {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if tool_runtime.is_interrupted(throw.id).await {
|
||||||
|
tool_runtime.discard_exec(throw.id).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match tool_runtime.take_exec(throw.id).await {
|
match tool_runtime.take_exec(throw.id).await {
|
||||||
Some(pending) => results_tx.send_error(
|
Some(pending) => results_tx.send_error(
|
||||||
crate::Error::Protocol(format!(
|
crate::Error::Protocol(format!(
|
||||||
|
|||||||
@@ -152,6 +152,19 @@ pub fn context_injection_queued(injection_id: String) -> pb::AgentServerMessage
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn context_injection_rejected(injection_id: String, reason: String) -> pb::AgentServerMessage {
|
||||||
|
server_interaction(pb::interaction_update::Message::ContextInjectionState(
|
||||||
|
pb::ContextInjectionStateUpdate {
|
||||||
|
injection_id,
|
||||||
|
state: Some(pb::ContextInjectionState {
|
||||||
|
state: Some(pb::context_injection_state::State::Rejected(
|
||||||
|
pb::ContextInjectionRejected { reason },
|
||||||
|
)),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn context_injection_delivered(
|
pub fn context_injection_delivered(
|
||||||
injection_id: String,
|
injection_id: String,
|
||||||
delivery_batch_id: String,
|
delivery_batch_id: String,
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ pub fn tool_placeholder(name: &str, call_id: &str) -> Result<pb::ToolCall> {
|
|||||||
"updatecurrentstep" => {
|
"updatecurrentstep" => {
|
||||||
Tool::CommunicateUpdateToolCall(pb::CommunicateUpdateToolCall::default())
|
Tool::CommunicateUpdateToolCall(pb::CommunicateUpdateToolCall::default())
|
||||||
}
|
}
|
||||||
"awaitshell" => Tool::AwaitToolCall(pb::AwaitToolCall::default()),
|
|
||||||
"getmcptools" => Tool::GetMcpToolsToolCall(pb::GetMcpToolsToolCall::default()),
|
"getmcptools" => Tool::GetMcpToolsToolCall(pb::GetMcpToolsToolCall::default()),
|
||||||
_ => return Err(Error::Protocol(format!("unsupported tool: {name}"))),
|
_ => return Err(Error::Protocol(format!("unsupported tool: {name}"))),
|
||||||
};
|
};
|
||||||
@@ -457,17 +456,6 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
|||||||
chars: string("chars"),
|
chars: string("chars"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Some(pb::tool_call::Tool::AwaitToolCall(tool)) => {
|
|
||||||
tool.args = Some(pb::AwaitArgs {
|
|
||||||
task_id: string("shell_id"),
|
|
||||||
block_until_ms: call
|
|
||||||
.arguments
|
|
||||||
.get("block_until_ms")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|v| v as u32),
|
|
||||||
regex: optional("pattern"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Some(pb::tool_call::Tool::GetMcpToolsToolCall(tool)) => {
|
Some(pb::tool_call::Tool::GetMcpToolsToolCall(tool)) => {
|
||||||
tool.args = Some(pb::GetMcpToolsArgs {
|
tool.args = Some(pb::GetMcpToolsArgs {
|
||||||
server: optional("server"),
|
server: optional("server"),
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ fn tool_identifier(name: &str, dynamic_tools: &HashSet<String>) -> String {
|
|||||||
return name.into();
|
return name.into();
|
||||||
}
|
}
|
||||||
match name {
|
match name {
|
||||||
"AwaitShell" => "AWAIT".into(),
|
|
||||||
"CallMcpTool" | "SembleSearch" | "SembleFindRelated" => "MCP".into(),
|
"CallMcpTool" | "SembleSearch" | "SembleFindRelated" => "MCP".into(),
|
||||||
"CreatePlan" => "CREATE_PLAN_V2".into(),
|
"CreatePlan" => "CREATE_PLAN_V2".into(),
|
||||||
"UpdateCurrentStep" => "COMMUNICATE_UPDATE".into(),
|
"UpdateCurrentStep" => "COMMUNICATE_UPDATE".into(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::{collections::BTreeMap, path::PathBuf};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{ModelSpec, PromptSpec, ToolDefinition},
|
model::{ModelSpec, PromptSpec, ToolDefinition},
|
||||||
@@ -10,11 +10,22 @@ use super::{assets::runtime_expression, Mode, PromptAssets};
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PromptCompiler {
|
pub struct PromptCompiler {
|
||||||
assets: PromptAssets,
|
assets: PromptAssets,
|
||||||
|
compaction_prompt_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PromptCompiler {
|
impl PromptCompiler {
|
||||||
pub fn new(assets: PromptAssets) -> Self {
|
pub fn new(assets: PromptAssets) -> Self {
|
||||||
Self { assets }
|
Self {
|
||||||
|
assets,
|
||||||
|
compaction_prompt_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_compaction_prompt_path(assets: PromptAssets, path: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
assets,
|
||||||
|
compaction_prompt_path: Some(path),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runtime_message(&self, mode: Mode, values: &BTreeMap<&str, String>) -> Result<String> {
|
pub fn runtime_message(&self, mode: Mode, values: &BTreeMap<&str, String>) -> Result<String> {
|
||||||
@@ -39,12 +50,13 @@ impl PromptCompiler {
|
|||||||
.display_name
|
.display_name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or(model.model_id.as_str());
|
.unwrap_or(model.model_id.as_str());
|
||||||
|
let prompt = match (mode, &self.compaction_prompt_path) {
|
||||||
|
(Mode::Compaction, Some(path)) => crate::config::compaction_prompt_override_at(path)?
|
||||||
|
.unwrap_or_else(|| self.assets.mode(mode).prompt.clone()),
|
||||||
|
_ => self.assets.mode(mode).prompt.clone(),
|
||||||
|
};
|
||||||
Ok(PromptSpec {
|
Ok(PromptSpec {
|
||||||
instructions: self
|
instructions: prompt.replace("{{FAKE_MODEL_NAME}}", fake_model_name),
|
||||||
.assets
|
|
||||||
.mode(mode)
|
|
||||||
.prompt
|
|
||||||
.replace("{{FAKE_MODEL_NAME}}", fake_model_name),
|
|
||||||
tools,
|
tools,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,9 +478,9 @@ pub fn dynamic_mcp(
|
|||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_mcp_parameters(tool_name: &str, parameters: Value) -> Result<Value> {
|
fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result<Value> {
|
||||||
let schema = parameters
|
let schema = parameters
|
||||||
.as_object()
|
.as_object_mut()
|
||||||
.ok_or_else(|| invalid_mcp_parameters(tool_name))?;
|
.ok_or_else(|| invalid_mcp_parameters(tool_name))?;
|
||||||
match schema.get("type") {
|
match schema.get("type") {
|
||||||
Some(Value::String(schema_type)) if schema_type == "object" => return Ok(parameters),
|
Some(Value::String(schema_type)) if schema_type == "object" => return Ok(parameters),
|
||||||
@@ -505,6 +505,12 @@ fn normalize_mcp_parameters(tool_name: &str, parameters: Value) -> Result<Value>
|
|||||||
if !object_only_union {
|
if !object_only_union {
|
||||||
return Err(invalid_mcp_parameters(tool_name));
|
return Err(invalid_mcp_parameters(tool_name));
|
||||||
}
|
}
|
||||||
|
// OpenAI-compatible function schemas (and the corresponding schema
|
||||||
|
// validators used by other providers) require the root schema to declare
|
||||||
|
// an object type. Cursor's app-control MCP sometimes sends an object-only
|
||||||
|
// `anyOf`/`oneOf` schema without that root annotation. Preserve the union
|
||||||
|
// while adding the annotation to the model-facing copy.
|
||||||
|
schema.insert("type".into(), Value::String("object".into()));
|
||||||
Ok(parameters)
|
Ok(parameters)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,7 +619,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dynamic_mcp_preserves_cursor_object_union_schema() {
|
fn dynamic_mcp_normalizes_cursor_object_union_without_mutating_wire_schema() {
|
||||||
let original_schema = serde_json::json!({
|
let original_schema = serde_json::json!({
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
@@ -654,7 +660,8 @@ mod tests {
|
|||||||
.get("cursor-app-control-move_agent_to_cloned_root")
|
.get("cursor-app-control-move_agent_to_cloned_root")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(definition.parameters, original_schema);
|
assert_eq!(definition.parameters["type"], "object");
|
||||||
|
assert_eq!(definition.parameters["anyOf"], original_schema["anyOf"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
wire.input_schema_json.as_deref(),
|
wire.input_schema_json.as_deref(),
|
||||||
Some(original_json.as_str())
|
Some(original_json.as_str())
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ impl CursorSession {
|
|||||||
let mut response_text = String::new();
|
let mut response_text = String::new();
|
||||||
let mut response_thinking = String::new();
|
let mut response_thinking = String::new();
|
||||||
let mut active_round = None::<ToolRoundId>;
|
let mut active_round = None::<ToolRoundId>;
|
||||||
|
let mut active_tool_calls = HashSet::<String>::new();
|
||||||
|
let mut interrupted_rounds = HashSet::<ToolRoundId>::new();
|
||||||
|
let mut interrupted_tool_calls = HashSet::<String>::new();
|
||||||
let mut final_checkpoint = None::<FinalCheckpoints>;
|
let mut final_checkpoint = None::<FinalCheckpoints>;
|
||||||
let mut compaction_checkpoint = None::<pb::ConversationStateStructure>;
|
let mut compaction_checkpoint = None::<pb::ConversationStateStructure>;
|
||||||
let mut turn_usage = None::<Usage>;
|
let mut turn_usage = None::<Usage>;
|
||||||
@@ -130,13 +133,16 @@ impl CursorSession {
|
|||||||
let mut presentation = Presentation::default();
|
let mut presentation = Presentation::default();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let input = if let Some(completion) = ready.pop_front() {
|
let input = if let Ok(action) = self.runtime_actions.try_recv() {
|
||||||
|
Input::RuntimeAction(Some(Box::new(action)))
|
||||||
|
} else if let Some(completion) = ready.pop_front() {
|
||||||
Input::Completion(completion)
|
Input::Completion(completion)
|
||||||
} else {
|
} else {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
action = self.runtime_actions.recv() => Input::RuntimeAction(action.map(Box::new)),
|
||||||
event = self.core.events.recv() => Input::Event(event),
|
event = self.core.events.recv() => Input::Event(event),
|
||||||
completion = self.results.recv() => Input::CompletionResult(completion),
|
completion = self.results.recv() => Input::CompletionResult(completion),
|
||||||
action = self.runtime_actions.recv() => Input::RuntimeAction(action.map(Box::new)),
|
|
||||||
failure = worker.failures.recv(), if checkpoint_worker_open => Input::CheckpointFailure(failure),
|
failure = worker.failures.recv(), if checkpoint_worker_open => Input::CheckpointFailure(failure),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -147,15 +153,16 @@ impl CursorSession {
|
|||||||
}
|
}
|
||||||
Input::Completion(completion) => {
|
Input::Completion(completion) => {
|
||||||
if let Some(completion) = self
|
if let Some(completion) = self
|
||||||
.forward_completion(completion, &mut completions)
|
.forward_completion(completion, &mut completions, &interrupted_tool_calls)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
ready.push_back(completion);
|
ready.push_back(completion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Input::CompletionResult(Some(result)) => {
|
Input::CompletionResult(Some(result)) => {
|
||||||
if let Some(completion) =
|
if let Some(completion) = self
|
||||||
self.forward_completion(result?, &mut completions).await?
|
.forward_completion(result?, &mut completions, &interrupted_tool_calls)
|
||||||
|
.await?
|
||||||
{
|
{
|
||||||
ready.push_back(completion);
|
ready.push_back(completion);
|
||||||
}
|
}
|
||||||
@@ -164,7 +171,15 @@ impl CursorSession {
|
|||||||
return Err(Error::Protocol("tool result channel closed".into()));
|
return Err(Error::Protocol("tool result channel closed".into()));
|
||||||
}
|
}
|
||||||
Input::RuntimeAction(Some(action)) => {
|
Input::RuntimeAction(Some(action)) => {
|
||||||
self.forward_injection(*action).await?;
|
self.forward_injection(
|
||||||
|
*action,
|
||||||
|
active_round.as_ref(),
|
||||||
|
&active_tool_calls,
|
||||||
|
&completions,
|
||||||
|
&mut interrupted_rounds,
|
||||||
|
&mut interrupted_tool_calls,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Input::RuntimeAction(None) => {
|
Input::RuntimeAction(None) => {
|
||||||
return Err(Error::Protocol("runtime action channel closed".into()));
|
return Err(Error::Protocol("runtime action channel closed".into()));
|
||||||
@@ -283,7 +298,23 @@ impl CursorSession {
|
|||||||
round_id,
|
round_id,
|
||||||
calls: round_calls,
|
calls: round_calls,
|
||||||
} => {
|
} => {
|
||||||
active_round = Some(round_id);
|
active_round = Some(round_id.clone());
|
||||||
|
active_tool_calls = round_calls
|
||||||
|
.iter()
|
||||||
|
.map(|call| call.call_id.clone())
|
||||||
|
.collect();
|
||||||
|
// Runtime actions are deliberately prioritized over core events. An
|
||||||
|
// injection can therefore be observed before the already-queued
|
||||||
|
// ToolRoundStarted event reaches this session. In that case the
|
||||||
|
// accepted injection is still pending delivery and this round must be
|
||||||
|
// detached without starting any root tools.
|
||||||
|
if interrupted_rounds.contains(&round_id)
|
||||||
|
|| !self.pending_injections.is_empty()
|
||||||
|
{
|
||||||
|
interrupted_rounds.insert(round_id.clone());
|
||||||
|
interrupted_tool_calls.extend(active_tool_calls.iter().cloned());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for dispatched in self
|
for dispatched in self
|
||||||
.tools
|
.tools
|
||||||
.start_batch(
|
.start_batch(
|
||||||
@@ -348,12 +379,11 @@ impl CursorSession {
|
|||||||
active_round = Some(round_id.clone());
|
active_round = Some(round_id.clone());
|
||||||
}
|
}
|
||||||
let mut tool_round_settled = false;
|
let mut tool_round_settled = false;
|
||||||
if let CommitCause::ToolResult { call_id } = &state.cause {
|
if let CommitCause::ToolResult {
|
||||||
let completion = completions.remove(call_id).ok_or_else(|| {
|
call_id,
|
||||||
Error::Protocol(format!(
|
interrupted,
|
||||||
"core committed a tool result without typed Cursor state: {call_id}"
|
} = &state.cause
|
||||||
))
|
{
|
||||||
})?;
|
|
||||||
let snapshot = self
|
let snapshot = self
|
||||||
.store
|
.store
|
||||||
.tool_round(active_round.as_ref().ok_or_else(|| {
|
.tool_round(active_round.as_ref().ok_or_else(|| {
|
||||||
@@ -372,9 +402,16 @@ impl CursorSession {
|
|||||||
"committed call is absent from tool round: {call_id}"
|
"committed call is absent from tool round: {call_id}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
self.handle
|
if !interrupted {
|
||||||
.emit(&interaction::tool_completed(call, &completion))?;
|
let completion = completions.remove(call_id).ok_or_else(|| {
|
||||||
presentation.tool_completed(&completion);
|
Error::Protocol(format!(
|
||||||
|
"core committed a tool result without typed Cursor state: {call_id}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
self.handle
|
||||||
|
.emit(&interaction::tool_completed(call, &completion))?;
|
||||||
|
presentation.tool_completed(&completion);
|
||||||
|
}
|
||||||
completed.insert(call_id.clone());
|
completed.insert(call_id.clone());
|
||||||
tool_round_settled = snapshot.status == ToolRoundStatus::Settled;
|
tool_round_settled = snapshot.status == ToolRoundStatus::Settled;
|
||||||
}
|
}
|
||||||
@@ -490,7 +527,10 @@ impl CursorSession {
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
active_round = None;
|
if let Some(round_id) = active_round.take() {
|
||||||
|
interrupted_rounds.remove(&round_id);
|
||||||
|
}
|
||||||
|
active_tool_calls.clear();
|
||||||
self.tool_runtime.clear_completed().await;
|
self.tool_runtime.clear_completed().await;
|
||||||
} else if !matches!(&state.cause, CommitCause::ToolResult { .. })
|
} else if !matches!(&state.cause, CommitCause::ToolResult { .. })
|
||||||
&& active_round.is_some()
|
&& active_round.is_some()
|
||||||
@@ -603,7 +643,11 @@ impl CursorSession {
|
|||||||
&self,
|
&self,
|
||||||
mut completion: ToolCompletion,
|
mut completion: ToolCompletion,
|
||||||
completions: &mut HashMap<String, ToolCompletion>,
|
completions: &mut HashMap<String, ToolCompletion>,
|
||||||
|
interrupted_tool_calls: &HashSet<String>,
|
||||||
) -> Result<Option<ToolCompletion>> {
|
) -> Result<Option<ToolCompletion>> {
|
||||||
|
if interrupted_tool_calls.contains(&completion.result().call_id) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
if let Some(image) = completion.take_read_image() {
|
if let Some(image) = completion.take_read_image() {
|
||||||
let blob_id = self.store.put_blob(&image.data, &[]).await?;
|
let blob_id = self.store.put_blob(&image.data, &[]).await?;
|
||||||
completion.persist_read_image(&blob_id, &image)?;
|
completion.persist_read_image(&blob_id, &image)?;
|
||||||
@@ -635,19 +679,33 @@ impl CursorSession {
|
|||||||
Ok(dispatched.completion)
|
Ok(dispatched.completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn forward_injection(&mut self, action: pb::InjectContextAction) -> Result<()> {
|
async fn forward_injection(
|
||||||
|
&mut self,
|
||||||
|
action: pb::InjectContextAction,
|
||||||
|
active_round: Option<&ToolRoundId>,
|
||||||
|
active_tool_calls: &HashSet<String>,
|
||||||
|
completions: &HashMap<String, ToolCompletion>,
|
||||||
|
interrupted_rounds: &mut HashSet<ToolRoundId>,
|
||||||
|
interrupted_tool_calls: &mut HashSet<String>,
|
||||||
|
) -> Result<()> {
|
||||||
if action.injection_id.is_empty() {
|
if action.injection_id.is_empty() {
|
||||||
return Err(Error::Protocol(
|
return Err(Error::Protocol(
|
||||||
"InjectContextAction has no injection_id".into(),
|
"InjectContextAction has no injection_id".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if self.injection_ids.contains(&action.injection_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if action.expected_run_id != self.context.request_id {
|
if action.expected_run_id != self.context.request_id {
|
||||||
return Err(Error::Protocol(format!(
|
let reason = format!(
|
||||||
"InjectContextAction expected run {}, active run is {}",
|
"InjectContextAction expected run {}, active run is {}",
|
||||||
action.expected_run_id, self.context.request_id
|
action.expected_run_id, self.context.request_id
|
||||||
)));
|
);
|
||||||
}
|
self.handle.emit(&interaction::context_injection_rejected(
|
||||||
if self.injection_ids.contains(&action.injection_id) {
|
action.injection_id.clone(),
|
||||||
|
reason,
|
||||||
|
))?;
|
||||||
|
self.injection_ids.insert(action.injection_id);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let user_message = match action.payload.as_ref() {
|
let user_message = match action.payload.as_ref() {
|
||||||
@@ -675,25 +733,31 @@ impl CursorSession {
|
|||||||
);
|
);
|
||||||
self.handle
|
self.handle
|
||||||
.emit(&interaction::context_injection_queued(injection_id.clone()))?;
|
.emit(&interaction::context_injection_queued(injection_id.clone()))?;
|
||||||
|
interrupted_tool_calls.extend(
|
||||||
|
active_tool_calls
|
||||||
|
.iter()
|
||||||
|
.filter(|call_id| !completions.contains_key(*call_id))
|
||||||
|
.cloned(),
|
||||||
|
);
|
||||||
|
if let Some(round_id) = active_round {
|
||||||
|
interrupted_rounds.insert(round_id.clone());
|
||||||
|
}
|
||||||
|
self.interrupt_execs().await;
|
||||||
if self
|
if self
|
||||||
.core
|
.core
|
||||||
.commands
|
.commands
|
||||||
.send(ClientCommand::RuntimeMessage(message))
|
.send(ClientCommand::InterruptWithMessage(message))
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
self.pending_injections.remove(&injection_id);
|
self.pending_injections.remove(&injection_id);
|
||||||
return Err(Error::RunNotFound(self.context.request_id.clone()));
|
return Err(Error::RunNotFound(self.context.request_id.clone()));
|
||||||
}
|
}
|
||||||
self.interrupt_execs().await;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn interrupt_execs(&self) {
|
async fn interrupt_execs(&self) {
|
||||||
// Keep runtime entries until Cursor returns the aborted result. The core tool
|
for id in self.tools.interrupt_for_message().await {
|
||||||
// round needs that terminal result before it can append the injected context
|
|
||||||
// after the complete assistant/tool pair and continue the same Run.
|
|
||||||
for id in self.tool_runtime.running_exec_ids().await {
|
|
||||||
let _ = self.handle.emit(&codec::abort(id));
|
let _ = self.handle.emit(&codec::abort(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,5 @@ mod request;
|
|||||||
mod response;
|
mod response;
|
||||||
|
|
||||||
pub use request::{abort, mcp_request, mcp_state_request, request};
|
pub use request::{abort, mcp_request, mcp_state_request, request};
|
||||||
pub(crate) use request::{
|
pub(crate) use request::{edit_read_request, json_object_to_prost, mcp_meta_request};
|
||||||
await_read_request, edit_read_request, json_object_to_prost, mcp_meta_request,
|
|
||||||
};
|
|
||||||
pub use response::{client_event, stream_closed, ClientExecEvent};
|
pub use response::{client_event, stream_closed, ClientExecEvent};
|
||||||
|
|||||||
@@ -191,32 +191,6 @@ pub(crate) fn edit_read_request(id: u32, call: &ToolCall) -> Result<pb::AgentSer
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn await_read_request(
|
|
||||||
id: u32,
|
|
||||||
call: &ToolCall,
|
|
||||||
context: &ExecContext,
|
|
||||||
) -> Result<pb::AgentServerMessage> {
|
|
||||||
let task_id = call
|
|
||||||
.arguments
|
|
||||||
.get("shell_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| Error::Protocol("AwaitShell is missing shell_id".into()))?;
|
|
||||||
Ok(server_message(
|
|
||||||
id,
|
|
||||||
call,
|
|
||||||
pb::exec_server_message::Message::ReadArgs(pb::ReadArgs {
|
|
||||||
path: format!(
|
|
||||||
"{}/{}.txt",
|
|
||||||
context.terminals_folder.trim_end_matches('/'),
|
|
||||||
task_id
|
|
||||||
),
|
|
||||||
tool_call_id: call.call_id.clone(),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
Some(false),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn edit_write_request(
|
pub(super) fn edit_write_request(
|
||||||
id: u32,
|
id: u32,
|
||||||
call: &ToolCall,
|
call: &ToolCall,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
Error, Result,
|
Error, Result,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::request::{await_read_request, edit_write_request};
|
use super::request::edit_write_request;
|
||||||
|
|
||||||
pub enum ClientExecEvent {
|
pub enum ClientExecEvent {
|
||||||
Delta(Box<pb::AgentServerMessage>),
|
Delta(Box<pb::AgentServerMessage>),
|
||||||
@@ -25,6 +25,12 @@ pub async fn client_event(
|
|||||||
message: &pb::ExecClientMessage,
|
message: &pb::ExecClientMessage,
|
||||||
pending: &CursorToolRuntime,
|
pending: &CursorToolRuntime,
|
||||||
) -> Result<ClientExecEvent> {
|
) -> Result<ClientExecEvent> {
|
||||||
|
if pending.is_interrupted(message.id).await {
|
||||||
|
if message.message.as_ref().is_some_and(is_terminal) {
|
||||||
|
pending.discard_exec(message.id).await;
|
||||||
|
}
|
||||||
|
return Ok(ClientExecEvent::Pending);
|
||||||
|
}
|
||||||
let call = match pending.exec_call(message.id).await {
|
let call = match pending.exec_call(message.id).await {
|
||||||
Some(call) => call,
|
Some(call) => call,
|
||||||
None if pending.completed_call(message.id).await.is_some() => {
|
None if pending.completed_call(message.id).await.is_some() => {
|
||||||
@@ -47,7 +53,6 @@ pub async fn client_event(
|
|||||||
let entry = take(message.id, pending).await?;
|
let entry = take(message.id, pending).await?;
|
||||||
return match entry.stage {
|
return match entry.stage {
|
||||||
ExecStage::EditRead => advance_edit(entry, wire_result, pending).await,
|
ExecStage::EditRead => advance_edit(entry, wire_result, pending).await,
|
||||||
ExecStage::Await(_) => advance_await(entry, wire_result, pending).await,
|
|
||||||
ExecStage::Direct | ExecStage::DynamicMcp(_) | ExecStage::EditWrite(_) => {
|
ExecStage::Direct | ExecStage::DynamicMcp(_) | ExecStage::EditWrite(_) => {
|
||||||
completed(entry, wire_result.clone())
|
completed(entry, wire_result.clone())
|
||||||
}
|
}
|
||||||
@@ -131,6 +136,10 @@ pub async fn client_event(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stream_closed(id: u32, pending: &CursorToolRuntime) -> Result<Option<ToolCompletion>> {
|
pub async fn stream_closed(id: u32, pending: &CursorToolRuntime) -> Result<Option<ToolCompletion>> {
|
||||||
|
if pending.is_interrupted(id).await {
|
||||||
|
pending.discard_exec(id).await;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let Some(entry) = pending.take_exec(id).await else {
|
let Some(entry) = pending.take_exec(id).await else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -177,79 +186,20 @@ pub async fn stream_closed(id: u32, pending: &CursorToolRuntime) -> Result<Optio
|
|||||||
)?))
|
)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn advance_await(
|
fn is_terminal(message: &pb::exec_client_message::Message) -> bool {
|
||||||
entry: PendingExec,
|
use pb::{exec_client_message::Message, shell_stream::Event};
|
||||||
result: &pb::exec_client_message::Message,
|
|
||||||
registry: &CursorToolRuntime,
|
match message {
|
||||||
) -> Result<ClientExecEvent> {
|
Message::ShellStream(stream) => matches!(
|
||||||
let read = match result {
|
stream.event.as_ref(),
|
||||||
pb::exec_client_message::Message::ReadResult(result)
|
Some(Event::Exit(_))
|
||||||
| pb::exec_client_message::Message::RedactedReadResult(result) => result,
|
| Some(Event::Backgrounded(_))
|
||||||
_ => return Err(Error::Protocol("AwaitShell expected ReadResult".into())),
|
| Some(Event::Rejected(_))
|
||||||
};
|
| Some(Event::PermissionDenied(_))
|
||||||
let ExecStage::Await(state) = &entry.stage else {
|
| Some(Event::SandboxUnsupported(_))
|
||||||
return Err(Error::Protocol(
|
),
|
||||||
"AwaitShell result reached a non-await execution stage".into(),
|
_ => true,
|
||||||
));
|
|
||||||
};
|
|
||||||
let content = match read.result.as_ref() {
|
|
||||||
Some(pb::read_result::Result::Success(success)) => match success.output.as_ref() {
|
|
||||||
Some(pb::read_success::Output::Content(content)) => content.as_str(),
|
|
||||||
_ => "",
|
|
||||||
},
|
|
||||||
Some(pb::read_result::Result::FileNotFound(_)) => "",
|
|
||||||
Some(pb::read_result::Result::Error(error)) => {
|
|
||||||
return Ok(ClientExecEvent::Completed(Box::new(result::await_error(
|
|
||||||
entry,
|
|
||||||
&error.error,
|
|
||||||
)?)))
|
|
||||||
}
|
|
||||||
_ => "",
|
|
||||||
};
|
|
||||||
let regex_match = state
|
|
||||||
.regex
|
|
||||||
.as_ref()
|
|
||||||
.map(|pattern| regex::Regex::new(pattern))
|
|
||||||
.transpose()
|
|
||||||
.map_err(|error| Error::Protocol(format!("invalid AwaitShell pattern: {error}")))?
|
|
||||||
.and_then(|pattern| {
|
|
||||||
pattern
|
|
||||||
.find(content)
|
|
||||||
.map(|found| found.as_str().to_string())
|
|
||||||
});
|
|
||||||
let exit_code = content.lines().find_map(|line| {
|
|
||||||
line.strip_prefix("exit_code:")
|
|
||||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
|
||||||
});
|
|
||||||
if regex_match.is_some() || exit_code.is_some() || std::time::Instant::now() >= state.deadline {
|
|
||||||
return Ok(ClientExecEvent::Completed(Box::new(result::await_result(
|
|
||||||
entry,
|
|
||||||
content.len() as u64,
|
|
||||||
regex_match,
|
|
||||||
exit_code,
|
|
||||||
)?)));
|
|
||||||
}
|
}
|
||||||
let state = match entry.stage {
|
|
||||||
ExecStage::Await(state) => state,
|
|
||||||
_ => {
|
|
||||||
return Err(Error::Protocol(
|
|
||||||
"AwaitShell result changed execution stage".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let wait = state
|
|
||||||
.deadline
|
|
||||||
.saturating_duration_since(std::time::Instant::now())
|
|
||||||
.min(std::time::Duration::from_secs(1));
|
|
||||||
tokio::time::sleep(wait).await;
|
|
||||||
let call = entry.call.clone();
|
|
||||||
let context = entry.context.clone();
|
|
||||||
let id = registry
|
|
||||||
.reserve_await_again(&call, &context, state, entry.started_at_ms)
|
|
||||||
.await?;
|
|
||||||
Ok(ClientExecEvent::Message(Box::new(await_read_request(
|
|
||||||
id, &call, &context,
|
|
||||||
)?)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn advance_edit(
|
async fn advance_edit(
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
//! AwaitShell's timed and file-backed execution paths.
|
|
||||||
|
|
||||||
use crate::{model::ToolCall, Error, Result};
|
|
||||||
|
|
||||||
use super::ToolStart;
|
|
||||||
use crate::cursor::tools::{
|
|
||||||
codec, result,
|
|
||||||
result::ToolResultSender,
|
|
||||||
runtime::{CursorToolRuntime, ExecContext},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(super) async fn start(
|
|
||||||
runtime: &CursorToolRuntime,
|
|
||||||
results: &ToolResultSender,
|
|
||||||
call: &ToolCall,
|
|
||||||
context: &ExecContext,
|
|
||||||
) -> Result<ToolStart> {
|
|
||||||
let message = if call
|
|
||||||
.arguments
|
|
||||||
.get("shell_id")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
let id = runtime.reserve_await(call, context).await?;
|
|
||||||
Some(codec::await_read_request(id, call, context)?)
|
|
||||||
} else {
|
|
||||||
wait_without_shell_id(results, call)?;
|
|
||||||
None
|
|
||||||
};
|
|
||||||
Ok(ToolStart {
|
|
||||||
messages: message.into_iter().collect(),
|
|
||||||
completion: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_without_shell_id(results: &ToolResultSender, call: &ToolCall) -> Result<()> {
|
|
||||||
let block_ms = call
|
|
||||||
.arguments
|
|
||||||
.get("block_until_ms")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.unwrap_or(30_000);
|
|
||||||
if block_ms == 0 || block_ms > 7_140_000 {
|
|
||||||
return Err(Error::Protocol(
|
|
||||||
"AwaitShell without shell_id requires block_until_ms in 1..=7140000".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let call = call.clone();
|
|
||||||
let results = results.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(block_ms)).await;
|
|
||||||
results.send(result::await_sleep(&call, block_ms));
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
mod await_shell;
|
|
||||||
mod edit;
|
mod edit;
|
||||||
mod exec;
|
mod exec;
|
||||||
mod interaction;
|
mod interaction;
|
||||||
@@ -51,6 +50,9 @@ pub(super) async fn start(
|
|||||||
return local::subagents_disabled(call);
|
return local::subagents_disabled(call);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let normalized_call = normalize_block_until_ms(call)?;
|
||||||
|
let call = normalized_call.as_ref().unwrap_or(call);
|
||||||
|
|
||||||
match normalized(&call.name).as_str() {
|
match normalized(&call.name).as_str() {
|
||||||
"shell" | "read" | "delete" | "grep" | "glob" | "readlints" | "task" | "callmcptool"
|
"shell" | "read" | "delete" | "grep" | "glob" | "readlints" | "task" | "callmcptool"
|
||||||
| "fetchmcpresource" | "getmcptools" => exec::start(runtime, call, context).await,
|
| "fetchmcpresource" | "getmcptools" => exec::start(runtime, call, context).await,
|
||||||
@@ -58,12 +60,60 @@ pub(super) async fn start(
|
|||||||
"askquestion" | "websearch" | "webfetch" | "switchmode" | "createplan"
|
"askquestion" | "websearch" | "webfetch" | "switchmode" | "createplan"
|
||||||
| "generateimage" => interaction::start(runtime, call).await,
|
| "generateimage" => interaction::start(runtime, call).await,
|
||||||
"todowrite" | "updatecurrentstep" => local::start(call, message_index),
|
"todowrite" | "updatecurrentstep" => local::start(call, message_index),
|
||||||
"awaitshell" => await_shell::start(runtime, results, call, context).await,
|
|
||||||
"semblesearch" | "semblefindrelated" => semble::start(results, call, store.cloned()),
|
"semblesearch" | "semblefindrelated" => semble::start(results, call, store.cloned()),
|
||||||
_ => Err(Error::Protocol(format!("unsupported tool: {}", call.name))),
|
_ => Err(Error::Protocol(format!("unsupported tool: {}", call.name))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_block_until_ms(call: &ToolCall) -> Result<Option<ToolCall>> {
|
||||||
|
if normalized(&call.name) != "shell" {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(value) = call.arguments.get("block_until_ms") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let integer = if let Some(value) = value.as_i64() {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
let value = value.as_f64().ok_or_else(|| {
|
||||||
|
Error::Protocol(format!("{} block_until_ms must be an integer", call.name))
|
||||||
|
})?;
|
||||||
|
if !value.is_finite() || value.fract() != 0.0 {
|
||||||
|
return Err(Error::Protocol(format!(
|
||||||
|
"{} block_until_ms must be an integer",
|
||||||
|
call.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if value < i64::MIN as f64 || value > i64::MAX as f64 {
|
||||||
|
return Err(Error::Protocol(format!(
|
||||||
|
"{} block_until_ms is out of range",
|
||||||
|
call.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
value as i64
|
||||||
|
};
|
||||||
|
|
||||||
|
if integer < 0 {
|
||||||
|
return Err(Error::Protocol(format!(
|
||||||
|
"{} block_until_ms is out of range",
|
||||||
|
call.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.as_i64().is_some() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut normalized_call = call.clone();
|
||||||
|
normalized_call
|
||||||
|
.arguments
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| Error::Protocol(format!("{} arguments must be a JSON object", call.name)))?
|
||||||
|
.insert("block_until_ms".into(), serde_json::Value::from(integer));
|
||||||
|
Ok(Some(normalized_call))
|
||||||
|
}
|
||||||
|
|
||||||
fn is_mcp_auth(call: &ToolCall) -> bool {
|
fn is_mcp_auth(call: &ToolCall) -> bool {
|
||||||
normalized(&call.name) == "callmcptool"
|
normalized(&call.name) == "callmcptool"
|
||||||
&& call
|
&& call
|
||||||
@@ -89,3 +139,61 @@ pub(super) fn normalized(name: &str) -> String {
|
|||||||
.flat_map(char::to_lowercase)
|
.flat_map(char::to_lowercase)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn tool(name: &str, arguments: serde_json::Value) -> ToolCall {
|
||||||
|
ToolCall {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call-1".into(),
|
||||||
|
model_call_id: "model-call-1".into(),
|
||||||
|
name: name.into(),
|
||||||
|
arguments_text: arguments.to_string(),
|
||||||
|
arguments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_accepts_integer_valued_float_timeout() {
|
||||||
|
let call = tool(
|
||||||
|
"Shell",
|
||||||
|
serde_json::json!({"command": "echo ok", "block_until_ms": 45_000.0}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let call = normalize_block_until_ms(&call).unwrap().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(call.arguments["block_until_ms"].as_i64(), Some(45_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_rejects_fractional_timeout() {
|
||||||
|
let call = tool(
|
||||||
|
"Shell",
|
||||||
|
serde_json::json!({"command": "echo ok", "block_until_ms": 30_000.5}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = normalize_block_until_ms(&call).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"protocol error: Shell block_until_ms must be an integer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_rejects_negative_timeout_instead_of_defaulting() {
|
||||||
|
let call = tool(
|
||||||
|
"Shell",
|
||||||
|
serde_json::json!({"command": "echo ok", "block_until_ms": -1}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = normalize_block_until_ms(&call).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"protocol error: Shell block_until_ms is out of range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,6 +155,11 @@ impl ToolDispatcher {
|
|||||||
.map(Some)
|
.map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn interrupt_for_message(&self) -> Vec<u32> {
|
||||||
|
self.edit_schedule.lock().await.clear();
|
||||||
|
self.runtime.interrupt_for_message().await
|
||||||
|
}
|
||||||
|
|
||||||
async fn start(
|
async fn start(
|
||||||
&self,
|
&self,
|
||||||
call: &ToolCall,
|
call: &ToolCall,
|
||||||
@@ -193,6 +198,9 @@ impl ToolDispatcher {
|
|||||||
&self,
|
&self,
|
||||||
response: &pb::InteractionResponse,
|
response: &pb::InteractionResponse,
|
||||||
) -> Result<ClientToolEvent> {
|
) -> Result<ClientToolEvent> {
|
||||||
|
if self.runtime.is_interrupted(response.id).await {
|
||||||
|
return Ok(ClientToolEvent::Pending);
|
||||||
|
}
|
||||||
let pending = match self.runtime.take_interaction(response.id).await {
|
let pending = match self.runtime.take_interaction(response.id).await {
|
||||||
Some(pending) => pending,
|
Some(pending) => pending,
|
||||||
None if self.runtime.completed_call(response.id).await.is_some() => {
|
None if self.runtime.completed_call(response.id).await.is_some() => {
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
cursor::proto::agent::v1 as pb,
|
|
||||||
model::{ToolCall, ToolResult},
|
|
||||||
Error, Result,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{now_ms, ToolCompletion};
|
|
||||||
use crate::cursor::tools::runtime::{ExecStage, PendingExec};
|
|
||||||
|
|
||||||
pub(crate) fn await_result(
|
|
||||||
pending: PendingExec,
|
|
||||||
output_length: u64,
|
|
||||||
regex_match: Option<String>,
|
|
||||||
exit_code: Option<i32>,
|
|
||||||
) -> Result<ToolCompletion> {
|
|
||||||
let ExecStage::Await(state) = &pending.stage else {
|
|
||||||
return Err(Error::Protocol(
|
|
||||||
"AwaitShell completion reached a non-await execution stage".into(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let runtime_ms = now_ms().saturating_sub(pending.started_at_ms);
|
|
||||||
let result = if exit_code.is_some() {
|
|
||||||
pb::await_success::AwaitResult::Complete(pb::AwaitTaskComplete {
|
|
||||||
task_id: state.task_id.clone(),
|
|
||||||
runtime_ms,
|
|
||||||
output_file_path: state.output_file_path.clone(),
|
|
||||||
output_length,
|
|
||||||
regex_requested: state.regex.is_some(),
|
|
||||||
regex_match,
|
|
||||||
exit_code,
|
|
||||||
wake_reason: Some("task_complete".into()),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
pb::await_success::AwaitResult::StillRunning(pb::AwaitTaskStillRunning {
|
|
||||||
task_id: state.task_id.clone(),
|
|
||||||
runtime_ms,
|
|
||||||
output_file_path: state.output_file_path.clone(),
|
|
||||||
output_length,
|
|
||||||
regex_requested: state.regex.is_some(),
|
|
||||||
regex_match,
|
|
||||||
wake_reason: Some("timeout_or_pattern".into()),
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let content = serde_json::json!({
|
|
||||||
"task_id": state.task_id,
|
|
||||||
"output_file_path": state.output_file_path,
|
|
||||||
"output_length": output_length,
|
|
||||||
"exit_code": exit_code,
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
completion(
|
|
||||||
&pending,
|
|
||||||
content,
|
|
||||||
false,
|
|
||||||
pb::await_result::Result::Success(pb::AwaitSuccess {
|
|
||||||
await_result: Some(result),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn await_error(pending: PendingExec, error: &str) -> Result<ToolCompletion> {
|
|
||||||
completion(
|
|
||||||
&pending,
|
|
||||||
error.into(),
|
|
||||||
true,
|
|
||||||
pb::await_result::Result::Error(pb::AwaitError {
|
|
||||||
error: error.into(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn completion(
|
|
||||||
pending: &PendingExec,
|
|
||||||
content: String,
|
|
||||||
is_error: bool,
|
|
||||||
result: pb::await_result::Result,
|
|
||||||
) -> Result<ToolCompletion> {
|
|
||||||
let ExecStage::Await(state) = &pending.stage else {
|
|
||||||
return Err(Error::Protocol(
|
|
||||||
"AwaitShell completion reached a non-await execution stage".into(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
Ok(ToolCompletion::new(
|
|
||||||
&pending.call,
|
|
||||||
pending.started_at_ms,
|
|
||||||
ToolResult {
|
|
||||||
call_id: pending.call.call_id.clone(),
|
|
||||||
content,
|
|
||||||
is_error,
|
|
||||||
image: None,
|
|
||||||
},
|
|
||||||
pb::tool_call::Tool::AwaitToolCall(pb::AwaitToolCall {
|
|
||||||
args: Some(pb::AwaitArgs {
|
|
||||||
task_id: state.task_id.clone(),
|
|
||||||
block_until_ms: pending
|
|
||||||
.call
|
|
||||||
.arguments
|
|
||||||
.get("block_until_ms")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as u32),
|
|
||||||
regex: state.regex.clone(),
|
|
||||||
}),
|
|
||||||
result: Some(pb::AwaitResult {
|
|
||||||
result: Some(result),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn await_sleep(call: &ToolCall, runtime_ms: u64) -> ToolCompletion {
|
|
||||||
ToolCompletion::new(
|
|
||||||
call,
|
|
||||||
now_ms().saturating_sub(runtime_ms),
|
|
||||||
ToolResult {
|
|
||||||
call_id: call.call_id.clone(),
|
|
||||||
content: format!("Waited {runtime_ms} ms"),
|
|
||||||
is_error: false,
|
|
||||||
image: None,
|
|
||||||
},
|
|
||||||
pb::tool_call::Tool::AwaitToolCall(pb::AwaitToolCall {
|
|
||||||
args: Some(pb::AwaitArgs {
|
|
||||||
task_id: String::new(),
|
|
||||||
block_until_ms: Some(runtime_ms as u32),
|
|
||||||
regex: None,
|
|
||||||
}),
|
|
||||||
result: Some(pb::AwaitResult {
|
|
||||||
result: Some(pb::await_result::Result::Success(pb::AwaitSuccess {
|
|
||||||
await_result: Some(pb::await_success::AwaitResult::StillRunning(
|
|
||||||
pb::AwaitTaskStillRunning {
|
|
||||||
task_id: String::new(),
|
|
||||||
runtime_ms,
|
|
||||||
output_file_path: String::new(),
|
|
||||||
output_length: 0,
|
|
||||||
regex_requested: false,
|
|
||||||
regex_match: None,
|
|
||||||
wake_reason: Some("sleep_complete".into()),
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,54 @@
|
|||||||
use crate::cursor::proto::agent::v1 as pb;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::{cursor::proto::agent::v1 as pb, model::limit_tool_result_text};
|
||||||
|
|
||||||
const KIB: usize = 1024;
|
const KIB: usize = 1024;
|
||||||
|
const READ_CONTENT_LIMIT: usize = 64 * KIB;
|
||||||
|
const READ_BINARY_LIMIT: usize = 32 * KIB;
|
||||||
const SHELL_STREAM_LIMIT: usize = 16 * KIB;
|
const SHELL_STREAM_LIMIT: usize = 16 * KIB;
|
||||||
const SHELL_CONTENT_LIMIT: usize = 32 * KIB;
|
const SHELL_INTERLEAVED_LIMIT: usize = 32 * KIB;
|
||||||
|
const GREP_CONTENT_LIMIT: usize = 32 * KIB;
|
||||||
|
const GREP_MATCH_LIMIT: usize = 2 * KIB;
|
||||||
|
const GREP_MATCHES_PER_FILE: usize = 100;
|
||||||
|
const GREP_TOTAL_MATCHES: usize = 300;
|
||||||
|
const GREP_LIST_LIMIT: usize = 300;
|
||||||
|
const GLOB_FILE_LIMIT: usize = 200;
|
||||||
|
const EDIT_RESULT_LIMIT: usize = 32 * KIB;
|
||||||
|
const PATCH_EDIT_RESULT_LIMIT: usize = 4 * KIB;
|
||||||
|
const MCP_TEXT_LIMIT: usize = 32 * KIB;
|
||||||
|
const MCP_CONTENT_ITEM_LIMIT: usize = 20;
|
||||||
|
const MCP_STRUCTURED_LIMIT: usize = 32 * KIB;
|
||||||
|
const MCP_BINARY_LIMIT: usize = 32 * KIB;
|
||||||
|
const MCP_RESOURCE_LIMIT: usize = 200;
|
||||||
|
const MCP_RESOURCE_DESCRIPTION_LIMIT: usize = KIB;
|
||||||
|
const WEB_FETCH_LIMIT: usize = 32 * KIB;
|
||||||
|
const WEB_SEARCH_LIMIT: usize = 16 * KIB;
|
||||||
|
const WEB_SEARCH_TITLE_LIMIT: usize = 512;
|
||||||
|
const WEB_SEARCH_SNIPPET_LIMIT: usize = 2 * KIB;
|
||||||
|
|
||||||
pub(super) fn model_content(tool: &pb::tool_call::Tool, content: &mut String) {
|
pub(super) fn tool_completion(
|
||||||
if matches!(tool, pb::tool_call::Tool::ShellToolCall(_)) {
|
tool_name: &str,
|
||||||
*content = truncate_edges("Shell", content, SHELL_CONTENT_LIMIT);
|
tool: &mut pb::tool_call::Tool,
|
||||||
|
content: &mut String,
|
||||||
|
) {
|
||||||
|
use pb::tool_call::Tool;
|
||||||
|
|
||||||
|
match tool {
|
||||||
|
Tool::ShellToolCall(tool) => gate_shell(tool),
|
||||||
|
Tool::GrepToolCall(tool) => gate_grep(tool),
|
||||||
|
Tool::GlobToolCall(tool) => gate_glob(tool),
|
||||||
|
Tool::ReadToolCall(tool) => gate_read(tool),
|
||||||
|
Tool::EditToolCall(tool) => gate_edit(tool_name, tool),
|
||||||
|
Tool::McpToolCall(tool) => gate_mcp(tool),
|
||||||
|
Tool::ListMcpResourcesToolCall(tool) => gate_mcp_resources(tool),
|
||||||
|
Tool::ReadMcpResourceToolCall(tool) => gate_mcp_resource(tool),
|
||||||
|
Tool::GetMcpToolsToolCall(tool) => gate_mcp_tools(tool),
|
||||||
|
Tool::WebFetchToolCall(tool) => gate_web_fetch(tool),
|
||||||
|
Tool::WebSearchToolCall(tool) => gate_web_search(tool),
|
||||||
|
Tool::GenerateImageToolCall(tool) => gate_generate_image(tool),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
*content = limit_tool_result_text(tool_name, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn exec_message(message: &mut pb::exec_client_message::Message) {
|
pub(super) fn exec_message(message: &mut pb::exec_client_message::Message) {
|
||||||
@@ -20,6 +61,12 @@ pub(super) fn exec_message(message: &mut pb::exec_client_message::Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn gate_shell(tool: &mut pb::ShellToolCall) {
|
||||||
|
if let Some(result) = tool.result.as_mut() {
|
||||||
|
gate_shell_result(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn gate_shell_result(result: &mut pb::ShellResult) {
|
fn gate_shell_result(result: &mut pb::ShellResult) {
|
||||||
use pb::shell_result::Result;
|
use pb::shell_result::Result;
|
||||||
match result.result.as_mut() {
|
match result.result.as_mut() {
|
||||||
@@ -27,22 +74,584 @@ fn gate_shell_result(result: &mut pb::ShellResult) {
|
|||||||
success.stdout = truncate_edges("Shell stdout", &success.stdout, SHELL_STREAM_LIMIT);
|
success.stdout = truncate_edges("Shell stdout", &success.stdout, SHELL_STREAM_LIMIT);
|
||||||
success.stderr = truncate_edges("Shell stderr", &success.stderr, SHELL_STREAM_LIMIT);
|
success.stderr = truncate_edges("Shell stderr", &success.stderr, SHELL_STREAM_LIMIT);
|
||||||
if let Some(interleaved) = success.interleaved_output.as_mut() {
|
if let Some(interleaved) = success.interleaved_output.as_mut() {
|
||||||
*interleaved =
|
*interleaved = truncate_edges(
|
||||||
truncate_edges("Shell interleaved output", interleaved, SHELL_CONTENT_LIMIT);
|
"Shell interleaved output",
|
||||||
|
interleaved,
|
||||||
|
SHELL_INTERLEAVED_LIMIT,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Result::Failure(failure)) => {
|
Some(Result::Failure(failure)) => {
|
||||||
failure.stdout = truncate_edges("Shell stdout", &failure.stdout, SHELL_STREAM_LIMIT);
|
failure.stdout = truncate_edges("Shell stdout", &failure.stdout, SHELL_STREAM_LIMIT);
|
||||||
failure.stderr = truncate_edges("Shell stderr", &failure.stderr, SHELL_STREAM_LIMIT);
|
failure.stderr = truncate_edges("Shell stderr", &failure.stderr, SHELL_STREAM_LIMIT);
|
||||||
if let Some(interleaved) = failure.interleaved_output.as_mut() {
|
if let Some(interleaved) = failure.interleaved_output.as_mut() {
|
||||||
*interleaved =
|
*interleaved = truncate_edges(
|
||||||
truncate_edges("Shell interleaved output", interleaved, SHELL_CONTENT_LIMIT);
|
"Shell interleaved output",
|
||||||
|
interleaved,
|
||||||
|
SHELL_INTERLEAVED_LIMIT,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn gate_read(tool: &mut pb::ReadToolCall) {
|
||||||
|
let Some(pb::read_tool_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(output) = success.output.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match output {
|
||||||
|
pb::read_tool_success::Output::Content(value) => {
|
||||||
|
let next = truncate_text("Read", value, READ_CONTENT_LIMIT);
|
||||||
|
if next != *value {
|
||||||
|
*value = next;
|
||||||
|
success.exceeded_limit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pb::read_tool_success::Output::Data(value) if value.len() > READ_BINARY_LIMIT => {
|
||||||
|
let notice = truncation_notice("Read binary data", READ_BINARY_LIMIT, 0, value.len());
|
||||||
|
success.output = Some(pb::read_tool_success::Output::Content(notice));
|
||||||
|
success.exceeded_limit = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_glob(tool: &mut pb::GlobToolCall) {
|
||||||
|
let Some(pb::glob_tool_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let original = success.files.len();
|
||||||
|
if original <= GLOB_FILE_LIMIT {
|
||||||
|
if success.total_files <= 0 {
|
||||||
|
success.total_files = original as i32;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
success.files.truncate(GLOB_FILE_LIMIT);
|
||||||
|
success.total_files = success.total_files.max(original as i32);
|
||||||
|
success.client_truncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_grep(tool: &mut pb::GrepToolCall) {
|
||||||
|
let Some(pb::grep_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut budget = GrepBudget {
|
||||||
|
content_bytes: GREP_CONTENT_LIMIT,
|
||||||
|
matches: GREP_TOTAL_MATCHES,
|
||||||
|
};
|
||||||
|
let mut workspace_names = success
|
||||||
|
.workspace_results
|
||||||
|
.keys()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
workspace_names.sort_unstable();
|
||||||
|
for name in workspace_names {
|
||||||
|
if let Some(result) = success.workspace_results.get_mut(&name) {
|
||||||
|
gate_grep_union(result, &mut budget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(result) = success.active_editor_result.as_mut() {
|
||||||
|
gate_grep_union(result, &mut budget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GrepBudget {
|
||||||
|
content_bytes: usize,
|
||||||
|
matches: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_grep_union(result: &mut pb::GrepUnionResult, budget: &mut GrepBudget) {
|
||||||
|
use pb::grep_union_result::Result;
|
||||||
|
match result.result.as_mut() {
|
||||||
|
Some(Result::Content(content)) => gate_grep_content(content, budget),
|
||||||
|
Some(Result::Files(files)) => {
|
||||||
|
let original = files.files.len();
|
||||||
|
if original > GREP_LIST_LIMIT {
|
||||||
|
files.files.truncate(GREP_LIST_LIMIT);
|
||||||
|
files.client_truncated = true;
|
||||||
|
}
|
||||||
|
if files.total_files <= 0 {
|
||||||
|
files.total_files = original as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Result::Count(counts)) => {
|
||||||
|
let original = counts.counts.len();
|
||||||
|
if original > GREP_LIST_LIMIT {
|
||||||
|
counts.counts.truncate(GREP_LIST_LIMIT);
|
||||||
|
counts.client_truncated = true;
|
||||||
|
}
|
||||||
|
if counts.total_files <= 0 {
|
||||||
|
counts.total_files = original as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_grep_content(content: &mut pb::GrepContentResult, budget: &mut GrepBudget) {
|
||||||
|
if content
|
||||||
|
.matches
|
||||||
|
.iter()
|
||||||
|
.flat_map(|file| &file.matches)
|
||||||
|
.any(is_grep_notice)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let original_bytes = grep_content_bytes(&content.matches);
|
||||||
|
let original_files = content.matches.len();
|
||||||
|
let mut truncated = false;
|
||||||
|
let mut files = Vec::with_capacity(original_files);
|
||||||
|
|
||||||
|
for file in &content.matches {
|
||||||
|
if budget.matches == 0 || budget.content_bytes == 0 {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut next = pb::GrepFileMatch {
|
||||||
|
file: file.file.clone(),
|
||||||
|
matches: Vec::new(),
|
||||||
|
};
|
||||||
|
for matched in &file.matches {
|
||||||
|
if is_grep_notice(matched) {
|
||||||
|
next.matches.push(matched.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if next.matches.len() >= GREP_MATCHES_PER_FILE
|
||||||
|
|| budget.matches == 0
|
||||||
|
|| budget.content_bytes == 0
|
||||||
|
{
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut next_match = matched.clone();
|
||||||
|
let original = next_match.content.clone();
|
||||||
|
next_match.content = truncate_text("Grep match", &original, GREP_MATCH_LIMIT);
|
||||||
|
if next_match.content != original {
|
||||||
|
next_match.content_truncated = true;
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
if next_match.content.len() > budget.content_bytes {
|
||||||
|
next_match.content =
|
||||||
|
truncate_text("Grep", &next_match.content, budget.content_bytes);
|
||||||
|
next_match.content_truncated = true;
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
if next_match.content.trim().is_empty() {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
budget.content_bytes -= next_match.content.len();
|
||||||
|
budget.matches -= 1;
|
||||||
|
next.matches.push(next_match);
|
||||||
|
}
|
||||||
|
if next.matches.len() < file.matches.len() {
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
if !next.matches.is_empty() {
|
||||||
|
files.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files.len() < original_files {
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
if truncated {
|
||||||
|
content.client_truncated = true;
|
||||||
|
add_grep_notice(&mut files, original_bytes);
|
||||||
|
}
|
||||||
|
content.matches = files;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_grep_notice(files: &mut Vec<pb::GrepFileMatch>, original_bytes: usize) {
|
||||||
|
if files
|
||||||
|
.iter()
|
||||||
|
.flat_map(|file| &file.matches)
|
||||||
|
.any(is_grep_notice)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let used = grep_content_bytes(files);
|
||||||
|
let notice = truncation_notice("Grep", GREP_CONTENT_LIMIT, used, original_bytes);
|
||||||
|
if used.saturating_add(notice.len()) <= GREP_CONTENT_LIMIT {
|
||||||
|
let matched = pb::GrepContentMatch {
|
||||||
|
line_number: 0,
|
||||||
|
content: notice,
|
||||||
|
content_truncated: true,
|
||||||
|
is_context_line: true,
|
||||||
|
};
|
||||||
|
if let Some(file) = files.last_mut() {
|
||||||
|
file.matches.push(matched);
|
||||||
|
} else {
|
||||||
|
files.push(pb::GrepFileMatch {
|
||||||
|
file: "[truncated]".into(),
|
||||||
|
matches: vec![matched],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(file) = files.last_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
file.matches.pop();
|
||||||
|
if file.matches.is_empty() {
|
||||||
|
files.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_grep_notice(matched: &pb::GrepContentMatch) -> bool {
|
||||||
|
matched.line_number == 0
|
||||||
|
&& matched.content_truncated
|
||||||
|
&& matched
|
||||||
|
.content
|
||||||
|
.starts_with("[truncated: Grep result exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grep_content_bytes(files: &[pb::GrepFileMatch]) -> usize {
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.flat_map(|file| &file.matches)
|
||||||
|
.map(|matched| matched.content.len())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_edit(tool_name: &str, tool: &mut pb::EditToolCall) {
|
||||||
|
let Some(pb::edit_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let limit = match tool_name.trim() {
|
||||||
|
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" => PATCH_EDIT_RESULT_LIMIT,
|
||||||
|
_ => EDIT_RESULT_LIMIT,
|
||||||
|
};
|
||||||
|
if let Some(diff) = success.diff_string.as_mut() {
|
||||||
|
*diff = truncate_text(tool_name, diff, limit);
|
||||||
|
success.before_full_file_content = None;
|
||||||
|
success.after_full_file_content.clear();
|
||||||
|
} else {
|
||||||
|
success.before_full_file_content = None;
|
||||||
|
success.after_full_file_content =
|
||||||
|
truncate_text(tool_name, &success.after_full_file_content, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_mcp(tool: &mut pb::McpToolCall) {
|
||||||
|
let Some(pb::mcp_tool_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if success.content.iter().any(is_mcp_notice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut notices = Vec::new();
|
||||||
|
if structured_json_len(&success.structured_content) > MCP_STRUCTURED_LIMIT {
|
||||||
|
let original = structured_json_len(&success.structured_content);
|
||||||
|
success.structured_content = truncated_struct(original, MCP_STRUCTURED_LIMIT);
|
||||||
|
notices.push(truncation_notice(
|
||||||
|
"MCP structured_content",
|
||||||
|
MCP_STRUCTURED_LIMIT,
|
||||||
|
0,
|
||||||
|
original,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let original_items = success.content.len();
|
||||||
|
if original_items > MCP_CONTENT_ITEM_LIMIT {
|
||||||
|
success.content.truncate(MCP_CONTENT_ITEM_LIMIT);
|
||||||
|
notices.push(format!(
|
||||||
|
"[truncated: MCP content items exceeded {MCP_CONTENT_ITEM_LIMIT} items; showing {MCP_CONTENT_ITEM_LIMIT} of {original_items} items]"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut remaining_text = MCP_TEXT_LIMIT;
|
||||||
|
let mut content = Vec::with_capacity(success.content.len() + notices.len());
|
||||||
|
for mut item in std::mem::take(&mut success.content) {
|
||||||
|
match item.content.as_mut() {
|
||||||
|
Some(pb::mcp_tool_result_content_item::Content::Text(text)) => {
|
||||||
|
let original = text.text.clone();
|
||||||
|
let next = truncate_text("MCP content item", &original, MCP_TEXT_LIMIT);
|
||||||
|
if remaining_text == 0 {
|
||||||
|
notices.push(truncation_notice(
|
||||||
|
"MCP text",
|
||||||
|
MCP_TEXT_LIMIT,
|
||||||
|
MCP_TEXT_LIMIT,
|
||||||
|
MCP_TEXT_LIMIT.saturating_add(original.len()),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
text.text = truncate_text("MCP text", &next, remaining_text);
|
||||||
|
remaining_text = remaining_text.saturating_sub(text.text.len());
|
||||||
|
}
|
||||||
|
Some(pb::mcp_tool_result_content_item::Content::Image(image))
|
||||||
|
if image.data.len() > MCP_BINARY_LIMIT =>
|
||||||
|
{
|
||||||
|
let original = image.data.len();
|
||||||
|
image.data.truncate(MCP_BINARY_LIMIT);
|
||||||
|
notices.push(truncation_notice(
|
||||||
|
"MCP image data",
|
||||||
|
MCP_BINARY_LIMIT,
|
||||||
|
image.data.len(),
|
||||||
|
original,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
content.push(item);
|
||||||
|
}
|
||||||
|
content.extend(notices.into_iter().map(mcp_notice));
|
||||||
|
success.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mcp_notice(text: String) -> pb::McpToolResultContentItem {
|
||||||
|
pb::McpToolResultContentItem {
|
||||||
|
content: Some(pb::mcp_tool_result_content_item::Content::Text(
|
||||||
|
pb::McpTextContent {
|
||||||
|
text,
|
||||||
|
output_location: None,
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_mcp_notice(item: &pb::McpToolResultContentItem) -> bool {
|
||||||
|
matches!(
|
||||||
|
item.content.as_ref(),
|
||||||
|
Some(pb::mcp_tool_result_content_item::Content::Text(text))
|
||||||
|
if text.text.starts_with("[truncated:")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn structured_json_len(value: &Option<prost_types::Struct>) -> usize {
|
||||||
|
value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| {
|
||||||
|
serde_json::to_vec(&serde_json::Value::Object(
|
||||||
|
value
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), super::prost_json(value)))
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.map_or(0, |value| value.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncated_struct(original: usize, limit: usize) -> Option<prost_types::Struct> {
|
||||||
|
Some(prost_types::Struct {
|
||||||
|
fields: BTreeMap::from([
|
||||||
|
("_truncated".into(), prost_bool(true)),
|
||||||
|
("original_json_bytes".into(), prost_number(original as f64)),
|
||||||
|
("limit_bytes".into(), prost_number(limit as f64)),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prost_bool(value: bool) -> prost_types::Value {
|
||||||
|
prost_types::Value {
|
||||||
|
kind: Some(prost_types::value::Kind::BoolValue(value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prost_number(value: f64) -> prost_types::Value {
|
||||||
|
prost_types::Value {
|
||||||
|
kind: Some(prost_types::value::Kind::NumberValue(value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_mcp_resources(tool: &mut pb::ListMcpResourcesToolCall) {
|
||||||
|
let Some(pb::list_mcp_resources_exec_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if success
|
||||||
|
.resources
|
||||||
|
.iter()
|
||||||
|
.any(|resource| resource.uri == "truncated:list-mcp-resources")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let original = success.resources.len();
|
||||||
|
success.resources.truncate(MCP_RESOURCE_LIMIT);
|
||||||
|
for resource in &mut success.resources {
|
||||||
|
if let Some(description) = resource.description.as_mut() {
|
||||||
|
*description = truncate_text(
|
||||||
|
"MCP resource description",
|
||||||
|
description,
|
||||||
|
MCP_RESOURCE_DESCRIPTION_LIMIT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success.resources.len() < original {
|
||||||
|
success
|
||||||
|
.resources
|
||||||
|
.push(pb::list_mcp_resources_exec_result::McpResource {
|
||||||
|
uri: "truncated:list-mcp-resources".into(),
|
||||||
|
name: Some("truncated".into()),
|
||||||
|
description: Some(truncation_notice(
|
||||||
|
"ListMcpResources",
|
||||||
|
MCP_TEXT_LIMIT,
|
||||||
|
success.resources.len(),
|
||||||
|
original,
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_mcp_resource(tool: &mut pb::ReadMcpResourceToolCall) {
|
||||||
|
let Some(pb::read_mcp_resource_exec_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match success.content.as_mut() {
|
||||||
|
Some(pb::read_mcp_resource_success::Content::Text(text)) => {
|
||||||
|
*text = truncate_text("FetchMcpResource", text, MCP_TEXT_LIMIT);
|
||||||
|
}
|
||||||
|
Some(pb::read_mcp_resource_success::Content::Blob(blob))
|
||||||
|
if blob.len() > MCP_BINARY_LIMIT =>
|
||||||
|
{
|
||||||
|
let notice =
|
||||||
|
truncation_notice("FetchMcpResource blob", MCP_BINARY_LIMIT, 0, blob.len());
|
||||||
|
success.content = Some(pb::read_mcp_resource_success::Content::Text(notice));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_mcp_tools(tool: &mut pb::GetMcpToolsToolCall) {
|
||||||
|
let Some(pb::get_mcp_tools_agent_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
success.content = truncate_text("GetMcpTools", &success.content, MCP_TEXT_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_web_fetch(tool: &mut pb::WebFetchToolCall) {
|
||||||
|
let Some(pb::web_fetch_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
success.markdown = truncate_text("WebFetch", &success.markdown, WEB_FETCH_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_web_search(tool: &mut pb::WebSearchToolCall) {
|
||||||
|
let Some(pb::web_search_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for reference in &mut success.references {
|
||||||
|
reference.title =
|
||||||
|
truncate_text("WebSearch title", &reference.title, WEB_SEARCH_TITLE_LIMIT);
|
||||||
|
reference.chunk = truncate_text(
|
||||||
|
"WebSearch snippet",
|
||||||
|
&reference.chunk,
|
||||||
|
WEB_SEARCH_SNIPPET_LIMIT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let original = web_search_bytes(&success.references);
|
||||||
|
while success.references.len() > 1 && web_search_bytes(&success.references) > WEB_SEARCH_LIMIT {
|
||||||
|
success.references.pop();
|
||||||
|
}
|
||||||
|
if original > WEB_SEARCH_LIMIT {
|
||||||
|
let total = web_search_bytes(&success.references);
|
||||||
|
if let Some(reference) = success.references.last_mut() {
|
||||||
|
let other = total.saturating_sub(reference.chunk.len());
|
||||||
|
let notice = truncation_notice(
|
||||||
|
"WebSearch",
|
||||||
|
WEB_SEARCH_LIMIT,
|
||||||
|
WEB_SEARCH_LIMIT.saturating_sub(other),
|
||||||
|
original,
|
||||||
|
);
|
||||||
|
let available = WEB_SEARCH_LIMIT.saturating_sub(other + notice.len() + 2);
|
||||||
|
reference.chunk = format!(
|
||||||
|
"{}\n\n{notice}",
|
||||||
|
utf8_prefix(&reference.chunk, available).trim_end_matches('\n')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn web_search_bytes(references: &[pb::WebSearchReference]) -> usize {
|
||||||
|
references
|
||||||
|
.iter()
|
||||||
|
.map(|reference| reference.title.len() + reference.url.len() + reference.chunk.len())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gate_generate_image(tool: &mut pb::GenerateImageToolCall) {
|
||||||
|
let Some(pb::generate_image_result::Result::Success(success)) = tool
|
||||||
|
.result
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|result| result.result.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !success.image_data.trim().is_empty()
|
||||||
|
&& !success
|
||||||
|
.image_data
|
||||||
|
.starts_with("[base64 image data omitted from replay; bytes=")
|
||||||
|
{
|
||||||
|
let original = success.image_data.trim().len();
|
||||||
|
success.image_data = format!("[base64 image data omitted from replay; bytes={original}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_text(tool_name: &str, content: &str, limit: usize) -> String {
|
||||||
|
if content.len() <= limit {
|
||||||
|
return content.to_string();
|
||||||
|
}
|
||||||
|
let original = content.len();
|
||||||
|
let mut shown = limit;
|
||||||
|
loop {
|
||||||
|
let notice = format!(
|
||||||
|
"\n\n[truncated: {tool_name} result exceeded {limit} bytes; showing {shown} of {original} bytes]"
|
||||||
|
);
|
||||||
|
let available = limit.saturating_sub(notice.len());
|
||||||
|
let kept = utf8_prefix(content, available);
|
||||||
|
if kept.len() == shown {
|
||||||
|
return format!("{}{notice}", kept.trim_end_matches('\n'));
|
||||||
|
}
|
||||||
|
shown = kept.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn truncate_edges(tool_name: &str, content: &str, limit: usize) -> String {
|
fn truncate_edges(tool_name: &str, content: &str, limit: usize) -> String {
|
||||||
if content.len() <= limit {
|
if content.len() <= limit {
|
||||||
return content.to_string();
|
return content.to_string();
|
||||||
@@ -64,6 +673,12 @@ fn truncate_edges(tool_name: &str, content: &str, limit: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn truncation_notice(tool_name: &str, limit: usize, shown: usize, original: usize) -> String {
|
||||||
|
format!(
|
||||||
|
"[truncated: {tool_name} result exceeded {limit} bytes; showing {shown} of {original} bytes]"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn utf8_prefix(value: &str, limit: usize) -> &str {
|
fn utf8_prefix(value: &str, limit: usize) -> &str {
|
||||||
let mut end = limit.min(value.len());
|
let mut end = limit.min(value.len());
|
||||||
while end > 0 && !value.is_char_boundary(end) {
|
while end > 0 && !value.is_char_boundary(end) {
|
||||||
@@ -84,33 +699,212 @@ fn utf8_suffix(value: &str, limit: usize) -> &str {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn shell_tool() -> pb::tool_call::Tool {
|
|
||||||
pb::tool_call::Tool::ShellToolCall(pb::ShellToolCall::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shell_output_keeps_both_ends_within_its_budget() {
|
fn shell_output_keeps_both_ends_within_its_budget() {
|
||||||
let mut content = format!("HEAD{}TAIL", " ".repeat(1024 * KIB));
|
let mut content = format!("HEAD{}TAIL", " ".repeat(1024 * KIB));
|
||||||
|
let mut tool = pb::tool_call::Tool::ShellToolCall(pb::ShellToolCall::default());
|
||||||
|
|
||||||
model_content(&shell_tool(), &mut content);
|
tool_completion("Shell", &mut tool, &mut content);
|
||||||
|
|
||||||
assert!(content.len() <= SHELL_CONTENT_LIMIT);
|
assert!(content.len() <= 128 * KIB);
|
||||||
assert!(content.starts_with("HEAD"));
|
assert!(content.starts_with("HEAD"));
|
||||||
assert!(content.ends_with("TAIL"));
|
assert!(content.contains("[truncated: Shell result exceeded"));
|
||||||
assert!(content.contains("omitted middle"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_shell_output_is_unchanged() {
|
fn grep_limits_matches_per_file_total_bytes_and_adds_notice() {
|
||||||
|
let matches = (0..150)
|
||||||
|
.map(|line_number| pb::GrepContentMatch {
|
||||||
|
line_number,
|
||||||
|
content: "x".repeat(3 * KIB),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut tool = pb::tool_call::Tool::GrepToolCall(pb::GrepToolCall {
|
||||||
|
result: Some(pb::GrepResult {
|
||||||
|
result: Some(pb::grep_result::Result::Success(pb::GrepSuccess {
|
||||||
|
workspace_results: std::collections::HashMap::from([(
|
||||||
|
"workspace".into(),
|
||||||
|
pb::GrepUnionResult {
|
||||||
|
result: Some(pb::grep_union_result::Result::Content(
|
||||||
|
pb::GrepContentResult {
|
||||||
|
matches: vec![pb::GrepFileMatch {
|
||||||
|
file: "large.txt".into(),
|
||||||
|
matches,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut model_content = "x".repeat(128 * KIB);
|
||||||
|
|
||||||
|
tool_completion("Grep", &mut tool, &mut model_content);
|
||||||
|
|
||||||
|
assert!(model_content.len() <= GREP_CONTENT_LIMIT);
|
||||||
|
assert!(model_content.contains("[truncated: Grep result exceeded"));
|
||||||
|
let pb::tool_call::Tool::GrepToolCall(tool) = tool else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let Some(pb::grep_result::Result::Success(success)) =
|
||||||
|
tool.result.clone().and_then(|result| result.result)
|
||||||
|
else {
|
||||||
|
panic!("expected grep success")
|
||||||
|
};
|
||||||
|
let result = success.workspace_results.get("workspace").unwrap();
|
||||||
|
let Some(pb::grep_union_result::Result::Content(content)) = result.result.as_ref() else {
|
||||||
|
panic!("expected grep content")
|
||||||
|
};
|
||||||
|
assert!(content.client_truncated);
|
||||||
|
assert!(grep_content_bytes(&content.matches) <= GREP_CONTENT_LIMIT);
|
||||||
|
assert!(content.matches[0].matches.len() <= GREP_MATCHES_PER_FILE + 1);
|
||||||
|
assert!(content.matches[0]
|
||||||
|
.matches
|
||||||
|
.last()
|
||||||
|
.unwrap()
|
||||||
|
.content
|
||||||
|
.contains("[truncated: Grep result exceeded"));
|
||||||
|
|
||||||
|
let once = tool.clone();
|
||||||
|
let mut tool_enum = pb::tool_call::Tool::GrepToolCall(tool);
|
||||||
|
let mut second_content = model_content.clone();
|
||||||
|
tool_completion("Grep", &mut tool_enum, &mut second_content);
|
||||||
|
let pb::tool_call::Tool::GrepToolCall(second) = tool_enum else {
|
||||||
|
panic!("expected grep tool")
|
||||||
|
};
|
||||||
|
assert_eq!(second, once);
|
||||||
|
assert_eq!(second_content, model_content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_content_is_limited_and_marked() {
|
||||||
|
let mut tool = pb::tool_call::Tool::ReadToolCall(pb::ReadToolCall {
|
||||||
|
result: Some(pb::ReadToolResult {
|
||||||
|
result: Some(pb::read_tool_result::Result::Success(pb::ReadToolSuccess {
|
||||||
|
output: Some(pb::read_tool_success::Output::Content(
|
||||||
|
"前".repeat(READ_CONTENT_LIMIT),
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut content = "前".repeat(READ_CONTENT_LIMIT);
|
||||||
|
|
||||||
|
tool_completion("Read", &mut tool, &mut content);
|
||||||
|
|
||||||
|
assert!(content.len() <= READ_CONTENT_LIMIT);
|
||||||
|
let pb::tool_call::Tool::ReadToolCall(tool) = tool else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let Some(pb::read_tool_result::Result::Success(success)) =
|
||||||
|
tool.result.and_then(|result| result.result)
|
||||||
|
else {
|
||||||
|
panic!("expected read success")
|
||||||
|
};
|
||||||
|
assert!(success.exceeded_limit);
|
||||||
|
let Some(pb::read_tool_success::Output::Content(output)) = success.output else {
|
||||||
|
panic!("expected text output")
|
||||||
|
};
|
||||||
|
assert!(output.len() <= READ_CONTENT_LIMIT);
|
||||||
|
assert!(output.contains("[truncated: Read result exceeded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_limits_items_text_and_structured_content() {
|
||||||
|
let mut tool = pb::tool_call::Tool::McpToolCall(pb::McpToolCall {
|
||||||
|
result: Some(pb::McpToolResult {
|
||||||
|
result: Some(pb::mcp_tool_result::Result::Success(pb::McpSuccess {
|
||||||
|
content: (0..25)
|
||||||
|
.map(|_| pb::McpToolResultContentItem {
|
||||||
|
content: Some(pb::mcp_tool_result_content_item::Content::Text(
|
||||||
|
pb::McpTextContent {
|
||||||
|
text: "x".repeat(4 * KIB),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
structured_content: Some(prost_types::Struct {
|
||||||
|
fields: BTreeMap::from([(
|
||||||
|
"large".into(),
|
||||||
|
prost_types::Value {
|
||||||
|
kind: Some(prost_types::value::Kind::StringValue(
|
||||||
|
"x".repeat(64 * KIB),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
let mut content = "x".repeat(64 * KIB);
|
let mut content = "x".repeat(64 * KIB);
|
||||||
let original = content.clone();
|
|
||||||
|
|
||||||
model_content(
|
tool_completion("CallMcpTool", &mut tool, &mut content);
|
||||||
&pb::tool_call::Tool::ReadToolCall(pb::ReadToolCall::default()),
|
|
||||||
&mut content,
|
assert!(content.len() <= MCP_TEXT_LIMIT);
|
||||||
|
let pb::tool_call::Tool::McpToolCall(tool) = tool else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let Some(pb::mcp_tool_result::Result::Success(success)) =
|
||||||
|
tool.result.and_then(|result| result.result)
|
||||||
|
else {
|
||||||
|
panic!("expected mcp success")
|
||||||
|
};
|
||||||
|
assert!(success.content.len() > MCP_CONTENT_ITEM_LIMIT);
|
||||||
|
assert_eq!(
|
||||||
|
success
|
||||||
|
.structured_content
|
||||||
|
.unwrap()
|
||||||
|
.fields
|
||||||
|
.get("_truncated")
|
||||||
|
.unwrap()
|
||||||
|
.kind,
|
||||||
|
Some(prost_types::value::Kind::BoolValue(true))
|
||||||
);
|
);
|
||||||
|
assert!(success.content.iter().any(|item| matches!(
|
||||||
|
item.content.as_ref(),
|
||||||
|
Some(pb::mcp_tool_result_content_item::Content::Text(text))
|
||||||
|
if text.text.contains("MCP content items exceeded")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
assert_eq!(content, original);
|
#[test]
|
||||||
|
fn edit_keeps_only_a_bounded_diff() {
|
||||||
|
let mut tool = pb::tool_call::Tool::EditToolCall(pb::EditToolCall {
|
||||||
|
result: Some(pb::EditResult {
|
||||||
|
result: Some(pb::edit_result::Result::Success(pb::EditSuccess {
|
||||||
|
diff_string: Some("d".repeat(16 * KIB)),
|
||||||
|
before_full_file_content: Some("b".repeat(64 * KIB)),
|
||||||
|
after_full_file_content: "a".repeat(64 * KIB),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut content = "x".repeat(64 * KIB);
|
||||||
|
|
||||||
|
tool_completion("StrReplace", &mut tool, &mut content);
|
||||||
|
|
||||||
|
assert!(content.len() <= PATCH_EDIT_RESULT_LIMIT);
|
||||||
|
let pb::tool_call::Tool::EditToolCall(tool) = tool else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let Some(pb::edit_result::Result::Success(success)) =
|
||||||
|
tool.result.and_then(|result| result.result)
|
||||||
|
else {
|
||||||
|
panic!("expected edit success")
|
||||||
|
};
|
||||||
|
assert!(success.diff_string.unwrap().len() <= PATCH_EDIT_RESULT_LIMIT);
|
||||||
|
assert!(success.before_full_file_content.is_none());
|
||||||
|
assert!(success.after_full_file_content.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -139,31 +933,6 @@ mod tests {
|
|||||||
assert!(success.stderr.len() <= SHELL_STREAM_LIMIT);
|
assert!(success.stderr.len() <= SHELL_STREAM_LIMIT);
|
||||||
assert!(success.stderr.starts_with("ERROR_HEAD"));
|
assert!(success.stderr.starts_with("ERROR_HEAD"));
|
||||||
assert!(success.stderr.ends_with("ERROR_TAIL"));
|
assert!(success.stderr.ends_with("ERROR_TAIL"));
|
||||||
assert!(success.interleaved_output.unwrap().len() <= SHELL_CONTENT_LIMIT);
|
assert!(success.interleaved_output.unwrap().len() <= SHELL_INTERLEAVED_LIMIT);
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn failed_shell_streams_are_limited() {
|
|
||||||
let mut message = pb::exec_client_message::Message::ShellResult(pb::ShellResult {
|
|
||||||
result: Some(pb::shell_result::Result::Failure(pb::ShellFailure {
|
|
||||||
stdout: "x".repeat(64 * KIB),
|
|
||||||
stderr: "y".repeat(64 * KIB),
|
|
||||||
interleaved_output: Some("z".repeat(64 * KIB)),
|
|
||||||
..Default::default()
|
|
||||||
})),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
exec_message(&mut message);
|
|
||||||
|
|
||||||
let pb::exec_client_message::Message::ShellResult(result) = message else {
|
|
||||||
panic!("expected Shell result");
|
|
||||||
};
|
|
||||||
let Some(pb::shell_result::Result::Failure(failure)) = result.result else {
|
|
||||||
panic!("expected Shell failure");
|
|
||||||
};
|
|
||||||
assert!(failure.stdout.len() <= SHELL_STREAM_LIMIT);
|
|
||||||
assert!(failure.stderr.len() <= SHELL_STREAM_LIMIT);
|
|
||||||
assert!(failure.interleaved_output.unwrap().len() <= SHELL_CONTENT_LIMIT);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
mod await_shell;
|
|
||||||
mod exec;
|
mod exec;
|
||||||
mod gate;
|
mod gate;
|
||||||
mod interaction;
|
mod interaction;
|
||||||
@@ -19,7 +18,6 @@ use crate::{
|
|||||||
|
|
||||||
use super::runtime::now_ms;
|
use super::runtime::now_ms;
|
||||||
|
|
||||||
pub(crate) use await_shell::{await_error, await_result, await_sleep};
|
|
||||||
pub(crate) use exec::{edit_failure, from_exec};
|
pub(crate) use exec::{edit_failure, from_exec};
|
||||||
pub(crate) use interaction::{complete_web_fetch, complete_web_search, from_interaction};
|
pub(crate) use interaction::{complete_web_fetch, complete_web_search, from_interaction};
|
||||||
pub(crate) use local::{local, subagents_disabled, todo_items};
|
pub(crate) use local::{local, subagents_disabled, todo_items};
|
||||||
@@ -89,9 +87,12 @@ impl ToolCompletion {
|
|||||||
call: &ToolCall,
|
call: &ToolCall,
|
||||||
started_at_ms: u64,
|
started_at_ms: u64,
|
||||||
mut result: ToolResult,
|
mut result: ToolResult,
|
||||||
tool: pb::tool_call::Tool,
|
mut tool: pb::tool_call::Tool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
gate::model_content(&tool, &mut result.content);
|
// Apply the model-visible size gate once, at the tool completion
|
||||||
|
// boundary. Canonical history and every provider projection then
|
||||||
|
// carry the same bounded result without reprocessing it.
|
||||||
|
gate::tool_completion(&call.name, &mut tool, &mut result.content);
|
||||||
Self {
|
Self {
|
||||||
result,
|
result,
|
||||||
tool_call: pb::ToolCall {
|
tool_call: pb::ToolCall {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::{HashMap, HashSet},
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicU32, Ordering},
|
atomic::{AtomicU32, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
},
|
},
|
||||||
time::Instant,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -19,6 +18,7 @@ pub struct CursorToolRuntime {
|
|||||||
execs: Arc<Mutex<HashMap<u32, PendingExec>>>,
|
execs: Arc<Mutex<HashMap<u32, PendingExec>>>,
|
||||||
interactions: Arc<Mutex<HashMap<u32, PendingInteraction>>>,
|
interactions: Arc<Mutex<HashMap<u32, PendingInteraction>>>,
|
||||||
completed: Arc<Mutex<HashMap<u32, String>>>,
|
completed: Arc<Mutex<HashMap<u32, String>>>,
|
||||||
|
interrupted: Arc<Mutex<HashSet<u32>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct PendingExec {
|
pub(crate) struct PendingExec {
|
||||||
@@ -35,14 +35,6 @@ pub(crate) enum ExecStage {
|
|||||||
DynamicMcp(pb::McpToolDefinition),
|
DynamicMcp(pb::McpToolDefinition),
|
||||||
EditRead,
|
EditRead,
|
||||||
EditWrite(EditWrite),
|
EditWrite(EditWrite),
|
||||||
Await(AwaitState),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct AwaitState {
|
|
||||||
pub deadline: Instant,
|
|
||||||
pub output_file_path: String,
|
|
||||||
pub task_id: String,
|
|
||||||
pub regex: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -171,60 +163,6 @@ impl CursorToolRuntime {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn reserve_await(
|
|
||||||
&self,
|
|
||||||
call: &ToolCall,
|
|
||||||
context: &ExecContext,
|
|
||||||
) -> Result<u32> {
|
|
||||||
let task_id = call
|
|
||||||
.arguments
|
|
||||||
.get("shell_id")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.ok_or_else(|| Error::Protocol("AwaitShell is missing shell_id".into()))?;
|
|
||||||
let block_ms = call
|
|
||||||
.arguments
|
|
||||||
.get("block_until_ms")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.unwrap_or(30_000);
|
|
||||||
if block_ms > 7_140_000 {
|
|
||||||
return Err(Error::Protocol(
|
|
||||||
"AwaitShell block_until_ms exceeds 7140000".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let output_file_path = format!(
|
|
||||||
"{}/{}.txt",
|
|
||||||
context.terminals_folder.trim_end_matches('/'),
|
|
||||||
task_id
|
|
||||||
);
|
|
||||||
self.reserve_exec_stage(
|
|
||||||
call,
|
|
||||||
context,
|
|
||||||
ExecStage::Await(AwaitState {
|
|
||||||
deadline: Instant::now() + std::time::Duration::from_millis(block_ms),
|
|
||||||
output_file_path,
|
|
||||||
task_id: task_id.to_string(),
|
|
||||||
regex: call
|
|
||||||
.arguments
|
|
||||||
.get("pattern")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn reserve_await_again(
|
|
||||||
&self,
|
|
||||||
call: &ToolCall,
|
|
||||||
context: &ExecContext,
|
|
||||||
state: AwaitState,
|
|
||||||
started_at_ms: u64,
|
|
||||||
) -> Result<u32> {
|
|
||||||
self.reserve_exec_stage(call, context, ExecStage::Await(state), Some(started_at_ms))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn reserve_exec_stage(
|
async fn reserve_exec_stage(
|
||||||
&self,
|
&self,
|
||||||
call: &ToolCall,
|
call: &ToolCall,
|
||||||
@@ -311,6 +249,10 @@ impl CursorToolRuntime {
|
|||||||
self.completed.lock().await.get(&id).cloned()
|
self.completed.lock().await.get(&id).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn is_interrupted(&self, id: u32) -> bool {
|
||||||
|
self.interrupted.lock().await.contains(&id)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn clear_completed(&self) {
|
pub async fn clear_completed(&self) {
|
||||||
self.completed.lock().await.clear();
|
self.completed.lock().await.clear();
|
||||||
}
|
}
|
||||||
@@ -329,9 +271,39 @@ impl CursorToolRuntime {
|
|||||||
ids.sort_unstable();
|
ids.sort_unstable();
|
||||||
self.interactions.lock().await.clear();
|
self.interactions.lock().await.clear();
|
||||||
self.completed.lock().await.clear();
|
self.completed.lock().await.clear();
|
||||||
|
self.interrupted.lock().await.clear();
|
||||||
ids
|
ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn interrupt_for_message(&self) -> Vec<u32> {
|
||||||
|
let (abort_ids, interrupted_ids) = {
|
||||||
|
let mut entries = self.execs.lock().await;
|
||||||
|
let mut abort_ids = Vec::new();
|
||||||
|
let mut interrupted_ids = Vec::new();
|
||||||
|
entries.retain(|id, entry| {
|
||||||
|
interrupted_ids.push(*id);
|
||||||
|
let keep_running = entry.call.name.eq_ignore_ascii_case("Task");
|
||||||
|
if !keep_running {
|
||||||
|
abort_ids.push(*id);
|
||||||
|
}
|
||||||
|
keep_running
|
||||||
|
});
|
||||||
|
(abort_ids, interrupted_ids)
|
||||||
|
};
|
||||||
|
let interaction_ids = {
|
||||||
|
let mut interactions = self.interactions.lock().await;
|
||||||
|
let ids = interactions.keys().copied().collect::<Vec<_>>();
|
||||||
|
interactions.clear();
|
||||||
|
ids
|
||||||
|
};
|
||||||
|
let mut interrupted = self.interrupted.lock().await;
|
||||||
|
interrupted.extend(interrupted_ids);
|
||||||
|
interrupted.extend(interaction_ids);
|
||||||
|
let mut abort_ids = abort_ids;
|
||||||
|
abort_ids.sort_unstable();
|
||||||
|
abort_ids
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn running_exec_ids(&self) -> Vec<u32> {
|
pub async fn running_exec_ids(&self) -> Vec<u32> {
|
||||||
let mut ids = self.execs.lock().await.keys().copied().collect::<Vec<_>>();
|
let mut ids = self.execs.lock().await.keys().copied().collect::<Vec<_>>();
|
||||||
ids.sort_unstable();
|
ids.sort_unstable();
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ pub(super) struct DeferredEdit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EditSchedule {
|
impl EditSchedule {
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.paths.clear();
|
||||||
|
self.active_paths.clear();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn start_or_defer(&mut self, path: String, edit: DeferredEdit) -> Option<DeferredEdit> {
|
pub fn start_or_defer(&mut self, path: String, edit: DeferredEdit) -> Option<DeferredEdit> {
|
||||||
if let Some(queue) = self.paths.get_mut(&path) {
|
if let Some(queue) = self.paths.get_mut(&path) {
|
||||||
queue.waiting.push_back(edit);
|
queue.waiting.push_back(edit);
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ impl ModelConfig {
|
|||||||
|
|
||||||
pub fn configure(&self, model: &mut super::ModelSpec) {
|
pub fn configure(&self, model: &mut super::ModelSpec) {
|
||||||
model.display_name = Some(self.display_name.clone());
|
model.display_name = Some(self.display_name.clone());
|
||||||
|
// A request-selected context is authoritative. Use the saved model
|
||||||
|
// value only when Cursor did not send a context parameter.
|
||||||
|
if model.context_window_tokens.is_none() {
|
||||||
|
model.context_window_tokens = self.context_window_tokens;
|
||||||
|
}
|
||||||
if model.reasoning.effort.is_none() {
|
if model.reasoning.effort.is_none() {
|
||||||
model.reasoning.effort = match self.model_type {
|
model.reasoning.effort = match self.model_type {
|
||||||
ModelType::OpenAi => self.reasoning_effort.clone(),
|
ModelType::OpenAi => self.reasoning_effort.clone(),
|
||||||
@@ -505,4 +510,77 @@ mod tests {
|
|||||||
"https://example.com/v1/messages"
|
"https://example.com/v1/messages"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_context_window_does_not_override_the_client_request() {
|
||||||
|
let input = input();
|
||||||
|
let config = ModelConfig {
|
||||||
|
model_hash: "hash".into(),
|
||||||
|
sort_order: input.sort_order,
|
||||||
|
display_name: input.display_name,
|
||||||
|
model_type: input.model_type,
|
||||||
|
base_url: input.base_url,
|
||||||
|
use_full_url: input.use_full_url,
|
||||||
|
api_key: input.api_key,
|
||||||
|
tooltip_data: input.tooltip_data,
|
||||||
|
model_id: input.model_id,
|
||||||
|
reasoning_effort: input.reasoning_effort,
|
||||||
|
openai_endpoint: input.openai_endpoint,
|
||||||
|
openai_extra_params_enabled: input.openai_extra_params_enabled,
|
||||||
|
openai_extra_params: input.openai_extra_params,
|
||||||
|
custom_headers_enabled: input.custom_headers_enabled,
|
||||||
|
custom_headers: input.custom_headers,
|
||||||
|
anthropic_extra_params_enabled: input.anthropic_extra_params_enabled,
|
||||||
|
anthropic_extra_params: input.anthropic_extra_params,
|
||||||
|
context_window_tokens: Some(350_000),
|
||||||
|
max_completion_tokens: input.max_completion_tokens,
|
||||||
|
anthropic_max_tokens: input.anthropic_max_tokens,
|
||||||
|
anthropic_thinking_effort: input.anthropic_thinking_effort,
|
||||||
|
thinking_budget_tokens: input.thinking_budget_tokens,
|
||||||
|
created_at_ms: 0,
|
||||||
|
updated_at_ms: 0,
|
||||||
|
};
|
||||||
|
let mut requested = super::super::ModelSpec::new("model-a");
|
||||||
|
requested.context_window_tokens = Some(200_000);
|
||||||
|
|
||||||
|
config.configure(&mut requested);
|
||||||
|
|
||||||
|
assert_eq!(requested.context_window_tokens, Some(200_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_context_window_fills_missing_client_value() {
|
||||||
|
let input = input();
|
||||||
|
let config = ModelConfig {
|
||||||
|
model_hash: "hash".into(),
|
||||||
|
sort_order: input.sort_order,
|
||||||
|
display_name: input.display_name,
|
||||||
|
model_type: input.model_type,
|
||||||
|
base_url: input.base_url,
|
||||||
|
use_full_url: input.use_full_url,
|
||||||
|
api_key: input.api_key,
|
||||||
|
tooltip_data: input.tooltip_data,
|
||||||
|
model_id: input.model_id,
|
||||||
|
reasoning_effort: input.reasoning_effort,
|
||||||
|
openai_endpoint: input.openai_endpoint,
|
||||||
|
openai_extra_params_enabled: input.openai_extra_params_enabled,
|
||||||
|
openai_extra_params: input.openai_extra_params,
|
||||||
|
custom_headers_enabled: input.custom_headers_enabled,
|
||||||
|
custom_headers: input.custom_headers,
|
||||||
|
anthropic_extra_params_enabled: input.anthropic_extra_params_enabled,
|
||||||
|
anthropic_extra_params: input.anthropic_extra_params,
|
||||||
|
context_window_tokens: Some(350_000),
|
||||||
|
max_completion_tokens: input.max_completion_tokens,
|
||||||
|
anthropic_max_tokens: input.anthropic_max_tokens,
|
||||||
|
anthropic_thinking_effort: input.anthropic_thinking_effort,
|
||||||
|
thinking_budget_tokens: input.thinking_budget_tokens,
|
||||||
|
created_at_ms: 0,
|
||||||
|
updated_at_ms: 0,
|
||||||
|
};
|
||||||
|
let mut requested = super::super::ModelSpec::new("model-a");
|
||||||
|
|
||||||
|
config.configure(&mut requested);
|
||||||
|
|
||||||
|
assert_eq!(requested.context_window_tokens, Some(350_000));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod run;
|
|||||||
mod runtime_tag;
|
mod runtime_tag;
|
||||||
mod token_count;
|
mod token_count;
|
||||||
mod tool;
|
mod tool;
|
||||||
|
mod tool_result_replay;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
pub use configuration::*;
|
pub use configuration::*;
|
||||||
@@ -26,4 +27,5 @@ pub use run::*;
|
|||||||
pub use runtime_tag::*;
|
pub use runtime_tag::*;
|
||||||
pub(crate) use token_count::*;
|
pub(crate) use token_count::*;
|
||||||
pub use tool::*;
|
pub use tool::*;
|
||||||
|
pub(crate) use tool_result_replay::limit_tool_result_text;
|
||||||
pub use usage::*;
|
pub use usage::*;
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
const KIB: usize = 1024;
|
||||||
|
|
||||||
|
pub(crate) fn limit_tool_result_text(name: &str, content: &str) -> String {
|
||||||
|
let Some(limit) = replay_limit(name) else {
|
||||||
|
return content.to_string();
|
||||||
|
};
|
||||||
|
let content = match name.trim() {
|
||||||
|
"GenerateImage" => compact_generate_image(content),
|
||||||
|
"Shell" => compact_shell(content),
|
||||||
|
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" | "Edit" | "Write" => {
|
||||||
|
compact_edit(name, content)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.unwrap_or_else(|| content.to_string());
|
||||||
|
truncate_replay_text(name, &content, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay_limit(name: &str) -> Option<usize> {
|
||||||
|
match name.trim() {
|
||||||
|
"GenerateImage" | "WebSearch" => Some(16 * KIB),
|
||||||
|
"Read" => Some(64 * KIB),
|
||||||
|
"Shell" => Some(128 * KIB),
|
||||||
|
"Grep" | "Glob" => Some(32 * KIB),
|
||||||
|
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" => Some(4 * KIB),
|
||||||
|
"Edit" | "EditNotebook" | "Write" | "WebFetch" => Some(32 * KIB),
|
||||||
|
"CallMcpTool" | "FetchMcpResource" | "ListMcpResources" | "GetMcpTools"
|
||||||
|
| "SembleSearch" | "SembleFindRelated" => Some(32 * KIB),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_replay_text(name: &str, content: &str, limit: usize) -> String {
|
||||||
|
if content.len() <= limit {
|
||||||
|
return content.to_string();
|
||||||
|
}
|
||||||
|
let original = content.len();
|
||||||
|
let mut shown = limit;
|
||||||
|
loop {
|
||||||
|
let notice = format!(
|
||||||
|
"\n\n[truncated: {name} result exceeded {limit} bytes; showing {shown} of {original} bytes]"
|
||||||
|
);
|
||||||
|
let available = limit.saturating_sub(notice.len());
|
||||||
|
let kept = utf8_prefix(content, available);
|
||||||
|
if kept.len() == shown {
|
||||||
|
return format!("{}{notice}", kept.trim_end_matches('\n'));
|
||||||
|
}
|
||||||
|
shown = kept.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_generate_image(content: &str) -> Option<String> {
|
||||||
|
let mut value = serde_json::from_str::<Value>(content.trim()).ok()?;
|
||||||
|
if !replace_image_data(&mut value) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
serde_json::to_string(&value).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_image_data(value: &mut Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
let mut changed = false;
|
||||||
|
for (key, child) in object.iter_mut() {
|
||||||
|
if matches!(key.as_str(), "image_data" | "imageData") {
|
||||||
|
if let Value::String(data) = child {
|
||||||
|
if data.starts_with("[base64 image data omitted from replay; bytes=") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*child = Value::String(format!(
|
||||||
|
"[base64 image data omitted from replay; bytes={}]",
|
||||||
|
data.trim().len()
|
||||||
|
));
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed |= replace_image_data(child);
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
Value::Array(items) => items.iter_mut().any(replace_image_data),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_shell(content: &str) -> Option<String> {
|
||||||
|
let mut value = serde_json::from_str::<Value>(content.trim()).ok()?;
|
||||||
|
if !compact_shell_fields(&mut value) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
serde_json::to_string(&value).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_shell_fields(value: &mut Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
let mut changed = false;
|
||||||
|
for (key, child) in object.iter_mut() {
|
||||||
|
if let Value::String(text) = child {
|
||||||
|
let limit = match key.as_str() {
|
||||||
|
"stdout" | "stderr" => Some(16 * KIB),
|
||||||
|
"interleaved_output" | "interleavedOutput" => Some(32 * KIB),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
let next = truncate_middle(&format!("Shell {key}"), text, limit);
|
||||||
|
if next != *text {
|
||||||
|
*text = next;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed |= compact_shell_fields(child);
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
Value::Array(items) => items.iter_mut().any(compact_shell_fields),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_edit(name: &str, content: &str) -> Option<String> {
|
||||||
|
let value = serde_json::from_str::<Value>(content.trim()).ok()?;
|
||||||
|
let success = value.get("success")?.as_object()?;
|
||||||
|
let diff = success
|
||||||
|
.get("diff_string")
|
||||||
|
.or_else(|| success.get("diffString"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.map(|text| truncate_replay_text(name, text, edit_limit(name)));
|
||||||
|
if let Some(diff) = diff {
|
||||||
|
return Some(serde_json::json!({"success": {"diff_string": diff}}).to_string());
|
||||||
|
}
|
||||||
|
let after = success
|
||||||
|
.get("after_full_file_content")
|
||||||
|
.or_else(|| success.get("afterFullFileContent"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.map(|text| truncate_replay_text(name, text, edit_limit(name)));
|
||||||
|
after
|
||||||
|
.map(|after| serde_json::json!({"success": {"after_full_file_content": after}}).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_limit(name: &str) -> usize {
|
||||||
|
match name.trim() {
|
||||||
|
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" => 4 * KIB,
|
||||||
|
_ => 32 * KIB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_middle(name: &str, content: &str, limit: usize) -> String {
|
||||||
|
if content.len() <= limit {
|
||||||
|
return content.to_string();
|
||||||
|
}
|
||||||
|
let original = content.len();
|
||||||
|
let mut shown = limit;
|
||||||
|
loop {
|
||||||
|
let notice = format!(
|
||||||
|
"\n\n[truncated: {name} result exceeded {limit} bytes; omitted middle; showing {shown} of {original} bytes]\n\n"
|
||||||
|
);
|
||||||
|
let available = limit.saturating_sub(notice.len());
|
||||||
|
let head = utf8_prefix(content, available / 2);
|
||||||
|
let tail = utf8_suffix(content, available.saturating_sub(head.len()));
|
||||||
|
let next_shown = head.len() + tail.len();
|
||||||
|
let next_notice = format!(
|
||||||
|
"\n\n[truncated: {name} result exceeded {limit} bytes; omitted middle; showing {next_shown} of {original} bytes]\n\n"
|
||||||
|
);
|
||||||
|
let output = format!("{head}{next_notice}{tail}");
|
||||||
|
if output.len() <= limit || next_notice == notice {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
shown = next_shown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf8_prefix(value: &str, limit: usize) -> &str {
|
||||||
|
let mut end = limit.min(value.len());
|
||||||
|
while end > 0 && !value.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
&value[..end]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf8_suffix(value: &str, limit: usize) -> &str {
|
||||||
|
let mut start = value.len().saturating_sub(limit);
|
||||||
|
while start < value.len() && !value.is_char_boundary(start) {
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
&value[start..]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncation_preserves_utf8_and_limit() {
|
||||||
|
let content = "前".repeat(32 * KIB);
|
||||||
|
let truncated = truncate_replay_text("Grep", &content, 32 * KIB);
|
||||||
|
|
||||||
|
assert!(truncated.len() <= 32 * KIB);
|
||||||
|
assert!(truncated.is_char_boundary(truncated.len()));
|
||||||
|
assert!(truncated.contains("[truncated: Grep result exceeded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_replay_compacts_nested_image_data_and_shell_streams() {
|
||||||
|
let image = serde_json::json!({"success": {"image_data": "x".repeat(64 * KIB)}});
|
||||||
|
let image_result = limit_tool_result_text("GenerateImage", &image.to_string());
|
||||||
|
assert!(image_result.contains("base64 image data omitted"));
|
||||||
|
assert!(image_result.len() < 1024);
|
||||||
|
assert_eq!(
|
||||||
|
limit_tool_result_text("GenerateImage", &image_result),
|
||||||
|
image_result
|
||||||
|
);
|
||||||
|
|
||||||
|
let shell = serde_json::json!({"success": {"stdout": "x".repeat(64 * KIB)}});
|
||||||
|
let shell_result = limit_tool_result_text("Shell", &shell.to_string());
|
||||||
|
assert!(shell_result.len() <= 128 * KIB);
|
||||||
|
assert!(shell_result.contains("omitted middle"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ impl Provider for OpenAiChatProvider {
|
|||||||
}
|
}
|
||||||
let Some(choice) = value.get("choices").and_then(Value::as_array).and_then(|values| values.first()) else { continue; };
|
let Some(choice) = value.get("choices").and_then(Value::as_array).and_then(|values| values.first()) else { continue; };
|
||||||
let delta = choice.get("delta").unwrap_or(&Value::Null);
|
let delta = choice.get("delta").unwrap_or(&Value::Null);
|
||||||
if let Some(reasoning_delta) = delta.get("reasoning_content").and_then(Value::as_str).filter(|text| !text.is_empty()) {
|
if let Some(reasoning_delta) = delta.get("reasoning_content").or_else(|| delta.get("reasoning")).and_then(Value::as_str).filter(|text| !text.is_empty()) {
|
||||||
if !thinking_open { thinking_open = true; yield ModelEvent::ThinkingStart; }
|
if !thinking_open { thinking_open = true; yield ModelEvent::ThinkingStart; }
|
||||||
reasoning.push_str(reasoning_delta);
|
reasoning.push_str(reasoning_delta);
|
||||||
yield ModelEvent::ThinkingDelta(reasoning_delta.into());
|
yield ModelEvent::ThinkingDelta(reasoning_delta.into());
|
||||||
@@ -230,12 +230,27 @@ fn openai_chat_messages(instructions: &str, messages: &[ProjectedMessage]) -> Re
|
|||||||
calls,
|
calls,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
value.insert("content".into(), Value::String(text.clone()));
|
|
||||||
let replay_reasoning = replay_state
|
let replay_reasoning = replay_state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|state| state.provider_kind == "openai_chat")
|
.filter(|state| state.provider_kind == "openai_chat")
|
||||||
.and_then(|state| state.value.get("reasoning_content"))
|
.and_then(|state| state.value.get("reasoning_content"))
|
||||||
.and_then(Value::as_str);
|
.and_then(Value::as_str)
|
||||||
|
.filter(|reasoning| !reasoning.is_empty());
|
||||||
|
|
||||||
|
// Chat Completions rejects an empty assistant content string. Tool-call
|
||||||
|
// assistant messages use null content, while an assistant with no visible
|
||||||
|
// content at all does not need to be sent.
|
||||||
|
if text.is_empty() && calls.is_empty() && replay_reasoning.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
value.insert(
|
||||||
|
"content".into(),
|
||||||
|
if text.is_empty() {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
Value::String(text.clone())
|
||||||
|
},
|
||||||
|
);
|
||||||
if let Some(reasoning) = replay_reasoning {
|
if let Some(reasoning) = replay_reasoning {
|
||||||
value.insert("reasoning_content".into(), Value::String(reasoning.into()));
|
value.insert("reasoning_content".into(), Value::String(reasoning.into()));
|
||||||
}
|
}
|
||||||
@@ -398,7 +413,7 @@ mod tests {
|
|||||||
model::{ContentPart, ProjectedContent, ProjectedMessage, ToolResultContent},
|
model::{ContentPart, ProjectedContent, ProjectedMessage, ToolResultContent},
|
||||||
model::{ProviderReplayState, Role, ToolCallContent},
|
model::{ProviderReplayState, Role, ToolCallContent},
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chat_replay_state_is_encoded_as_reasoning_content() {
|
fn chat_replay_state_is_encoded_as_reasoning_content() {
|
||||||
@@ -430,6 +445,52 @@ mod tests {
|
|||||||
assert_eq!(messages[0]["tool_calls"][0]["id"], "call-1");
|
assert_eq!(messages[0]["tool_calls"][0]["id"], "call-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_tool_call_assistant_uses_null_content() {
|
||||||
|
let messages = openai_chat_messages(
|
||||||
|
"",
|
||||||
|
&[ProjectedMessage {
|
||||||
|
message_id: "test".into(),
|
||||||
|
role: Role::Assistant,
|
||||||
|
content: ProjectedContent::Assistant {
|
||||||
|
text: String::new(),
|
||||||
|
thinking: String::new(),
|
||||||
|
replay_state: None,
|
||||||
|
calls: vec![ToolCallContent {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call-1".into(),
|
||||||
|
name: "Read".into(),
|
||||||
|
arguments: json!({"path": "README.md"}),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(messages[0]["content"], Value::Null);
|
||||||
|
assert!(messages[0]["tool_calls"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_contentless_assistant_is_omitted() {
|
||||||
|
let messages = openai_chat_messages(
|
||||||
|
"",
|
||||||
|
&[ProjectedMessage {
|
||||||
|
message_id: "test".into(),
|
||||||
|
role: Role::Assistant,
|
||||||
|
content: ProjectedContent::Assistant {
|
||||||
|
text: String::new(),
|
||||||
|
thinking: String::new(),
|
||||||
|
replay_state: None,
|
||||||
|
calls: vec![],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(messages.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn another_provider_replay_does_not_invent_chat_reasoning_content() {
|
fn another_provider_replay_does_not_invent_chat_reasoning_content() {
|
||||||
let messages = openai_chat_messages(
|
let messages = openai_chat_messages(
|
||||||
|
|||||||
+86
-15
@@ -249,14 +249,14 @@ impl RunEngine {
|
|||||||
let mut pending_insertions = Vec::new();
|
let mut pending_insertions = Vec::new();
|
||||||
let cycle = loop {
|
let cycle = loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
result = &mut cycle => break result,
|
biased;
|
||||||
command = client.commands.recv() => {
|
command = client.commands.recv() => {
|
||||||
let message = match command {
|
let message = match command {
|
||||||
Some(ClientCommand::InsertMessages(insertion)) => {
|
Some(ClientCommand::InsertMessages(insertion)) => {
|
||||||
pending_insertions.push(insertion);
|
pending_insertions.push(insertion);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Some(ClientCommand::RuntimeMessage(message)) => message,
|
Some(ClientCommand::InterruptWithMessage(message)) => message,
|
||||||
Some(ClientCommand::RuntimeEvent(event)) => event.into_message(),
|
Some(ClientCommand::RuntimeEvent(event)) => event.into_message(),
|
||||||
Some(ClientCommand::Cancel) => {
|
Some(ClientCommand::Cancel) => {
|
||||||
cycle_cancellation.cancel();
|
cycle_cancellation.cancel();
|
||||||
@@ -321,7 +321,8 @@ impl RunEngine {
|
|||||||
Err(outcome) => return (outcome, usage),
|
Err(outcome) => return (outcome, usage),
|
||||||
};
|
};
|
||||||
continue 'model;
|
continue 'model;
|
||||||
}
|
},
|
||||||
|
result = &mut cycle => break result,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let cycle = match cycle {
|
let cycle = match cycle {
|
||||||
@@ -541,6 +542,9 @@ impl RunEngine {
|
|||||||
model.max_output_tokens = Some(COMPACTION_OUTPUT_TOKENS);
|
model.max_output_tokens = Some(COMPACTION_OUTPUT_TOKENS);
|
||||||
model.reasoning.enabled = false;
|
model.reasoning.enabled = false;
|
||||||
model.reasoning.effort = None;
|
model.reasoning.effort = None;
|
||||||
|
let instructions = crate::config::compaction_prompt_override()
|
||||||
|
.map_err(|error| RunOutcome::Failed(error.into()))?
|
||||||
|
.unwrap_or_else(|| COMPACTION_INSTRUCTIONS.into());
|
||||||
let invocation = crate::model::ModelInvocation {
|
let invocation = crate::model::ModelInvocation {
|
||||||
call_id: format!("{}:{provider_call_index}", prepared.run_id),
|
call_id: format!("{}:{provider_call_index}", prepared.run_id),
|
||||||
run_id: prepared.run_id.to_string(),
|
run_id: prepared.run_id.to_string(),
|
||||||
@@ -548,7 +552,7 @@ impl RunEngine {
|
|||||||
provider_call_index,
|
provider_call_index,
|
||||||
request: crate::model::ModelRequest {
|
request: crate::model::ModelRequest {
|
||||||
prompt: crate::model::PromptSpec {
|
prompt: crate::model::PromptSpec {
|
||||||
instructions: COMPACTION_INSTRUCTIONS.into(),
|
instructions,
|
||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
},
|
},
|
||||||
model,
|
model,
|
||||||
@@ -558,23 +562,68 @@ impl RunEngine {
|
|||||||
let cycle_cancellation = cancellation.child_token();
|
let cycle_cancellation = cancellation.child_token();
|
||||||
let (silent_events, mut discarded_events) = tokio::sync::mpsc::channel(256);
|
let (silent_events, mut discarded_events) = tokio::sync::mpsc::channel(256);
|
||||||
let drain = tokio::spawn(async move { while discarded_events.recv().await.is_some() {} });
|
let drain = tokio::spawn(async move { while discarded_events.recv().await.is_some() {} });
|
||||||
let cycle = consume_model_cycle(
|
let mut pending_insertions = Vec::new();
|
||||||
self.provider.stream(invocation, cycle_cancellation.clone()),
|
let mut interrupted_message = None;
|
||||||
&silent_events,
|
let cycle = {
|
||||||
&cycle_cancellation,
|
let cycle = consume_model_cycle(
|
||||||
)
|
self.provider.stream(invocation, cycle_cancellation.clone()),
|
||||||
.await;
|
&silent_events,
|
||||||
|
&cycle_cancellation,
|
||||||
|
);
|
||||||
|
tokio::pin!(cycle);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
command = client.commands.recv() => match command {
|
||||||
|
Some(ClientCommand::InsertMessages(insertion)) => {
|
||||||
|
pending_insertions.push(insertion);
|
||||||
|
}
|
||||||
|
Some(ClientCommand::InterruptWithMessage(message)) => {
|
||||||
|
cycle_cancellation.cancel();
|
||||||
|
interrupted_message = Some(message);
|
||||||
|
break cycle.await;
|
||||||
|
}
|
||||||
|
Some(ClientCommand::RuntimeEvent(event)) => {
|
||||||
|
cycle_cancellation.cancel();
|
||||||
|
interrupted_message = Some(event.into_message());
|
||||||
|
break cycle.await;
|
||||||
|
}
|
||||||
|
Some(ClientCommand::Cancel) => {
|
||||||
|
cycle_cancellation.cancel();
|
||||||
|
return Err(RunOutcome::Cancelled);
|
||||||
|
}
|
||||||
|
Some(ClientCommand::ClientClosed { error }) => {
|
||||||
|
cycle_cancellation.cancel();
|
||||||
|
return Err(RunOutcome::Failed(RunFailure::Client(error)));
|
||||||
|
}
|
||||||
|
Some(ClientCommand::ToolResult(_)) => {
|
||||||
|
cycle_cancellation.cancel();
|
||||||
|
return Err(RunOutcome::Failed(RunFailure::Protocol(
|
||||||
|
"received a tool result while automatic compaction was running".into(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cycle_cancellation.cancel();
|
||||||
|
return Err(client_failure());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
result = &mut cycle => break result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
drop(silent_events);
|
drop(silent_events);
|
||||||
let _ = drain.await;
|
let _ = drain.await;
|
||||||
let (summary, compaction_usage) = match cycle {
|
let (summary, compaction_usage) = match (interrupted_message.is_some(), cycle) {
|
||||||
Ok(cycle) if cycle.calls.is_empty() && !cycle.text.trim().is_empty() => {
|
(true, Ok(cycle)) => (fallback_summary(&compactable), cycle.usage),
|
||||||
|
(true, Err(failure)) => (fallback_summary(&compactable), failure.usage),
|
||||||
|
(false, Ok(cycle)) if cycle.calls.is_empty() && !cycle.text.trim().is_empty() => {
|
||||||
(cycle.text.trim().to_string(), cycle.usage)
|
(cycle.text.trim().to_string(), cycle.usage)
|
||||||
}
|
}
|
||||||
Ok(cycle) => {
|
(false, Ok(cycle)) => {
|
||||||
tracing::warn!("automatic compaction returned no usable summary; using fallback");
|
tracing::warn!("automatic compaction returned no usable summary; using fallback");
|
||||||
(fallback_summary(&compactable), cycle.usage)
|
(fallback_summary(&compactable), cycle.usage)
|
||||||
}
|
}
|
||||||
Err(failure) => {
|
(false, Err(failure)) => {
|
||||||
tracing::warn!(error = ?failure.failure, "automatic compaction model failed; using fallback");
|
tracing::warn!(error = ?failure.failure, "automatic compaction model failed; using fallback");
|
||||||
(fallback_summary(&compactable), failure.usage)
|
(fallback_summary(&compactable), failure.usage)
|
||||||
}
|
}
|
||||||
@@ -594,7 +643,7 @@ impl RunEngine {
|
|||||||
let mut replacement = retained_request_context.into_iter().collect::<Vec<_>>();
|
let mut replacement = retained_request_context.into_iter().collect::<Vec<_>>();
|
||||||
replacement.push(summary_message);
|
replacement.push(summary_message);
|
||||||
replacement.extend(prepared.initial_messages.iter().cloned());
|
replacement.extend(prepared.initial_messages.iter().cloned());
|
||||||
let revision = self
|
let mut revision = self
|
||||||
.store
|
.store
|
||||||
.replace_revision(
|
.replace_revision(
|
||||||
&prepared.conversation_id,
|
&prepared.conversation_id,
|
||||||
@@ -620,6 +669,28 @@ impl RunEngine {
|
|||||||
emit(client, ClientEvent::AutoCompactionCompleted)
|
emit(client, ClientEvent::AutoCompactionCompleted)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| client_failure())?;
|
.map_err(|_| client_failure())?;
|
||||||
|
revision = append_insertions(
|
||||||
|
&self.store,
|
||||||
|
prepared,
|
||||||
|
client,
|
||||||
|
cancellation,
|
||||||
|
revision,
|
||||||
|
pending_insertions,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.0;
|
||||||
|
if let Some(message) = interrupted_message {
|
||||||
|
revision = append_runtime_message(
|
||||||
|
&self.store,
|
||||||
|
prepared,
|
||||||
|
client,
|
||||||
|
cancellation,
|
||||||
|
revision,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.0;
|
||||||
|
}
|
||||||
Ok((revision, compaction_usage))
|
Ok((revision, compaction_usage))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-30
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -5,7 +7,7 @@ use crate::{
|
|||||||
ClientCommand, ClientEvent, ClientPort, CommitBarrier, CommitCause, MessageInsertion,
|
ClientCommand, ClientEvent, ClientPort, CommitBarrier, CommitCause, MessageInsertion,
|
||||||
StateCommitted,
|
StateCommitted,
|
||||||
},
|
},
|
||||||
model::{PreparedRun, RevisionId, ToolCall, ToolRoundAssistant, ToolRoundId},
|
model::{PreparedRun, RevisionId, ToolCall, ToolResult, ToolRoundAssistant, ToolRoundId},
|
||||||
store::Store,
|
store::Store,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ pub(super) async fn execute(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut remaining = calls.len();
|
let mut remaining = calls.len();
|
||||||
|
let mut completed_call_ids = HashSet::new();
|
||||||
let mut pending_runtime_messages = insertions
|
let mut pending_runtime_messages = insertions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(PendingRuntimeMessage::Insertion)
|
.map(PendingRuntimeMessage::Insertion)
|
||||||
@@ -92,6 +95,7 @@ pub(super) async fn execute(
|
|||||||
.await
|
.await
|
||||||
.map_err(failed)?;
|
.map_err(failed)?;
|
||||||
revision = committed.revision_id;
|
revision = committed.revision_id;
|
||||||
|
completed_call_ids.insert(call_id.clone());
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
round_id = %round_id,
|
round_id = %round_id,
|
||||||
call_id,
|
call_id,
|
||||||
@@ -113,7 +117,10 @@ pub(super) async fn execute(
|
|||||||
ClientEvent::StateCommitted(StateCommitted {
|
ClientEvent::StateCommitted(StateCommitted {
|
||||||
revision_id: revision,
|
revision_id: revision,
|
||||||
tool_round_version: committed.tool_round_version,
|
tool_round_version: committed.tool_round_version,
|
||||||
cause: CommitCause::ToolResult { call_id },
|
cause: CommitCause::ToolResult {
|
||||||
|
call_id,
|
||||||
|
interrupted: false,
|
||||||
|
},
|
||||||
barrier,
|
barrier,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -125,8 +132,66 @@ pub(super) async fn execute(
|
|||||||
Some(ClientCommand::RuntimeEvent(event)) => {
|
Some(ClientCommand::RuntimeEvent(event)) => {
|
||||||
pending_runtime_messages.push(PendingRuntimeMessage::Message(event.into_message()));
|
pending_runtime_messages.push(PendingRuntimeMessage::Message(event.into_message()));
|
||||||
}
|
}
|
||||||
Some(ClientCommand::RuntimeMessage(message)) => {
|
Some(ClientCommand::InterruptWithMessage(message)) => {
|
||||||
pending_runtime_messages.push(PendingRuntimeMessage::Message(message));
|
for call in calls
|
||||||
|
.iter()
|
||||||
|
.filter(|call| !completed_call_ids.contains(&call.call_id))
|
||||||
|
{
|
||||||
|
let result = ToolResult {
|
||||||
|
call_id: call.call_id.clone(),
|
||||||
|
content: "Tool execution was interrupted by a newer user message.".into(),
|
||||||
|
is_error: true,
|
||||||
|
image: None,
|
||||||
|
};
|
||||||
|
let committed = store
|
||||||
|
.commit_tool_result(
|
||||||
|
&prepared.conversation_id,
|
||||||
|
&prepared.run_id,
|
||||||
|
&round_id,
|
||||||
|
&result,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(failed)?;
|
||||||
|
revision = committed.revision_id;
|
||||||
|
let (barrier, ready) = if committed.settled {
|
||||||
|
let (barrier, ready) = CommitBarrier::before_continue();
|
||||||
|
(barrier, Some(ready))
|
||||||
|
} else {
|
||||||
|
(CommitBarrier::None, None)
|
||||||
|
};
|
||||||
|
send(
|
||||||
|
client,
|
||||||
|
ClientEvent::StateCommitted(StateCommitted {
|
||||||
|
revision_id: revision,
|
||||||
|
tool_round_version: committed.tool_round_version,
|
||||||
|
cause: CommitCause::ToolResult {
|
||||||
|
call_id: call.call_id.clone(),
|
||||||
|
interrupted: true,
|
||||||
|
},
|
||||||
|
barrier,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Some(ready) = ready {
|
||||||
|
super::engine::wait_for_state_ready(ready, cancellation).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pending in pending_runtime_messages {
|
||||||
|
revision =
|
||||||
|
append_pending(store, prepared, client, cancellation, revision, pending)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
revision = super::engine::append_runtime_message(
|
||||||
|
store,
|
||||||
|
prepared,
|
||||||
|
client,
|
||||||
|
cancellation,
|
||||||
|
revision,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.0;
|
||||||
|
return Ok(revision);
|
||||||
}
|
}
|
||||||
Some(ClientCommand::InsertMessages(insertion)) => {
|
Some(ClientCommand::InsertMessages(insertion)) => {
|
||||||
pending_runtime_messages.push(PendingRuntimeMessage::Insertion(insertion))
|
pending_runtime_messages.push(PendingRuntimeMessage::Insertion(insertion))
|
||||||
@@ -139,32 +204,7 @@ pub(super) async fn execute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for pending in pending_runtime_messages {
|
for pending in pending_runtime_messages {
|
||||||
match pending {
|
revision = append_pending(store, prepared, client, cancellation, revision, pending).await?;
|
||||||
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)
|
Ok(revision)
|
||||||
}
|
}
|
||||||
@@ -174,6 +214,38 @@ enum PendingRuntimeMessage {
|
|||||||
Insertion(MessageInsertion),
|
Insertion(MessageInsertion),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn append_pending(
|
||||||
|
store: &Store,
|
||||||
|
prepared: &PreparedRun,
|
||||||
|
client: &mut ClientPort,
|
||||||
|
cancellation: &CancellationToken,
|
||||||
|
revision: RevisionId,
|
||||||
|
pending: PendingRuntimeMessage,
|
||||||
|
) -> std::result::Result<RevisionId, RunOutcome> {
|
||||||
|
match pending {
|
||||||
|
PendingRuntimeMessage::Message(message) => Ok(super::engine::append_runtime_message(
|
||||||
|
store,
|
||||||
|
prepared,
|
||||||
|
client,
|
||||||
|
cancellation,
|
||||||
|
revision,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.0),
|
||||||
|
PendingRuntimeMessage::Insertion(insertion) => Ok(super::engine::append_insertions(
|
||||||
|
store,
|
||||||
|
prepared,
|
||||||
|
client,
|
||||||
|
cancellation,
|
||||||
|
revision,
|
||||||
|
vec![insertion],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn send(client: &ClientPort, event: ClientEvent) -> std::result::Result<(), RunOutcome> {
|
async fn send(client: &ClientPort, event: ClientEvent) -> std::result::Result<(), RunOutcome> {
|
||||||
client
|
client
|
||||||
.events
|
.events
|
||||||
|
|||||||
+657
-4
@@ -5,11 +5,15 @@ mod fixtures;
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
use cursor_server::{
|
use cursor_server::{
|
||||||
cursor::prompting::{PromptAssets, PromptCompiler},
|
cursor::prompting::{PromptAssets, PromptCompiler},
|
||||||
cursor::{connect, proto::agent::v1 as pb},
|
cursor::{connect, proto::agent::v1 as pb},
|
||||||
cursor::{CursorCommand, CursorSessionRegistry},
|
cursor::{CursorCommand, CursorSessionRegistry},
|
||||||
model::{ConversationId, ModelSpec, PreparedRun, PromptSpec, RunAction, RunId, RunKind},
|
model::{
|
||||||
|
ConversationId, ModelConfigInput, ModelSpec, ModelType, PreparedRun, PromptSpec, RunAction,
|
||||||
|
RunId, RunKind, Usage, OPENAI_CHAT_ENDPOINT,
|
||||||
|
},
|
||||||
provider::{FinishReason, ModelEvent},
|
provider::{FinishReason, ModelEvent},
|
||||||
run::RunRegistry,
|
run::RunRegistry,
|
||||||
store::RunStatus,
|
store::RunStatus,
|
||||||
@@ -428,6 +432,472 @@ async fn injected_user_context_restarts_only_the_active_model_cycle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn injected_user_context_aborts_pending_tools_and_ignores_late_results() {
|
||||||
|
let (_directory, store) = fixtures::temp_store().await;
|
||||||
|
let provider = fake_provider::FakeProvider::default();
|
||||||
|
provider.push(tool_response("call-1", "Read", "{\"path\":\"/tmp/a\"}"));
|
||||||
|
let release = provider.push_gated(text_response("continued after tool interruption"));
|
||||||
|
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("interrupt-tool-request")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut output = handle.subscribe();
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: 0,
|
||||||
|
message: Box::new(client_run_for(
|
||||||
|
"interrupt-tool-request",
|
||||||
|
"interrupt-tool-conversation",
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut append_seqno = 1;
|
||||||
|
let exec_id = wait_for_exec(&handle, &mut output, &mut append_seqno, "Read").await;
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: append_seqno,
|
||||||
|
message: Box::new(runtime_injection_for(
|
||||||
|
"tool-injection",
|
||||||
|
"interrupt-tool-request",
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
append_seqno += 1;
|
||||||
|
|
||||||
|
let mut saw_abort = false;
|
||||||
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
while provider.requests().len() < 2 || !saw_abort {
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"root model did not restart after tool interruption"
|
||||||
|
);
|
||||||
|
if let Ok(Some(frame)) =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(20), output.recv()).await
|
||||||
|
{
|
||||||
|
let (_, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
|
||||||
|
let server = pb::AgentServerMessage::decode(payload).unwrap();
|
||||||
|
if let Some(pb::agent_server_message::Message::ExecServerControlMessage(control)) =
|
||||||
|
server.message
|
||||||
|
{
|
||||||
|
if let Some(pb::exec_server_control_message::Message::Abort(abort)) =
|
||||||
|
control.message
|
||||||
|
{
|
||||||
|
assert_eq!(abort.id, exec_id);
|
||||||
|
saw_abort = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acknowledge_kv(&handle, &mut append_seqno, &frame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: append_seqno,
|
||||||
|
message: Box::new(read_success(exec_id)),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
append_seqno += 1;
|
||||||
|
release.notify_one();
|
||||||
|
|
||||||
|
drain_successfully(&handle, &mut output, &mut append_seqno).await;
|
||||||
|
|
||||||
|
let requests = provider.requests();
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].history,
|
||||||
|
requests[1].history[..requests[0].history.len()]
|
||||||
|
);
|
||||||
|
let history = serde_json::to_string(&requests[1].history).unwrap();
|
||||||
|
let interrupted = history
|
||||||
|
.find("Tool execution was interrupted by a newer user message.")
|
||||||
|
.expect("interrupted tool result missing from provider history");
|
||||||
|
let injected = history
|
||||||
|
.find("injected follow-up")
|
||||||
|
.expect("injected message missing from provider history");
|
||||||
|
assert!(interrupted < injected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn injected_user_context_detaches_subagents_without_cancelling_them() {
|
||||||
|
let (_directory, store) = fixtures::temp_store().await;
|
||||||
|
let provider = fake_provider::FakeProvider::default();
|
||||||
|
provider.push(tool_response(
|
||||||
|
"task-call",
|
||||||
|
"Task",
|
||||||
|
&serde_json::json!({
|
||||||
|
"description": "Inspect protocol",
|
||||||
|
"prompt": "Inspect the protocol",
|
||||||
|
"subagent_type": "generalPurpose",
|
||||||
|
"run_in_background": false
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
let release = provider.push_gated(text_response("continued while subagent runs"));
|
||||||
|
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("detach-subagent-request")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut output = handle.subscribe();
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: 0,
|
||||||
|
message: Box::new(client_run_for(
|
||||||
|
"detach-subagent-request",
|
||||||
|
"detach-subagent-conversation",
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut append_seqno = 1;
|
||||||
|
let exec_id = wait_for_exec(&handle, &mut output, &mut append_seqno, "Task").await;
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: append_seqno,
|
||||||
|
message: Box::new(runtime_injection_for(
|
||||||
|
"subagent-injection",
|
||||||
|
"detach-subagent-request",
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
append_seqno += 1;
|
||||||
|
|
||||||
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
while provider.requests().len() < 2 {
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"root model did not restart while subagent remained active"
|
||||||
|
);
|
||||||
|
if let Ok(Some(frame)) =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(20), output.recv()).await
|
||||||
|
{
|
||||||
|
let (_, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
|
||||||
|
let server = pb::AgentServerMessage::decode(payload).unwrap();
|
||||||
|
if let Some(pb::agent_server_message::Message::ExecServerControlMessage(control)) =
|
||||||
|
server.message
|
||||||
|
{
|
||||||
|
if let Some(pb::exec_server_control_message::Message::Abort(abort)) =
|
||||||
|
control.message
|
||||||
|
{
|
||||||
|
assert_ne!(abort.id, exec_id, "Task must not be aborted by injection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acknowledge_kv(&handle, &mut append_seqno, &frame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: append_seqno,
|
||||||
|
message: Box::new(subagent_success(exec_id)),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
append_seqno += 1;
|
||||||
|
release.notify_one();
|
||||||
|
|
||||||
|
drain_successfully(&handle, &mut output, &mut append_seqno).await;
|
||||||
|
|
||||||
|
let history = serde_json::to_string(&provider.requests()[1].history).unwrap();
|
||||||
|
assert!(history.contains("Tool execution was interrupted by a newer user message."));
|
||||||
|
assert!(history.contains("injected follow-up"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn injected_user_context_interrupts_automatic_compaction() {
|
||||||
|
let (_directory, store) = fixtures::temp_store().await;
|
||||||
|
let model = store
|
||||||
|
.create_model(&ModelConfigInput {
|
||||||
|
sort_order: 0,
|
||||||
|
display_name: "Test Model".into(),
|
||||||
|
model_type: ModelType::OpenAi,
|
||||||
|
base_url: "https://example.com/v1/chat/completions".into(),
|
||||||
|
use_full_url: true,
|
||||||
|
api_key: "test-key".into(),
|
||||||
|
tooltip_data: "Test Model".into(),
|
||||||
|
model_id: "test-model".into(),
|
||||||
|
reasoning_effort: None,
|
||||||
|
openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
|
||||||
|
openai_extra_params_enabled: false,
|
||||||
|
openai_extra_params: serde_json::json!({}),
|
||||||
|
custom_headers_enabled: false,
|
||||||
|
custom_headers: serde_json::json!({}),
|
||||||
|
anthropic_extra_params_enabled: false,
|
||||||
|
anthropic_extra_params: serde_json::json!({}),
|
||||||
|
context_window_tokens: Some(10_001),
|
||||||
|
max_completion_tokens: None,
|
||||||
|
anthropic_max_tokens: None,
|
||||||
|
anthropic_thinking_effort: None,
|
||||||
|
thinking_budget_tokens: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let provider = fake_provider::FakeProvider::default();
|
||||||
|
provider.push(text_response("seed answer"));
|
||||||
|
provider.push_pending();
|
||||||
|
provider.push(text_response("continued after compacting injection"));
|
||||||
|
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 seed_state = run_to_end(
|
||||||
|
®istry,
|
||||||
|
"seed-request",
|
||||||
|
client_run_for_model(
|
||||||
|
"seed-request",
|
||||||
|
"compaction-injection-conversation",
|
||||||
|
&model.model_hash,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let handle = registry
|
||||||
|
.get_or_create("inject-during-compaction")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut output = handle.subscribe();
|
||||||
|
let mut compacting_request = client_run_for_model_with_state(
|
||||||
|
"inject-during-compaction",
|
||||||
|
"compaction-injection-conversation",
|
||||||
|
&model.model_hash,
|
||||||
|
Some(seed_state),
|
||||||
|
);
|
||||||
|
let Some(pb::agent_client_message::Message::RunRequest(request)) =
|
||||||
|
compacting_request.message.as_mut()
|
||||||
|
else {
|
||||||
|
panic!("expected RunRequest")
|
||||||
|
};
|
||||||
|
request.requested_model.as_mut().unwrap().parameters.push(
|
||||||
|
pb::requested_model::ModelParameterValue {
|
||||||
|
id: "context".into(),
|
||||||
|
value: "10001".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: 0,
|
||||||
|
message: Box::new(compacting_request),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut append_seqno = 1;
|
||||||
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
while provider.requests().len() < 2 {
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"automatic compaction did not start"
|
||||||
|
);
|
||||||
|
if let Ok(Some(frame)) =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(20), output.recv()).await
|
||||||
|
{
|
||||||
|
acknowledge_kv(&handle, &mut append_seqno, &frame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: append_seqno,
|
||||||
|
message: Box::new(runtime_injection_for(
|
||||||
|
"compaction-injection",
|
||||||
|
"inject-during-compaction",
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
.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();
|
||||||
|
if let Some(pb::agent_server_message::Message::InteractionUpdate(update)) = server.message {
|
||||||
|
if let Some(pb::interaction_update::Message::TextDelta(delta)) = update.message {
|
||||||
|
saw_continued |= delta.text.contains("continued after compacting injection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acknowledge_kv(&handle, &mut append_seqno, &frame).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let requests = provider.requests();
|
||||||
|
assert_eq!(requests.len(), 3);
|
||||||
|
assert!(requests[1]
|
||||||
|
.prompt
|
||||||
|
.instructions
|
||||||
|
.starts_with("Summarize the conversation for the next model turn."));
|
||||||
|
assert!(!serde_json::to_string(&requests[1].history)
|
||||||
|
.unwrap()
|
||||||
|
.contains("injected follow-up"));
|
||||||
|
assert!(serde_json::to_string(&requests[2].history)
|
||||||
|
.unwrap()
|
||||||
|
.contains("injected follow-up"));
|
||||||
|
assert!(saw_continued);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_context_injection_is_rejected_without_failing_the_active_run() {
|
||||||
|
let (_directory, store) = fixtures::temp_store().await;
|
||||||
|
let provider = fake_provider::FakeProvider::default();
|
||||||
|
let release = provider.push_gated(vec![
|
||||||
|
ModelEvent::Start {
|
||||||
|
model_call_id: "active-cycle".into(),
|
||||||
|
},
|
||||||
|
ModelEvent::TextStart,
|
||||||
|
ModelEvent::TextDelta("active run completed".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("active-request").await.unwrap();
|
||||||
|
let mut output = handle.subscribe();
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: 0,
|
||||||
|
message: Box::new(client_run_for(
|
||||||
|
"active-request",
|
||||||
|
"stale-injection-conversation",
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut append_seqno = 1;
|
||||||
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
while provider.requests().is_empty() {
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"provider did not start"
|
||||||
|
);
|
||||||
|
if let Ok(Some(frame)) =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(20), output.recv()).await
|
||||||
|
{
|
||||||
|
acknowledge_kv(&handle, &mut append_seqno, &frame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: append_seqno,
|
||||||
|
message: Box::new(runtime_injection_for("stale-injection", "replaced-request")),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
append_seqno += 1;
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: append_seqno,
|
||||||
|
message: Box::new(runtime_injection_for("stale-injection", "replaced-request")),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
append_seqno += 1;
|
||||||
|
|
||||||
|
let mut rejection_count = 0;
|
||||||
|
let mut released = 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();
|
||||||
|
let rejected = match server.message {
|
||||||
|
Some(pb::agent_server_message::Message::InteractionUpdate(pb::InteractionUpdate {
|
||||||
|
message:
|
||||||
|
Some(pb::interaction_update::Message::ContextInjectionState(
|
||||||
|
pb::ContextInjectionStateUpdate {
|
||||||
|
injection_id,
|
||||||
|
state:
|
||||||
|
Some(pb::ContextInjectionState {
|
||||||
|
state:
|
||||||
|
Some(pb::context_injection_state::State::Rejected(rejected)),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
..
|
||||||
|
})) if injection_id == "stale-injection" => {
|
||||||
|
assert_eq!(
|
||||||
|
rejected.reason,
|
||||||
|
"InjectContextAction expected run replaced-request, active run is active-request"
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
acknowledge_kv(&handle, &mut append_seqno, &frame).await;
|
||||||
|
if rejected {
|
||||||
|
rejection_count += 1;
|
||||||
|
if !released {
|
||||||
|
released = true;
|
||||||
|
release.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(released, "stale injection was not rejected");
|
||||||
|
assert_eq!(rejection_count, 1);
|
||||||
|
assert_eq!(provider.requests().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cancel_subagent_action_aborts_the_target_task_and_keeps_the_parent_running() {
|
async fn cancel_subagent_action_aborts_the_target_task_and_keeps_the_parent_running() {
|
||||||
let (_directory, store) = fixtures::temp_store().await;
|
let (_directory, store) = fixtures::temp_store().await;
|
||||||
@@ -614,6 +1084,23 @@ fn client_run() -> pb::AgentClientMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn client_run_for(request_id: &str, conversation_id: &str) -> pb::AgentClientMessage {
|
fn client_run_for(request_id: &str, conversation_id: &str) -> pb::AgentClientMessage {
|
||||||
|
client_run_for_model(request_id, conversation_id, "test-model")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_run_for_model(
|
||||||
|
request_id: &str,
|
||||||
|
conversation_id: &str,
|
||||||
|
model_id: &str,
|
||||||
|
) -> pb::AgentClientMessage {
|
||||||
|
client_run_for_model_with_state(request_id, conversation_id, model_id, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_run_for_model_with_state(
|
||||||
|
request_id: &str,
|
||||||
|
conversation_id: &str,
|
||||||
|
model_id: &str,
|
||||||
|
state: Option<pb::ConversationStateStructure>,
|
||||||
|
) -> pb::AgentClientMessage {
|
||||||
pb::AgentClientMessage {
|
pb::AgentClientMessage {
|
||||||
message: Some(pb::agent_client_message::Message::RunRequest(
|
message: Some(pb::agent_client_message::Message::RunRequest(
|
||||||
pb::AgentRunRequest {
|
pb::AgentRunRequest {
|
||||||
@@ -634,15 +1121,177 @@ fn client_run_for(request_id: &str, conversation_id: &str) -> pb::AgentClientMes
|
|||||||
conversation_id: Some(conversation_id.into()),
|
conversation_id: Some(conversation_id.into()),
|
||||||
run_id: Some(request_id.into()),
|
run_id: Some(request_id.into()),
|
||||||
requested_model: Some(pb::RequestedModel {
|
requested_model: Some(pb::RequestedModel {
|
||||||
model_id: "test-model".into(),
|
model_id: model_id.into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
|
conversation_state: state,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn text_response(text: &str) -> Vec<ModelEvent> {
|
||||||
|
vec![
|
||||||
|
ModelEvent::Start {
|
||||||
|
model_call_id: format!("call-{text}"),
|
||||||
|
},
|
||||||
|
ModelEvent::TextStart,
|
||||||
|
ModelEvent::TextDelta(text.into()),
|
||||||
|
ModelEvent::TextEnd,
|
||||||
|
ModelEvent::Usage(Usage {
|
||||||
|
input_tokens: Some(1),
|
||||||
|
output_tokens: Some(1),
|
||||||
|
total_tokens: Some(2),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
ModelEvent::Done(FinishReason::Stop),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_response(call_id: &str, name: &str, arguments: &str) -> Vec<ModelEvent> {
|
||||||
|
vec![
|
||||||
|
ModelEvent::Start {
|
||||||
|
model_call_id: format!("call-{call_id}"),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallStart {
|
||||||
|
index: 0,
|
||||||
|
call_id: call_id.into(),
|
||||||
|
name: name.into(),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallArgumentsDelta {
|
||||||
|
index: 0,
|
||||||
|
delta: arguments.into(),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallEnd { index: 0 },
|
||||||
|
ModelEvent::Done(FinishReason::ToolUse),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_exec(
|
||||||
|
handle: &cursor_server::cursor::CursorSessionHandle,
|
||||||
|
output: &mut tokio::sync::mpsc::UnboundedReceiver<Bytes>,
|
||||||
|
append_seqno: &mut i64,
|
||||||
|
tool: &str,
|
||||||
|
) -> u32 {
|
||||||
|
loop {
|
||||||
|
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("RunSSE closed before 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();
|
||||||
|
if let Some(pb::agent_server_message::Message::ExecServerMessage(exec)) = server.message {
|
||||||
|
let matches = match exec.message.as_ref() {
|
||||||
|
Some(pb::exec_server_message::Message::ReadArgs(_)) => tool == "Read",
|
||||||
|
Some(pb::exec_server_message::Message::SubagentArgs(_)) => tool == "Task",
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if matches {
|
||||||
|
return exec.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acknowledge_kv(handle, append_seqno, &frame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drain_successfully(
|
||||||
|
handle: &cursor_server::cursor::CursorSessionHandle,
|
||||||
|
output: &mut tokio::sync::mpsc::UnboundedReceiver<Bytes>,
|
||||||
|
append_seqno: &mut i64,
|
||||||
|
) {
|
||||||
|
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"{}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acknowledge_kv(handle, append_seqno, &frame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_success(id: u32) -> pb::AgentClientMessage {
|
||||||
|
pb::AgentClientMessage {
|
||||||
|
message: Some(pb::agent_client_message::Message::ExecClientMessage(
|
||||||
|
pb::ExecClientMessage {
|
||||||
|
id,
|
||||||
|
message: Some(pb::exec_client_message::Message::ReadResult(
|
||||||
|
pb::ReadResult {
|
||||||
|
result: Some(pb::read_result::Result::Success(pb::ReadSuccess {
|
||||||
|
path: "/tmp/a".into(),
|
||||||
|
total_lines: 1,
|
||||||
|
file_size: 1,
|
||||||
|
output: Some(pb::read_success::Output::Content("late".into())),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subagent_success(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::Success(pb::SubagentSuccess {
|
||||||
|
agent_id: "detached-child".into(),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_to_end(
|
||||||
|
registry: &CursorSessionRegistry,
|
||||||
|
request_id: &str,
|
||||||
|
request: pb::AgentClientMessage,
|
||||||
|
) -> pb::ConversationStateStructure {
|
||||||
|
let handle = registry.get_or_create(request_id).await.unwrap();
|
||||||
|
let mut output = handle.subscribe();
|
||||||
|
handle
|
||||||
|
.command(CursorCommand::Append {
|
||||||
|
seqno: 0,
|
||||||
|
message: Box::new(request),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut append_seqno = 1;
|
||||||
|
let mut state = None;
|
||||||
|
loop {
|
||||||
|
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("RunSSE closed before EndStream");
|
||||||
|
let (flags, _) = connect::decode_frames(&frame).unwrap().pop().unwrap();
|
||||||
|
if flags & connect::END_STREAM_FLAG != 0 {
|
||||||
|
return state.expect("Run ended without a checkpoint");
|
||||||
|
}
|
||||||
|
let (_, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
|
||||||
|
let server = pb::AgentServerMessage::decode(payload).unwrap();
|
||||||
|
if let Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(update)) =
|
||||||
|
server.message
|
||||||
|
{
|
||||||
|
state = Some(update);
|
||||||
|
}
|
||||||
|
acknowledge_kv(&handle, &mut append_seqno, &frame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn acknowledge_kv(
|
async fn acknowledge_kv(
|
||||||
handle: &cursor_server::cursor::CursorSessionHandle,
|
handle: &cursor_server::cursor::CursorSessionHandle,
|
||||||
append_seqno: &mut i64,
|
append_seqno: &mut i64,
|
||||||
@@ -700,13 +1349,17 @@ fn runtime_user_message() -> pb::AgentClientMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_injection() -> pb::AgentClientMessage {
|
fn runtime_injection() -> pb::AgentClientMessage {
|
||||||
|
runtime_injection_for("injection-1", "inject-request")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_injection_for(injection_id: &str, expected_run_id: &str) -> pb::AgentClientMessage {
|
||||||
pb::AgentClientMessage {
|
pb::AgentClientMessage {
|
||||||
message: Some(pb::agent_client_message::Message::ConversationAction(
|
message: Some(pb::agent_client_message::Message::ConversationAction(
|
||||||
pb::ConversationAction {
|
pb::ConversationAction {
|
||||||
action: Some(pb::conversation_action::Action::InjectContextAction(
|
action: Some(pb::conversation_action::Action::InjectContextAction(
|
||||||
pb::InjectContextAction {
|
pb::InjectContextAction {
|
||||||
injection_id: "injection-1".into(),
|
injection_id: injection_id.into(),
|
||||||
expected_run_id: "inject-request".into(),
|
expected_run_id: expected_run_id.into(),
|
||||||
payload: Some(pb::inject_context_action::Payload::UserContext(
|
payload: Some(pb::inject_context_action::Payload::UserContext(
|
||||||
pb::UserContextInjection {
|
pb::UserContextInjection {
|
||||||
user_message: Some(pb::UserMessage {
|
user_message: Some(pb::UserMessage {
|
||||||
|
|||||||
@@ -46,6 +46,28 @@ fn every_tool_result_is_projected_as_string_content() {
|
|||||||
assert_eq!(string_result.content, "plain text");
|
assert_eq!(string_result.content, "plain text");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projected_tool_result_prefixes_remain_stable() {
|
||||||
|
let first = vec![named_tool_result("Grep", &"x".repeat(64 * 1024))];
|
||||||
|
let mut second = first.clone();
|
||||||
|
second.push(fixtures::user("u2", "continue"));
|
||||||
|
|
||||||
|
let projected_first = project_messages(&first).unwrap();
|
||||||
|
let projected_second = project_messages(&second).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(projected_first, projected_second[..projected_first.len()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unbounded_tool_results_are_not_rewritten() {
|
||||||
|
let original = "x".repeat(64 * 1024);
|
||||||
|
let projected = project_messages(&[named_tool_result("Delete", &original)]).unwrap();
|
||||||
|
let ProjectedContent::ToolResult(result) = &projected[0].content else {
|
||||||
|
panic!("expected tool result")
|
||||||
|
};
|
||||||
|
assert_eq!(result.content, original);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn assistant_text_and_thinking_remain_separate_during_projection() {
|
fn assistant_text_and_thinking_remain_separate_during_projection() {
|
||||||
let messages = vec![CanonicalMessage {
|
let messages = vec![CanonicalMessage {
|
||||||
@@ -117,7 +139,7 @@ fn every_prompt_mode_loads_the_captured_tool_set() {
|
|||||||
.as_path(),
|
.as_path(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(assets.mode(Mode::Agent).tools.len(), 22);
|
assert_eq!(assets.mode(Mode::Agent).tools.len(), 21);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
assets
|
assets
|
||||||
.mode(Mode::Agent)
|
.mode(Mode::Agent)
|
||||||
@@ -141,7 +163,6 @@ fn every_prompt_mode_loads_the_captured_tool_set() {
|
|||||||
"Glob",
|
"Glob",
|
||||||
"AskQuestion",
|
"AskQuestion",
|
||||||
"Task",
|
"Task",
|
||||||
"AwaitShell",
|
|
||||||
"GetMcpTools",
|
"GetMcpTools",
|
||||||
"FetchMcpResource",
|
"FetchMcpResource",
|
||||||
"SwitchMode",
|
"SwitchMode",
|
||||||
@@ -172,7 +193,7 @@ fn every_prompt_mode_loads_the_captured_tool_set() {
|
|||||||
"SembleSearch",
|
"SembleSearch",
|
||||||
"SembleFindRelated",
|
"SembleFindRelated",
|
||||||
],
|
],
|
||||||
"ec10becac85819cda321298762892852194c78601db66cc0b4ce74bc1213e29e",
|
"98bb57a9ade7f1a572c5c5fe77a905a129d28ecfd42b8d318250f6486b09e1ec",
|
||||||
);
|
);
|
||||||
assert_mode(
|
assert_mode(
|
||||||
&assets,
|
&assets,
|
||||||
@@ -194,7 +215,7 @@ fn every_prompt_mode_loads_the_captured_tool_set() {
|
|||||||
"SembleSearch",
|
"SembleSearch",
|
||||||
"SembleFindRelated",
|
"SembleFindRelated",
|
||||||
],
|
],
|
||||||
"e2eb8a1ebd70d53b1b2eb6bedabdce62ff070a05a6168216013d0a1144ed8bb5",
|
"9a7e0f9e0bd8ef0af01032fa311686f72c42ec260e3057f6fae5e68f5ed36fb8",
|
||||||
);
|
);
|
||||||
assert_mode(
|
assert_mode(
|
||||||
&assets,
|
&assets,
|
||||||
@@ -218,7 +239,7 @@ fn every_prompt_mode_loads_the_captured_tool_set() {
|
|||||||
"SembleSearch",
|
"SembleSearch",
|
||||||
"SembleFindRelated",
|
"SembleFindRelated",
|
||||||
],
|
],
|
||||||
"ec10becac85819cda321298762892852194c78601db66cc0b4ce74bc1213e29e",
|
"98bb57a9ade7f1a572c5c5fe77a905a129d28ecfd42b8d318250f6486b09e1ec",
|
||||||
);
|
);
|
||||||
assert_mode(
|
assert_mode(
|
||||||
&assets,
|
&assets,
|
||||||
@@ -244,7 +265,7 @@ fn every_prompt_mode_loads_the_captured_tool_set() {
|
|||||||
"SembleSearch",
|
"SembleSearch",
|
||||||
"SembleFindRelated",
|
"SembleFindRelated",
|
||||||
],
|
],
|
||||||
"25f7b559941baabfc9b1046455b04ca812fc41a6878ad55a43d83f0bd18cd92f",
|
"976b309dd91e314d4916439ebb9da8995751d011532e39934a1da7593dc78ccb",
|
||||||
);
|
);
|
||||||
assert_mode(
|
assert_mode(
|
||||||
&assets,
|
&assets,
|
||||||
@@ -263,7 +284,6 @@ fn every_prompt_mode_loads_the_captured_tool_set() {
|
|||||||
"Write",
|
"Write",
|
||||||
"Read",
|
"Read",
|
||||||
"Glob",
|
"Glob",
|
||||||
"AwaitShell",
|
|
||||||
"GetMcpTools",
|
"GetMcpTools",
|
||||||
"FetchMcpResource",
|
"FetchMcpResource",
|
||||||
"SwitchMode",
|
"SwitchMode",
|
||||||
@@ -272,7 +292,7 @@ fn every_prompt_mode_loads_the_captured_tool_set() {
|
|||||||
"SembleSearch",
|
"SembleSearch",
|
||||||
"SembleFindRelated",
|
"SembleFindRelated",
|
||||||
],
|
],
|
||||||
"48c8e0fe825f9c2450307ca5e70cde7077c4282c135b2cd15338bd4bd0c43636",
|
"6de1ee86a131ca093c7143f54fffcba2fc14b32ff45fd6f5e0df1347058ad744",
|
||||||
);
|
);
|
||||||
assert_mode(
|
assert_mode(
|
||||||
&assets,
|
&assets,
|
||||||
@@ -282,7 +302,7 @@ fn every_prompt_mode_loads_the_captured_tool_set() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
schema_digest(&assets.mode(Mode::Agent).tools),
|
schema_digest(&assets.mode(Mode::Agent).tools),
|
||||||
"e53a72c1d131ff3f65c619799232440b064e90e99f5d3fcceb63e32598d3a0fc"
|
"282a1dff7957090d0a75eac4a46474ac7cffa1b0937bdf97354544e729bb15c2"
|
||||||
);
|
);
|
||||||
let task = assets
|
let task = assets
|
||||||
.mode(Mode::Agent)
|
.mode(Mode::Agent)
|
||||||
@@ -519,7 +539,6 @@ fn subagent_uses_the_agent_prompt_and_only_the_captured_tool_delta() {
|
|||||||
"Write",
|
"Write",
|
||||||
"Read",
|
"Read",
|
||||||
"Glob",
|
"Glob",
|
||||||
"AwaitShell",
|
|
||||||
"GetMcpTools",
|
"GetMcpTools",
|
||||||
"FetchMcpResource",
|
"FetchMcpResource",
|
||||||
"SwitchMode",
|
"SwitchMode",
|
||||||
@@ -569,6 +588,23 @@ fn tool_result_with_call(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn named_tool_result(name: &str, output: &str) -> CanonicalMessage {
|
||||||
|
CanonicalMessage {
|
||||||
|
message_id: format!("result-{name}"),
|
||||||
|
role: Role::Tool,
|
||||||
|
origin: Origin::Tool,
|
||||||
|
content: MessageContent::ToolResult(ToolResultContent {
|
||||||
|
call_id: format!("call-{name}"),
|
||||||
|
name: name.into(),
|
||||||
|
content: output.into(),
|
||||||
|
is_error: false,
|
||||||
|
image: None,
|
||||||
|
provider_parts: Vec::new(),
|
||||||
|
}),
|
||||||
|
runtime_event_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn assistant_tool_pair(
|
fn assistant_tool_pair(
|
||||||
id: &str,
|
id: &str,
|
||||||
tool_round_id: &str,
|
tool_round_id: &str,
|
||||||
|
|||||||
@@ -745,89 +745,6 @@ async fn unknown_exec_id_is_a_protocol_error() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn await_shell_consumes_the_background_output_file_terminal_state() {
|
|
||||||
let runtime = CursorToolRuntime::default();
|
|
||||||
let dispatcher = ToolDispatcher::new(runtime.clone());
|
|
||||||
let mut await_call = call("await-call", "AwaitShell");
|
|
||||||
await_call.arguments = json!({
|
|
||||||
"shell_id": "42",
|
|
||||||
"block_until_ms": 1000,
|
|
||||||
"pattern": "ready",
|
|
||||||
});
|
|
||||||
await_call.arguments_text = await_call.arguments.to_string();
|
|
||||||
let completed = HashSet::new();
|
|
||||||
let started = HashSet::new();
|
|
||||||
let dispatched = dispatcher
|
|
||||||
.start_batch(
|
|
||||||
&[await_call],
|
|
||||||
ToolBatchState {
|
|
||||||
completed: &completed,
|
|
||||||
started: &started,
|
|
||||||
response_text: "",
|
|
||||||
response_thinking: "",
|
|
||||||
},
|
|
||||||
&[],
|
|
||||||
&BTreeMap::new(),
|
|
||||||
&exec_context(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let exec = dispatched[0]
|
|
||||||
.messages
|
|
||||||
.iter()
|
|
||||||
.find_map(|message| match message.message.as_ref() {
|
|
||||||
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => Some(exec),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
let Some(pb::exec_server_message::Message::ReadArgs(read)) = exec.message.as_ref() else {
|
|
||||||
panic!("expected AwaitShell ReadArgs")
|
|
||||||
};
|
|
||||||
assert_eq!(read.path, "/tmp/terminals/42.txt");
|
|
||||||
|
|
||||||
let event = codec::client_event(
|
|
||||||
&pb::ExecClientMessage {
|
|
||||||
id: exec.id,
|
|
||||||
message: Some(pb::exec_client_message::Message::ReadResult(
|
|
||||||
pb::ReadResult {
|
|
||||||
result: Some(pb::read_result::Result::Success(pb::ReadSuccess {
|
|
||||||
output: Some(pb::read_success::Output::Content(
|
|
||||||
"server ready\nexit_code: 0\n".into(),
|
|
||||||
)),
|
|
||||||
..Default::default()
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
&runtime,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let codec::ClientExecEvent::Completed(completion) = event else {
|
|
||||||
panic!("expected completed AwaitShell")
|
|
||||||
};
|
|
||||||
assert_eq!(completion.result().call_id, "await-call");
|
|
||||||
assert!(!completion.result().is_error);
|
|
||||||
let Some(pb::tool_call::Tool::AwaitToolCall(tool)) = completion.tool_call().tool.as_ref()
|
|
||||||
else {
|
|
||||||
panic!("expected AwaitToolCall")
|
|
||||||
};
|
|
||||||
let pb::await_result::Result::Success(success) =
|
|
||||||
tool.result.as_ref().unwrap().result.as_ref().unwrap()
|
|
||||||
else {
|
|
||||||
panic!("expected Await success")
|
|
||||||
};
|
|
||||||
let pb::await_success::AwaitResult::Complete(complete) = success.await_result.as_ref().unwrap()
|
|
||||||
else {
|
|
||||||
panic!("expected completed background task")
|
|
||||||
};
|
|
||||||
assert_eq!(complete.task_id, "42");
|
|
||||||
assert_eq!(complete.exit_code, Some(0));
|
|
||||||
assert_eq!(complete.regex_match.as_deref(), Some("ready"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn provider_tool_use_waits_for_client_result_then_calls_provider_again() {
|
async fn provider_tool_use_waits_for_client_result_then_calls_provider_again() {
|
||||||
let (directory, store) = fixtures::temp_store().await;
|
let (directory, store) = fixtures::temp_store().await;
|
||||||
|
|||||||
Reference in New Issue
Block a user