Compare commits

...
26 Commits
Author SHA1 Message Date
leokun a3ec2a0dfc release: 0.0.48 2026-08-12 20:32:19 +08:00
leokunandGitHub 38472a3c63 Merge pull request #299 from leookun/fix/issue-298-per-user-ca
Fix shared root CA by generating one per installation
2026-08-12 20:11:43 +08:00
leokun 2b8d1c9e3d fix: generate a per-installation root CA 2026-08-12 20:04:29 +08:00
leokunandGitHub 7a67e76617 Merge pull request #270 from zfscgy/feat/image-read
Feat/image read: 本地文件读取工具支持读取图片
2026-08-12 01:22:35 +08:00
leookun 08ce12aa55 Merge branch 'main' into codex/pr270-content-addressed-read-images
# Conflicts:
#	frontend/src/i18n/generated/catalog.json
2026-08-12 01:14:24 +08:00
leookun 8f8d28880d feat(forwarder): persist read images by content hash 2026-08-12 01:10:14 +08:00
leokunandGitHub 988ba63d40 Merge pull request #291 from Sxuan-Coder/feat/optional-reasoning-effort
fix(model): 支持不设置 reasoning effort
2026-08-11 23:44:52 +08:00
上玄 edd59dc684 fix(model): 支持不设置推理强度 2026-08-11 21:18:10 +08:00
leokunandGitHub da5fa34a4d 更新 README-CN.md 2026-08-10 23:19:34 +08:00
leokunandGitHub 4bd2359282 Update README-CN.md 2026-08-10 23:14:14 +08:00
leokunandGitHub 7838ffc6a2 Update README.md 2026-08-10 23:13:48 +08:00
leokunandGitHub ee30775be1 Update Chinese translation link in README 2026-08-10 23:10:17 +08:00
leokunandGitHub 80a5093aa9 Update README.md 2026-08-10 23:09:41 +08:00
leokunandGitHub b9742b1667 Update README-CN.md 2026-08-10 23:09:04 +08:00
leokunandGitHub 7d3e74be59 Update README.md 2026-08-10 23:08:36 +08:00
leokunandGitHub 1285bf9d62 Update README-CN.md 2026-08-10 23:05:44 +08:00
leokunandGitHub 7a724595eb Update README.md 2026-08-10 23:05:31 +08:00
leokunandGitHub 00aec40e50 Update README.md 2026-08-10 23:03:50 +08:00
leokunandGitHub c10a2d475d Merge pull request #285 from leookun/release/0.0.47
release: 0.0.47
2026-08-10 22:56:00 +08:00
leookun 4864d3675b release: 0.0.47 2026-08-10 22:55:16 +08:00
leokunandGitHub 3f95318a49 Merge pull request #284 from leookun/fix/compress
Enhance checkpoint handling and error management in forwarder
2026-08-10 22:34:40 +08:00
leookun 9373e57ebf Enhance checkpoint handling and error management in forwarder
- Added flushing of assistant text during provider completion to ensure no output is lost on transport failure.
- Updated checkpoint blob synchronization tests to validate behavior under various conditions, including terminal and non-terminal states.
- Introduced new functions for managing checkpoint terminal actions, improving clarity and maintainability of the code.
- Implemented additional tests for imported blob handling and conversation state restoration, ensuring robustness in data integrity across operations.
2026-08-10 22:25:37 +08:00
leokunandGitHub f1992b0cfe Merge pull request #281 from jiah0231/fix/openai-reasoning-summary
fix: request OpenAI Responses reasoning summaries
2026-08-10 10:24:45 +08:00
haoge0211 67a9c27931 fix: request OpenAI Responses reasoning summaries 2026-08-08 20:48:07 +08:00
郑非 85a43115c7 read image tests 2026-08-06 21:06:13 +08:00
郑非 b475166ba8 Support read image 2026-08-06 21:05:08 +08:00
69 changed files with 2913 additions and 587 deletions
+13 -1
View File
@@ -2,6 +2,11 @@
# cursor-byok
cursor-byok 是 Cursor 后端的本地实现。
<br>
<br>
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
[使用教程](https://docs.leokun.cn) · [下载最新版](https://github.com/leookun/cursor-byok/releases/latest) · [问题反馈](https://github.com/leookun/cursor-byok/issues) · [English](./README.md)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
@@ -84,12 +89,19 @@ cursor-byok 在本机负责协议适配、模型请求转发、工具调用衔
- [Telegram 交流群](https://t.me/cursor_byok)
- QQ 交流群:`1095916242``1094411438``1095918002``1094419321`
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
## 开发与贡献
欢迎提交 Issue 和 Pull Request。开发环境、构建命令、项目结构及提交规范请阅读 [贡献指南](./CONTRIBUTING.md)。
## 贡献者名单
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
</a>
## 许可证
本项目基于 [MIT License](./LICENSE) 开源。
+17 -2
View File
@@ -1,14 +1,20 @@
<div align="center">
# cursor-byok
cursor-byok is a local implementation of Cursor's backend.
<br>
<br>
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
[User Guide](https://docs.leokun.cn) · [Latest Release](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [简体中文](./README-CN.md)
[User Guide](https://docs.leokun.cn) · [Download](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [中文版本说明](./README-CN.md)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
</div>
![Connect cursor-byok to a wide range of model APIs](./images/en-brand.png)
@@ -84,12 +90,21 @@ See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32)
- [Telegram community](https://t.me/cursor_byok)
- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
## Development and Contributing
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
## Contributors
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
</a>
## License
This project is open source under the [MIT License](./LICENSE).
+1 -1
View File
@@ -8,7 +8,7 @@ info:
description: "Cursor助手"
copyright: "© 2026, Cursor助手"
comments: "Cursor助手"
version: "0.0.46"
version: "0.0.48"
dev_mode:
root_path: .
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.46</string>
<string>0.0.48</string>
<key>CFBundleVersion</key>
<string>0.0.46</string>
<string>0.0.48</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.46</string>
<string>0.0.48</string>
<key>CFBundleVersion</key>
<string>0.0.46</string>
<string>0.0.48</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
+27 -14
View File
@@ -6,7 +6,7 @@
name: "Cursor助手"
arch: ${GOARCH}
platform: "linux"
version: "0.0.46"
version: "0.0.48"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
@@ -24,24 +24,24 @@ contents:
- src: "./build/linux/Cursor助手.desktop"
dst: "/usr/share/applications/Cursor助手.desktop"
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
- libgtk-4-1
- libwebkitgtk-6.0-4
# Distribution-specific overrides for different package formats and WebKit versions
# Distribution-specific overrides for different package formats
overrides:
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
rpm:
depends:
- gtk3
- webkit2gtk4.1
- gtk4
- webkitgtk6.0
# Arch Linux packages (WebKit 4.1)
# Arch Linux packages
archlinux:
depends:
- gtk3
- webkit2gtk-4.1
- gtk4
- webkitgtk-6.0
# scripts section to ensure desktop database is updated after install
scripts:
@@ -50,13 +50,26 @@ scripts:
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
# If you build your app with -tags gtk3 (legacy WebKit2GTK 4.1 stack — supported through v3.0.x, removed in v3.1),
# replace the depends/overrides above with these:
#
# depends:
# - libgtk-3-0
# - libwebkit2gtk-4.1-0
# overrides:
# rpm:
# depends:
# - gtk3
# - webkit2gtk4.1
# archlinux:
# depends:
# - gtk3
# - webkit2gtk-4.1
#
# replaces:
# - foobar
# provides:
# - bar
# depends:
# - gtk3
# - libwebkit2gtk
# recommends:
# - whatever
# suggests:
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fixed": {
"file_version": "0.0.46"
"file_version": "0.0.48"
},
"info": {
"0000": {
"ProductVersion": "0.0.46",
"ProductVersion": "0.0.48",
"CompanyName": "Cursor助手",
"FileDescription": "Cursor助手",
"LegalCopyright": "© 2026, Cursor助手",
+26 -1
View File
@@ -14,7 +14,7 @@
!define INFO_PRODUCTNAME "Cursor助手"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.0.46"
!define INFO_PRODUCTVERSION "0.0.48"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, Cursor助手"
@@ -27,8 +27,16 @@
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef WAILS_INSTALL_SCOPE
!define WAILS_INSTALL_SCOPE "machine"
!endif
!ifndef REQUEST_EXECUTION_LEVEL
!if "${WAILS_INSTALL_SCOPE}" == "user"
!define REQUEST_EXECUTION_LEVEL "user"
!else
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
@@ -115,6 +123,18 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!else
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
@@ -125,13 +145,18 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!endif
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
DeleteRegKey HKCU "${UNINST_KEY}"
!else
DeleteRegKey HKLM "${UNINST_KEY}"
!endif
!macroend
!macro wails.setShellContext
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.46" processorArchitecture="*"/>
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.48" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
+3 -1
View File
@@ -28,6 +28,7 @@ type exchangeContext struct {
type Server struct {
config Config
certManager *certs.Manager
caCertPEM []byte
store *exchangeStore
counter atomic.Uint64
proxyServer *http.Server
@@ -43,13 +44,14 @@ func New(config Config) (*Server, error) {
if err := validateLoopbackAddress(config.UIAddr); err != nil {
return nil, err
}
manager, err := certs.NewEmbeddedManager()
manager, caCertPEM, err := certs.NewGeneratedManager()
if err != nil {
return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
}
server := &Server{
config: config,
certManager: manager,
caCertPEM: caCertPEM,
store: newExchangeStore(config.MaxExchanges),
}
proxyHandler, err := server.newProxyHandler()
+1 -3
View File
@@ -8,8 +8,6 @@ import (
"net/http"
"strings"
"time"
"cursor/internal/certs"
)
//go:embed web/*
@@ -93,7 +91,7 @@ func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Req
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
_, _ = writer.Write(certs.EmbeddedCACertPEM())
_, _ = writer.Write(server.caCertPEM)
}
func writeJSON(writer http.ResponseWriter, status int, payload any) {
@@ -19,6 +19,7 @@ const modelTypeOptions = [
];
const reasoningEffortOptions = [
{ label: "不设置", value: "", icon: "icon-[mdi--minus-circle-outline]" },
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
+2 -1
View File
@@ -35,6 +35,7 @@ const modelTypeTabs = [
];
const reasoningEffortOptions = [
{ label: "不设置", value: "", icon: "icon-[mdi--minus-circle-outline]" },
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
@@ -150,7 +151,7 @@ const fieldTips = {
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
apiKey: "调用该模型服务需要使用的访问密钥。",
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
reasoningEffort: "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
reasoningEffort: "仅当模型支持 reasoning_effort 时才选择推理强度;选择“不设置”后,请求不会携带该参数。越高通常越稳,但也可能更慢。",
maxCompletionTokens: "单次回复允许生成的最大 Token 数。留空时使用默认值。",
openAIEndpoint: "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -59,6 +59,7 @@
"4c0a929bb86ce912": "Current: {0}",
"4d2b6e53be6002e5": "Cache Statistics Strategy: {0} ({1})",
"4d8c1c5b42830791": "Unknown",
"4e30d7c9ed2b0eee": "Not set",
"4f0982ba1d37e51b": "Current outbound requests use environment variable proxy",
"5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.",
"54e6745ff43c9c74": "Sorting failed",
@@ -112,6 +113,7 @@
"86df7ec743047234": "Service running",
"899add6275682210": "Uses 200000 by default when left blank",
"8a4ef3e48e4e8a5a": "Enabled",
"8b8428f714611458": "Only select a reasoning effort when the model supports reasoning_effort. When set to Not set, the request omits this parameter. Higher values are usually more stable, but may also be slower.",
"8c1935935600e336": "Model Test",
"8cbcf741e727dbf7": "Model Settings",
"8d1de152be6360ce": "Valid ratio: {0}",
@@ -165,6 +167,7 @@
"bb074b86a98f6911": "Context Window",
"bc87a4121a0873b3": "Refresh Stats",
"bd4464ea88d3f24a": "Total turns: {0}",
"bd4d7a3c6e5a1ac8": "{0} reasoning effort only supports Not set, low, medium, high, xhigh, and max",
"bddd504af0c92fd0": "System PAC/automatic proxy detected; current version is handled as a direct connection",
"bef280f9eb392495": "Conversation Turns",
"c228558cf257fc49": "Delete failed",
@@ -194,7 +197,6 @@
"d95e5cb6bdcee553": "Include Cache Creation",
"da590a8fe3ce4de0": "Please select",
"daede9881787abe7": "Notes",
"dbb4b5be9b5723dc": "{0} reasoning effort only supports low, medium, high, xhigh, and max",
"dbee6e7139243362": "{0} base URL cannot be empty",
"dc82c5e8fb2ab777": "Version: v{0}",
"de8184da1ef88d03": "Configured",
@@ -215,7 +217,6 @@
"f0b6a23368dd47cc": "Enter a model ID directly, or select one from the list returned by the server.",
"f1aa7326f38b4c09": "Drag to reorder",
"f1e0fc261d42fe29": "Notes shown when hovering over the model list.",
"f363622480699c52": "Reasoning effort only applies to some models that support reasoning_effort. Not all models do. Higher values are usually more stable, but may also be slower.",
"f3a76d896853c1df": "Miss",
"f3fae6cccb9004b1": "Custom header name cannot be empty",
"f474a4108aba4c4c": "Stop Service",
+3 -2
View File
@@ -59,6 +59,7 @@
"4c0a929bb86ce912": "現在:{0}",
"4d2b6e53be6002e5": "キャッシュ統計ポリシー:{0}{1})",
"4d8c1c5b42830791": "不明",
"4e30d7c9ed2b0eee": "設定しない",
"4f0982ba1d37e51b": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています",
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"54e6745ff43c9c74": "並べ替えに失敗しました",
@@ -112,6 +113,7 @@
"86df7ec743047234": "サービス稼働中",
"899add6275682210": "空欄で 200000",
"8a4ef3e48e4e8a5a": "有効",
"8b8428f714611458": "モデルが reasoning_effort に対応している場合のみ推論強度を選択してください。「設定しない」を選ぶと、リクエストにこのパラメータは含まれません。値が高いほど安定しやすい反面、遅くなることがあります。",
"8c1935935600e336": "モデルテスト",
"8cbcf741e727dbf7": "モデル設定",
"8d1de152be6360ce": "有効率: {0}",
@@ -165,6 +167,7 @@
"bb074b86a98f6911": "コンテキストウィンドウ",
"bc87a4121a0873b3": "統計を更新",
"bd4464ea88d3f24a": "総ターン: {0}",
"bd4d7a3c6e5a1ac8": "{0} の推論強度は「設定しない」、low、medium、high、xhigh、max のみサポートします",
"bddd504af0c92fd0": "システムのPAC/自動プロキシが検出されました。現在のバージョンは直接接続として処理されます",
"bef280f9eb392495": "会話ターン",
"c228558cf257fc49": "削除に失敗しました",
@@ -194,7 +197,6 @@
"d95e5cb6bdcee553": "キャッシュ作成を含める",
"da590a8fe3ce4de0": "選択してください",
"daede9881787abe7": "メモ",
"dbb4b5be9b5723dc": "{0} の推論強度は low、medium、high、xhigh、max のみサポートします",
"dbee6e7139243362": "{0} のベース URL は必須です",
"dc82c5e8fb2ab777": "バージョン: v{0}",
"de8184da1ef88d03": "設定済み",
@@ -215,7 +217,6 @@
"f0b6a23368dd47cc": "モデルIDを直接入力するか、サーバーから返された一覧から選択します。",
"f1aa7326f38b4c09": "ドラッグして並べ替え",
"f1e0fc261d42fe29": "モデル一覧にホバーしたときに表示されるメモです。",
"f363622480699c52": "推論強度は reasoning_effort をサポートする一部のモデルでのみ有効です。すべてのモデルが対応しているわけではありません。値が高いほど安定しやすい反面、遅くなることがあります。",
"f3a76d896853c1df": "ミス",
"f3fae6cccb9004b1": "カスタムヘッダー名は空にできません",
"f474a4108aba4c4c": "サービスを停止",
+3 -2
View File
@@ -59,6 +59,7 @@
"4c0a929bb86ce912": "Сейчас: {0}",
"4d2b6e53be6002e5": "Стратегия статистики кеша: {0} ({1})",
"4d8c1c5b42830791": "Неизвестно",
"4e30d7c9ed2b0eee": "Не задано",
"4f0982ba1d37e51b": "Исходящие запросы используют прокси из переменных окружения",
"5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.",
"54e6745ff43c9c74": "Не удалось изменить порядок",
@@ -112,6 +113,7 @@
"86df7ec743047234": "Сервис запущен",
"899add6275682210": "Если оставить пустым, используется 200000",
"8a4ef3e48e4e8a5a": "Включено",
"8b8428f714611458": "Выбирайте интенсивность рассуждений только для моделей с поддержкой reasoning_effort. Если выбрать «Не задано», этот параметр не будет добавлен в запрос. Более высокие значения обычно дают более стабильный результат, но могут замедлить ответ.",
"8c1935935600e336": "Проверка модели",
"8cbcf741e727dbf7": "Настройки модели",
"8d1de152be6360ce": "Доля успешных: {0}",
@@ -165,6 +167,7 @@
"bb074b86a98f6911": "Контекстное окно",
"bc87a4121a0873b3": "Обновить статистику",
"bd4464ea88d3f24a": "Всего ходов: {0}",
"bd4d7a3c6e5a1ac8": "Интенсивность рассуждений {0} поддерживает только значения «Не задано», low, medium, high, xhigh и max",
"bddd504af0c92fd0": "Обнаружен системный PAC/автоматический прокси; в текущей версии используется прямое подключение",
"bef280f9eb392495": "Ходы диалога",
"c228558cf257fc49": "Не удалось удалить",
@@ -194,7 +197,6 @@
"d95e5cb6bdcee553": "Учитывать создание кеша",
"da590a8fe3ce4de0": "Выберите значение",
"daede9881787abe7": "Примечания",
"dbb4b5be9b5723dc": "Интенсивность рассуждений {0} поддерживает только low, medium, high, xhigh и max",
"dbee6e7139243362": "Базовый URL {0} не может быть пустым",
"dc82c5e8fb2ab777": "Версия: v{0}",
"de8184da1ef88d03": "Настроено",
@@ -215,7 +217,6 @@
"f0b6a23368dd47cc": "Введите идентификатор модели вручную или выберите его из списка, полученного от сервера.",
"f1aa7326f38b4c09": "Перетащите, чтобы изменить порядок",
"f1e0fc261d42fe29": "Примечание, отображаемое при наведении на модель в списке.",
"f363622480699c52": "Интенсивность рассуждений применяется только к моделям с поддержкой reasoning_effort. Чем выше значение, тем обычно стабильнее результат, но ответ может формироваться медленнее.",
"f3a76d896853c1df": "Промах",
"f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым",
"f474a4108aba4c4c": "Остановить сервис",
+3 -2
View File
@@ -59,6 +59,7 @@
"4c0a929bb86ce912": "当前:{0}",
"4d2b6e53be6002e5": "缓存统计策略:{0}{1}",
"4d8c1c5b42830791": "未知",
"4e30d7c9ed2b0eee": "不设置",
"4f0982ba1d37e51b": "当前出站请求使用环境变量代理",
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
"54e6745ff43c9c74": "排序失败",
@@ -112,6 +113,7 @@
"86df7ec743047234": "服务运行中",
"899add6275682210": "留空时默认 200000",
"8a4ef3e48e4e8a5a": "已开启",
"8b8428f714611458": "仅当模型支持 reasoning_effort 时才选择推理强度;选择“不设置”后,请求不会携带该参数。越高通常越稳,但也可能更慢。",
"8c1935935600e336": "模型测试",
"8cbcf741e727dbf7": "模型配置",
"8d1de152be6360ce": "有效占比:{0}",
@@ -165,6 +167,7 @@
"bb074b86a98f6911": "上下文窗口",
"bc87a4121a0873b3": "刷新统计",
"bd4464ea88d3f24a": "总轮次:{0}",
"bd4d7a3c6e5a1ac8": "{0} 的推理强度仅支持不设置、low、medium、high、xhigh、max",
"bddd504af0c92fd0": "检测到系统 PAC/自动代理,当前版本按直连处理",
"bef280f9eb392495": "对话轮次",
"c228558cf257fc49": "删除失败",
@@ -194,7 +197,6 @@
"d95e5cb6bdcee553": "计入缓存创建",
"da590a8fe3ce4de0": "请选择",
"daede9881787abe7": "备注",
"dbb4b5be9b5723dc": "{0} 的推理强度仅支持 low、medium、high、xhigh、max",
"dbee6e7139243362": "{0} 的接口地址不能为空",
"dc82c5e8fb2ab777": "版本:v{0}",
"de8184da1ef88d03": "已配置",
@@ -215,7 +217,6 @@
"f0b6a23368dd47cc": "可以直接输入模型标识,或从服务端返回的列表中选择。",
"f1aa7326f38b4c09": "拖拽排序",
"f1e0fc261d42fe29": "模型列表 hover 时显示的备注说明。",
"f363622480699c52": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
"f3a76d896853c1df": "未命中",
"f3fae6cccb9004b1": "自定义请求头名称不能为空",
"f474a4108aba4c4c": "关闭服务",
+13 -8
View File
@@ -18,11 +18,14 @@ import {
testModelAdapter,
fetchModelAdapterModels,
} from "@/services/clientApi";
import {
normalizeReasoningEffort,
SUPPORTED_REASONING_EFFORTS,
} from "@/state/modelAdapterReasoning";
const APP_STATE_STORAGE_KEY = "cursor-client:runtime-state:v2";
const GENERIC_SERVICE_ERROR = "服务错误";
const SUPPORTED_MODEL_ADAPTER_TYPES = new Set(["openai", "anthropic"]);
const SUPPORTED_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
const SUPPORTED_ANTHROPIC_THINKING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
export const ANTHROPIC_THINKING_EFFORT_DEFAULT = "xhigh";
export const OPENAI_ENDPOINT_RESPONSES = "/v1/responses";
@@ -165,7 +168,7 @@ export function buildModelAdapterTestRequestHash(source) {
normalizeBaseURL(adapter.baseURL),
asString(adapter.apiKey),
asString(adapter.modelID),
adapter.type === "openai" ? asString(adapter.reasoningEffort || "medium") : "",
adapter.type === "openai" ? asString(adapter.reasoningEffort) : "",
adapter.type === "openai" ? normalizeOpenAIEndpoint(adapter.openAIEndpoint) : "",
adapter.type === "openai" ? String(Boolean(adapter.openAIExtraParamsEnabled)) : "false",
adapter.type === "openai" && adapter.openAIExtraParamsEnabled ? asString(adapter.openAIExtraParamsJSON) : "",
@@ -254,7 +257,7 @@ export function createEmptyModelAdapter() {
apiKey: "",
tooltipData: "备注",
modelID: "",
reasoningEffort: "medium",
reasoningEffort: "",
openAIEndpoint: OPENAI_ENDPOINT_RESPONSES,
openAIExtraParamsEnabled: false,
openAIExtraParamsJSON: OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
@@ -329,7 +332,7 @@ function validateAnthropicExtraParamsJSON(value) {
export function normalizeModelAdapter(source) {
const raw = source && typeof source === "object" ? source : {};
const normalizedType = asString(raw.type).toLowerCase();
const normalizedReasoningEffort = asString(raw.reasoningEffort || raw.reasoning_effort).toLowerCase();
const normalizedReasoningEffort = normalizeReasoningEffort(raw.reasoningEffort ?? raw.reasoning_effort);
const normalizedAnthropicThinkingEffort = asString(
raw.anthropicThinkingEffort
?? raw.anthropic_thinking_effort
@@ -362,9 +365,7 @@ export function normalizeModelAdapter(source) {
apiKey: asString(raw.apiKey || raw.key),
tooltipData: asString(raw.tooltipData),
modelID: asString(raw.modelID),
reasoningEffort: SUPPORTED_REASONING_EFFORTS.has(normalizedReasoningEffort)
? normalizedReasoningEffort
: "medium",
reasoningEffort: normalizedReasoningEffort,
openAIEndpoint: normalizedType === "openai" ? normalizedOpenAIEndpoint : "",
openAIExtraParamsEnabled,
openAIExtraParamsJSON,
@@ -442,7 +443,7 @@ export function validateModelAdapters(source) {
return `${prefix} 的上下文窗口必须为正整数`;
}
if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) {
return `${prefix} 的推理强度仅支持 low、medium、high、xhigh、max`;
return `${prefix} 的推理强度仅支持不设置、low、medium、high、xhigh、max`;
}
if (adapter.type === "anthropic" && adapter.anthropicMaxTokens && (!Number.isInteger(adapter.anthropicMaxTokens) || adapter.anthropicMaxTokens <= 0)) {
return `${prefix} 的最大输出 Token 必须为正整数`;
@@ -1086,6 +1087,10 @@ export async function refreshModelAdapterTestResults() {
export function startModelAdapterTest(adapter) {
const normalized = normalizeModelAdapter(adapter);
const validationError = validateModelAdapters([normalized]);
if (validationError) {
return Promise.reject(new Error(validationError));
}
return testModelAdapter(normalized).then((rawResult) => {
const result = normalizeModelAdapterTestResult(rawResult);
if (result.adapterID) {
@@ -0,0 +1,14 @@
export const SUPPORTED_REASONING_EFFORTS = new Set(["", "low", "medium", "high", "xhigh", "max"]);
export function normalizeReasoningEffort(value) {
if (typeof value === "string") {
return value.trim().toLowerCase();
}
if (value instanceof String) {
return value.toString().trim().toLowerCase();
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value).trim().toLowerCase();
}
return "";
}
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
normalizeReasoningEffort,
SUPPORTED_REASONING_EFFORTS,
} from "./modelAdapterReasoning.js";
test("normalizeReasoningEffort preserves blank and supported values", () => {
assert.equal(normalizeReasoningEffort(""), "");
assert.equal(normalizeReasoningEffort(" HIGH "), "high");
assert.equal(SUPPORTED_REASONING_EFFORTS.has(normalizeReasoningEffort("max")), true);
});
test("normalizeReasoningEffort preserves unknown values for validation", () => {
const normalized = normalizeReasoningEffort(" Unsupported ");
assert.equal(normalized, "unsupported");
assert.equal(SUPPORTED_REASONING_EFFORTS.has(normalized), false);
});
+14 -13
View File
@@ -70,20 +70,21 @@ func Run(resources EmbeddedResources) error {
logger.Init()
netproxy.InstallDefaultTransport()
embeddedCACertPEM := certs.EmbeddedCACertPEM()
logEmbeddedCAInfo(embeddedCACertPEM)
certManager, err := certs.NewEmbeddedManager()
if err := appdata.EnsureAssistantHome(); err != nil {
return err
}
certManager, caCertPEM, err := certs.LoadOrCreateManager(appdata.CACertFilePath(), appdata.CAKeyFilePath())
if err != nil {
return err
}
logCAInfo(caCertPEM)
defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr
proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager)
if err != nil {
return err
}
proxyService := bridge.NewProxyService(proxyServer, certManager, embeddedCACertPEM)
proxyService := bridge.NewProxyService(proxyServer, certManager, caCertPEM)
adAssetBaseURL := defaultBackendBaseURL
if cfg, err := proxyService.LoadUserConfig(); err == nil {
adAssetBaseURL = browserReachableLoopbackBaseURL(cfg.BackendListenAddr)
@@ -442,20 +443,20 @@ func browserReachableLoopbackBaseURL(listenAddr string) string {
return "http://" + net.JoinHostPort(host, port)
}
// logEmbeddedCAInfo 用于处理与 logEmbeddedCAInfo 相关的逻辑
func logEmbeddedCAInfo(certPEM []byte) {
// logCAInfo 记录当前安装专属 CA 的公开信息
func logCAInfo(certPEM []byte) {
if len(certPEM) == 0 {
logger.Errorf("embedded CA is empty")
logger.Errorf("installation CA is empty")
return
}
cert, err := parseEmbeddedCert(certPEM)
cert, err := parseCert(certPEM)
if err != nil {
logger.Errorf("parse embedded CA failed: %v", err)
logger.Errorf("parse installation CA failed: %v", err)
return
}
sum := sha256.Sum256(cert.Raw)
logger.Infof(
"embedded CA loaded: sha256=%s subject=%s valid=%s~%s",
"installation CA loaded: sha256=%s subject=%s valid=%s~%s",
strings.ToUpper(hex.EncodeToString(sum[:])),
cert.Subject.String(),
cert.NotBefore.Format(time.RFC3339),
@@ -463,8 +464,8 @@ func logEmbeddedCAInfo(certPEM []byte) {
)
}
// parseEmbeddedCert 用于处理与 parseEmbeddedCert 相关的逻辑
func parseEmbeddedCert(data []byte) (*x509.Certificate, error) {
// parseCert 解析 DER 或 PEM 编码的证书
func parseCert(data []byte) (*x509.Certificate, error) {
if block, _ := pem.Decode(data); block != nil {
return x509.ParseCertificate(block.Bytes)
}
+5
View File
@@ -70,3 +70,8 @@ func LogsRootPath() string {
func CACertFilePath() string {
return filepath.Join(DataRootPath(), "ca.crt")
}
// CAKeyFilePath 返回仅供本安装使用的 CA 私钥路径。
func CAKeyFilePath() string {
return filepath.Join(DataRootPath(), "ca.key")
}
+3 -1
View File
@@ -97,6 +97,7 @@ internal/backend/
- `~/.cursor-local-assistant-v2/config.yaml`
- `~/.cursor-local-assistant-v2/data/ca.crt`
- `~/.cursor-local-assistant-v2/data/ca.key`
- `~/.cursor-local-assistant-v2/data/ads/`
- `~/.cursor-local-assistant-v2/history/`
- `~/.cursor-local-assistant-v2/logs/`
@@ -104,7 +105,8 @@ internal/backend/
约定:
- `config.yaml` 是用户配置
- `data/ca.crt` 是注入给宿主的 CA 证书
- `data/ca.crt`首次运行时为当前用户生成、注入给宿主的 CA 证书
- `data/ca.key` 是与该证书配套的本地私钥,权限固定为 `0600`,不得打包或提交到仓库
- `data/ads/` 是广告包与资源缓存目录
- `history/` 是会话事实与全局 usage JSON 目录,不属于日志
- `logs/` 只保留必要文本运行日志
+79 -1
View File
@@ -2,8 +2,15 @@
package execbridge
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"strings"
"sync/atomic"
"time"
@@ -31,10 +38,18 @@ type ExecApplyResult struct {
ToolResultPayload string
// ToolCall 保存可用于发 ToolCallCompletedUpdate 的工具调用对象;当前仅对支持 ToolCall 的执行型工具可用。
ToolCall *agentv1.ToolCall
// ContentBlobs 保存需要在提交 history 前写入内容寻址存储的二进制内容。
ContentBlobs []ContentBlob
// ExecuteHookResponse 保存 execute hook 的结构化响应。
ExecuteHookResponse *agentv1.ExecuteHookResponse
}
// ContentBlob 表示由内容哈希稳定寻址的执行结果二进制数据。
type ContentBlob struct {
ID []byte
Data []byte
}
// OpenExecContext 表示执行桥打开请求时需要的最小上下文。
type OpenExecContext struct {
ConversationID string
@@ -145,6 +160,9 @@ func (bridge *Bridge) ApplyExecClientMessage(msg *agentv1.ExecClientMessage, pen
readResult := normalizeReadResultForModel(msg.GetReadResult())
result.ToolResultPayload = summarizeReadResult(readResult)
result.ToolCall = buildReadCompletedToolCall(pending.ToolCallID, pending.ArgsJSON, readResult)
if contentBlob, ok := readImageContentBlob(readResult); ok {
result.ContentBlobs = []ContentBlob{contentBlob}
}
result.IsTerminal = true
return result, nil
case "write":
@@ -2285,6 +2303,64 @@ func buildReadMcpResourceCompletedToolCall(argsJSON []byte, result *agentv1.Read
}
}
func supportedReadImageMIMEType(data []byte) string {
if len(data) == 0 {
return ""
}
detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(data)))
configuration, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || configuration.Width <= 0 || configuration.Height <= 0 {
return ""
}
switch strings.ToLower(strings.TrimSpace(format)) {
case "png":
if detected == "image/png" {
return detected
}
case "jpeg":
if detected == "image/jpeg" {
return detected
}
case "gif":
if detected == "image/gif" {
return detected
}
}
return ""
}
func readImageContentBlob(result *agentv1.ReadResult) (ContentBlob, bool) {
success := result.GetSuccess()
if success == nil {
return ContentBlob{}, false
}
data := success.GetData()
if supportedReadImageMIMEType(data) == "" {
return ContentBlob{}, false
}
digest := sha256.Sum256(data)
return ContentBlob{
ID: append([]byte(nil), digest[:]...),
Data: append([]byte(nil), data...),
}, true
}
func readImageBlobID(data []byte) ([]byte, bool) {
if supportedReadImageMIMEType(data) == "" {
return nil, false
}
digest := sha256.Sum256(data)
return append([]byte(nil), digest[:]...), true
}
func readImageDataBlobOutput(data []byte) *agentv1.ReadToolSuccess_DataBlobId {
blobID, ok := readImageBlobID(data)
if !ok {
return nil
}
return &agentv1.ReadToolSuccess_DataBlobId{DataBlobId: blobID}
}
// convertReadResultToReadToolResult 把 `ReadResult` 映射为 `ReadToolResult`。
func convertReadResultToReadToolResult(result *agentv1.ReadResult) *agentv1.ReadToolResult {
if result == nil {
@@ -2318,7 +2394,9 @@ func convertReadResultToReadToolResult(result *agentv1.ReadResult) *agentv1.Read
if content != "" {
toolSuccess.Output = &agentv1.ReadToolSuccess_Content{Content: content}
} else if len(data) > 0 {
if len(data) > readReplayBinaryLimit {
if imageOutput := readImageDataBlobOutput(data); imageOutput != nil {
toolSuccess.Output = imageOutput
} else if len(data) > readReplayBinaryLimit {
toolSuccess.ExceededLimit = true
toolSuccess.Output = &agentv1.ReadToolSuccess_Content{
Content: replayTruncationNotice("Read binary data", readReplayBinaryLimit, 0, len(data)),
@@ -0,0 +1,125 @@
package execbridge
import (
"bytes"
"crypto/sha256"
"image"
"image/color"
"image/png"
"strings"
"testing"
"cursor/gen/agentv1"
runtimecore "cursor/internal/backend/agent/core"
)
func TestApplyExecClientMessageReturnsContentAddressedReadImage(t *testing.T) {
imageData := validReadTestPNG(t)
wantBlobID := sha256.Sum256(imageData)
result, err := NewBridge().ApplyExecClientMessage(&agentv1.ExecClientMessage{
Message: &agentv1.ExecClientMessage_ReadResult{
ReadResult: &agentv1.ReadResult{
Result: &agentv1.ReadResult_Success{
Success: &agentv1.ReadSuccess{
Path: "diagram.png",
FileSize: int64(len(imageData)),
OutputBlobId: append([]byte(nil), wantBlobID[:]...),
Output: &agentv1.ReadSuccess_Data{Data: imageData},
},
},
},
},
}, runtimecore.PendingExec{
ExecKind: "read",
ToolCallID: "call-1",
ArgsJSON: []byte(`{"path":"diagram.png"}`),
})
if err != nil {
t.Fatalf("ApplyExecClientMessage() error = %v", err)
}
if len(result.ContentBlobs) != 1 {
t.Fatalf("content blob count = %d, want 1", len(result.ContentBlobs))
}
if !bytes.Equal(result.ContentBlobs[0].ID, wantBlobID[:]) || !bytes.Equal(result.ContentBlobs[0].Data, imageData) {
t.Fatalf("content blob = %#v", result.ContentBlobs[0])
}
readSuccess := result.ToolCall.GetReadToolCall().GetResult().GetSuccess()
if readSuccess == nil {
t.Fatal("read tool result is not successful")
}
if !bytes.Equal(readSuccess.GetDataBlobId(), wantBlobID[:]) {
t.Fatalf("data_blob_id = %x, want %x", readSuccess.GetDataBlobId(), wantBlobID)
}
if len(readSuccess.GetData()) != 0 {
t.Fatalf("read tool result retained %d image bytes", len(readSuccess.GetData()))
}
}
func TestApplyExecClientMessageUsesComputedImageBlobID(t *testing.T) {
imageData := validReadTestPNG(t)
wantBlobID := sha256.Sum256(imageData)
result, err := NewBridge().ApplyExecClientMessage(&agentv1.ExecClientMessage{
Message: &agentv1.ExecClientMessage_ReadResult{
ReadResult: &agentv1.ReadResult{
Result: &agentv1.ReadResult_Success{
Success: &agentv1.ReadSuccess{
Path: "diagram.png",
OutputBlobId: bytes.Repeat([]byte{0xff}, sha256.Size),
Output: &agentv1.ReadSuccess_Data{Data: imageData},
},
},
},
},
}, runtimecore.PendingExec{ExecKind: "read", ToolCallID: "call-1"})
if err != nil {
t.Fatalf("ApplyExecClientMessage() error = %v", err)
}
if !bytes.Equal(result.ContentBlobs[0].ID, wantBlobID[:]) {
t.Fatalf("content blob id = %x, want computed %x", result.ContentBlobs[0].ID, wantBlobID)
}
}
func TestConvertReadResultKeepsTextAndLimitsUnsupportedBinary(t *testing.T) {
textResult := convertReadResultToReadToolResult(&agentv1.ReadResult{
Result: &agentv1.ReadResult_Success{
Success: &agentv1.ReadSuccess{
Path: "notes.txt",
Output: &agentv1.ReadSuccess_Content{Content: "hello"},
},
},
})
if got := textResult.GetSuccess().GetContent(); got != "hello" {
t.Fatalf("text read content = %q, want hello", got)
}
largeBinary := bytes.Repeat([]byte{0xff}, readReplayBinaryLimit+1)
binaryResult := convertReadResultToReadToolResult(&agentv1.ReadResult{
Result: &agentv1.ReadResult_Success{
Success: &agentv1.ReadSuccess{
Path: "archive.bin",
Output: &agentv1.ReadSuccess_Data{Data: largeBinary},
},
},
})
binarySuccess := binaryResult.GetSuccess()
if binarySuccess == nil || !binarySuccess.GetExceededLimit() {
t.Fatal("large non-image binary was not limited")
}
if binarySuccess.GetData() != nil || binarySuccess.GetDataBlobId() != nil {
t.Fatal("large non-image binary was retained")
}
if !strings.Contains(binarySuccess.GetContent(), "Read binary data") {
t.Fatalf("large binary fallback = %q", binarySuccess.GetContent())
}
}
func validReadTestPNG(t *testing.T) []byte {
t.Helper()
value := image.NewRGBA(image.Rect(0, 0, 2, 2))
value.Set(0, 0, color.RGBA{R: 0x44, G: 0x88, B: 0xcc, A: 0xff})
var encoded bytes.Buffer
if err := png.Encode(&encoded, value); err != nil {
t.Fatalf("encode test png: %v", err)
}
return encoded.Bytes()
}
+19 -2
View File
@@ -1126,7 +1126,16 @@ func isAnthropicCacheableBlock(block map[string]any) bool {
case contentPartTypeText:
return strings.TrimSpace(anthropicStringField(block, "text")) != ""
case "tool_result":
return strings.TrimSpace(anthropicStringField(block, "content")) != ""
switch content := block["content"].(type) {
case string:
return strings.TrimSpace(content) != ""
case []map[string]any:
return len(content) > 0
case []any:
return len(content) > 0
default:
return false
}
case "tool_use":
return strings.TrimSpace(anthropicStringField(block, "id")) != "" && strings.TrimSpace(anthropicStringField(block, "name")) != ""
default:
@@ -1178,10 +1187,18 @@ func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, r
if toolUseID == "" {
return nil, nil, fmt.Errorf("anthropic tool message requires tool_call_id")
}
var content any = message.Content
if hasImageContentParts(message.ContentParts) {
contentBlocks, err := anthropicContentBlocks(message)
if err != nil {
return nil, nil, err
}
content = contentBlocks
}
pendingToolResults = append(pendingToolResults, map[string]any{
"type": "tool_result",
"tool_use_id": toolUseID,
"content": message.Content,
"content": content,
})
case "user", "assistant":
flushToolResults()
+11 -2
View File
@@ -50,6 +50,7 @@ type openAIResponsesRequestBody struct {
type openAIResponsesReasoning struct {
Effort string `json:"effort,omitempty"`
Summary string `json:"summary,omitempty"`
}
type openAIToolAccumulator struct {
@@ -944,7 +945,7 @@ func (adapter *OpenAIAdapter) streamResponses(ctx context.Context, req StreamReq
requestBody.Tools = tools
}
if effort := strings.TrimSpace(req.ReasoningEffort); effort != "" {
requestBody.Reasoning = &openAIResponsesReasoning{Effort: effort}
requestBody.Reasoning = &openAIResponsesReasoning{Effort: effort, Summary: "auto"}
requestBody.Include = []string{"reasoning.encrypted_content"}
}
body = requestBody
@@ -1967,10 +1968,18 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
}
if role == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
callID := openAIResponsesToolMessageCallID(message, responsesCallIDs)
var output any = openAIResponsesMessageText(message)
if hasImageContentParts(message.ContentParts) {
content, err := openAIResponsesMessageContent(message, false)
if err != nil {
return "", nil, err
}
output = content
}
items = append(items, map[string]any{
"type": "function_call_output",
"call_id": callID,
"output": openAIResponsesMessageText(message),
"output": output,
})
activeAssistantReasoningKey = ""
continue
@@ -2,12 +2,140 @@ package modeladapter
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestOpenAIResponsesRequestsReasoningSummary(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if err := json.NewDecoder(request.Body).Decode(&requestBody); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
writer.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"check the request\"}\n\n")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"model\":\"gpt-5.6\",\"status\":\"completed\",\"output_text\":\"done\"}}\n\n")
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := &OpenAIAdapter{client: server.Client()}
events := make([]ModelEvent, 0, 4)
err := adapter.Stream(context.Background(), StreamRequest{
RequestID: "request-1",
RunID: "run-1",
ModelCallID: "model-call-1",
BaseURL: server.URL,
APIKey: "test-key",
ProviderModelID: "gpt-5.6",
OpenAIEndpoint: "/v1/responses",
ReasoningEffort: "high",
Messages: []Message{{Role: "user", Content: "hello"}},
MaxTokens: 128,
}, func(event ModelEvent) error {
events = append(events, event)
return nil
})
if err != nil {
t.Fatalf("stream failed: %v", err)
}
reasoning, ok := requestBody["reasoning"].(map[string]any)
if !ok {
t.Fatalf("reasoning request body missing: %#v", requestBody)
}
if got := reasoning["effort"]; got != "high" {
t.Fatalf("reasoning.effort = %#v, want high", got)
}
if got := reasoning["summary"]; got != "auto" {
t.Fatalf("reasoning.summary = %#v, want auto", got)
}
include, ok := requestBody["include"].([]any)
if !ok || len(include) != 1 || include[0] != "reasoning.encrypted_content" {
t.Fatalf("reasoning include = %#v, want encrypted content", requestBody["include"])
}
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingDelta, 1)
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingCompleted, 1)
assertOpenAIEventKindCount(t, events, ModelEventKindTextDelta, 1)
}
func TestOpenAIResponsesOmitsReasoningWhenEffortBlank(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if err := json.NewDecoder(request.Body).Decode(&requestBody); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
writer.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"model\":\"grok-composer-2.5-fast\",\"status\":\"completed\",\"output_text\":\"done\"}}\n\n")
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := &OpenAIAdapter{client: server.Client()}
err := adapter.Stream(context.Background(), StreamRequest{
RequestID: "request-1",
RunID: "run-1",
ModelCallID: "model-call-1",
BaseURL: server.URL,
APIKey: "test-key",
ProviderModelID: "grok-composer-2.5-fast",
OpenAIEndpoint: "/v1/responses",
Messages: []Message{{Role: "user", Content: "hello"}},
MaxTokens: 128,
}, func(ModelEvent) error { return nil })
if err != nil {
t.Fatalf("stream failed: %v", err)
}
if _, exists := requestBody["reasoning"]; exists {
t.Fatalf("reasoning should be omitted when effort is blank: %#v", requestBody["reasoning"])
}
if _, exists := requestBody["reasoning_effort"]; exists {
t.Fatalf("reasoning_effort should be omitted when effort is blank: %#v", requestBody["reasoning_effort"])
}
}
func TestOpenAIChatCompletionsOmitsReasoningWhenEffortBlank(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if err := json.NewDecoder(request.Body).Decode(&requestBody); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
writer.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(writer, "data: {\"model\":\"grok-composer-2.5-fast\",\"choices\":[{\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n\n")
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := &OpenAIAdapter{client: server.Client()}
err := adapter.Stream(context.Background(), StreamRequest{
RequestID: "request-1",
RunID: "run-1",
ModelCallID: "model-call-1",
BaseURL: server.URL,
APIKey: "test-key",
ProviderModelID: "grok-composer-2.5-fast",
OpenAIEndpoint: "/v1/chat/completions",
Messages: []Message{{Role: "user", Content: "hello"}},
MaxTokens: 128,
}, func(ModelEvent) error { return nil })
if err != nil {
t.Fatalf("stream failed: %v", err)
}
for _, field := range []string{"reasoning_effort", "reasoning", "include"} {
if value, exists := requestBody[field]; exists {
t.Fatalf("%s should be omitted when effort is blank: %#v", field, value)
}
}
}
func TestOpenAIChatCompletionsIgnoresBlankFinishReason(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "text/event-stream")
@@ -1,10 +1,64 @@
package modeladapter
import (
"context"
"reflect"
"testing"
"time"
legacyruntime "cursor/internal/runtime"
)
type recordingModelAdapter struct {
request StreamRequest
}
func (adapter *recordingModelAdapter) Stream(_ context.Context, req StreamRequest, _ func(ModelEvent) error) error {
adapter.request = req
return nil
}
type staticChannelResolver struct {
channel *legacyruntime.ResolvedChannel
}
func (resolver staticChannelResolver) SelectChannelForModel(context.Context, string) (*legacyruntime.ResolvedChannel, error) {
return resolver.channel, nil
}
func (staticChannelResolver) ProviderStreamIdleTimeout(context.Context) time.Duration {
return time.Second
}
func TestRouterRuntimeDisabledClearsReasoningEffort(t *testing.T) {
openAI := &recordingModelAdapter{}
router := &Router{
openai: openAI,
resolver: staticChannelResolver{channel: &legacyruntime.ResolvedChannel{
ID: "channel-a",
Provider: "openai",
Model: "grok-composer-2.5-fast",
ReasoningEffort: "medium",
}},
}
requestKnobs := map[string]any{"reasoning_effort": "medium"}
err := router.Stream(context.Background(), StreamRequest{
ModelID: "channel-a",
ThinkingEffort: "disabled",
RequestKnobs: requestKnobs,
}, func(ModelEvent) error { return nil })
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
if got := openAI.request.ReasoningEffort; got != "" {
t.Fatalf("ReasoningEffort = %q, want blank", got)
}
if _, exists := openAI.request.RequestKnobs["reasoning_effort"]; exists {
t.Fatalf("reasoning_effort knob should be removed: %#v", openAI.request.RequestKnobs)
}
}
func TestSanitizeProviderMessagesMergesLegacyAssistantTextAndToolCallTurnsIdempotently(t *testing.T) {
input := []Message{
{
@@ -0,0 +1,86 @@
package modeladapter
import (
"strings"
"testing"
)
func TestToolImageProviderEncodings(t *testing.T) {
message := toolImageMessageForTest()
t.Run("openai_chat", func(t *testing.T) {
items, err := normalizeOpenAIProviderMessages([]Message{message}, false)
if err != nil {
t.Fatalf("normalizeOpenAIProviderMessages() error = %v", err)
}
if len(items) != 1 || items[0]["role"] != "tool" || items[0]["tool_call_id"] != "call-1" {
t.Fatalf("openai chat tool message = %#v", items)
}
content, ok := items[0]["content"].([]map[string]any)
if !ok || len(content) != 2 {
t.Fatalf("openai chat content = %#v", items[0]["content"])
}
imageURL, ok := content[1]["image_url"].(map[string]any)
if content[1]["type"] != "image_url" || !ok || !strings.HasPrefix(imageURL["url"].(string), "data:image/png;base64,") {
t.Fatalf("openai chat image part = %#v", content[1])
}
})
t.Run("openai_responses", func(t *testing.T) {
_, items, err := normalizeOpenAIResponsesInput([]Message{message})
if err != nil {
t.Fatalf("normalizeOpenAIResponsesInput() error = %v", err)
}
if len(items) != 1 || items[0]["type"] != "function_call_output" {
t.Fatalf("openai responses items = %#v", items)
}
content, ok := items[0]["output"].([]map[string]any)
if !ok || len(content) != 2 {
t.Fatalf("openai responses output = %#v", items[0]["output"])
}
if content[0]["type"] != "input_text" || content[1]["type"] != "input_image" {
t.Fatalf("openai responses content = %#v", content)
}
})
t.Run("anthropic", func(t *testing.T) {
_, messages, err := normalizeAnthropicProviderMessages([]Message{message}, false, false)
if err != nil {
t.Fatalf("normalizeAnthropicProviderMessages() error = %v", err)
}
if len(messages) != 1 || messages[0].Role != "user" || len(messages[0].Content) != 1 {
t.Fatalf("anthropic messages = %#v", messages)
}
toolResult := messages[0].Content[0]
if toolResult["type"] != "tool_result" || toolResult["tool_use_id"] != "call-1" {
t.Fatalf("anthropic tool result = %#v", toolResult)
}
content, ok := toolResult["content"].([]map[string]any)
if !ok || len(content) != 2 {
t.Fatalf("anthropic tool content = %#v", toolResult["content"])
}
if content[0]["type"] != "text" || content[1]["type"] != "image" {
t.Fatalf("anthropic content blocks = %#v", content)
}
})
}
func toolImageMessageForTest() Message {
return Message{
Role: "tool",
Content: "read binary bytes=16",
ToolCallID: "call-1",
Name: "Read",
ContentParts: []ContentPart{
{Type: "text", Text: "read binary bytes=16"},
{
Type: "image",
Image: &ImageContent{
MIMEType: "image/png",
Path: "diagram.png",
Data: []byte("\x89PNG\r\n\x1a\nimage"),
},
},
},
}
}
+3
View File
@@ -730,6 +730,9 @@ func (service *Service) handleProviderDoneEvent(stream *ActiveStream, payload *s
service.setTurnPhase(stream, TurnPhaseFailed)
return service.closeStreamWithProviderError(stream, conversationID, turnSeq, requestID, accumulatedText, accumulatedReasoning, accumulatedReasoningSignature, accumulatedReasoningSignatureSource, accumulatedReasoningItemID, accumulatedReasoningStatus, accumulatedReasoningSummary, usage, providerErr, !hadToolInvocation)
}
if err := service.flushAssistantText(stream, conversationID, turnSeq, requestID, accumulatedText, accumulatedReasoning, accumulatedReasoningSignature, accumulatedReasoningSignatureSource, accumulatedReasoningItemID, accumulatedReasoningStatus, accumulatedReasoningSummary, !hadToolInvocation); err != nil {
return service.failStream(stream, "unknown", fmt.Errorf("flush failed provider output: %w", err))
}
service.setTurnPhase(stream, TurnPhaseFailed)
return service.failStream(stream, "unknown", payload.Err)
}
+42 -53
View File
@@ -19,15 +19,29 @@ type pendingCheckpointBlobWrite struct {
blob CheckpointBlob
}
func clonePendingTurnCompletion(completion *pendingTurnCompletion) *pendingTurnCompletion {
func successfulCheckpointTerminalAction(completion *pendingTurnCompletion) checkpointTerminalAction {
if completion == nil {
return nil
return checkpointTerminalAction{}
}
return checkpointTerminalAction{
Kind: checkpointTerminalActionComplete,
Completion: *completion,
}
}
func failedCheckpointTerminalAction(errorCode string, errorMessage string) checkpointTerminalAction {
return checkpointTerminalAction{
Kind: checkpointTerminalActionFail,
ErrorCode: strings.TrimSpace(errorCode),
ErrorMessage: strings.TrimSpace(errorMessage),
}
cloned := *completion
return &cloned
}
func (service *Service) queueCheckpointProjection(stream *ActiveStream, projection *CheckpointProjection, completion *pendingTurnCompletion) error {
return service.queueCheckpointProjectionWithTerminal(stream, projection, successfulCheckpointTerminalAction(completion))
}
func (service *Service) queueCheckpointProjectionWithTerminal(stream *ActiveStream, projection *CheckpointProjection, terminal checkpointTerminalAction) error {
if service == nil || stream == nil || projection == nil || projection.State == nil {
return nil
}
@@ -43,8 +57,8 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
if stream.ConfirmedCheckpointBlobs == nil {
stream.ConfirmedCheckpointBlobs = make(map[string]struct{})
}
if completion == nil && stream.PendingCheckpoint != nil {
completion = stream.PendingCheckpoint.Completion
if terminal.Kind == checkpointTerminalActionNone && stream.PendingCheckpoint != nil {
terminal = stream.PendingCheckpoint.Terminal
}
required := make(map[string]struct{}, len(projection.Blobs))
pendingKeys := make(map[string]struct{}, len(stream.PendingCheckpointBlobWrites))
@@ -76,9 +90,9 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
stream.PendingCheckpoint = &pendingCheckpointPublish{
State: state,
Required: required,
Completion: clonePendingTurnCompletion(completion),
Terminal: terminal,
}
if completion != nil {
if terminal.Kind != checkpointTerminalActionNone {
stream.Phase = TurnPhaseCheckpointing
}
stream.UpdatedAt = time.Now().UTC()
@@ -94,13 +108,8 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
if service.checkpointProjectionReady(stream) {
return service.publishReadyCheckpoint(stream)
}
// Keep the latest live UI state ahead of an immediate client abort. Blob writes are
// ordered before this snapshot; acknowledgements still gate terminal completion.
if completion == nil {
if err := service.publishPendingCheckpoint(stream); err != nil {
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("publish pending checkpoint: %w", err))
}
}
// Checkpoints reference these Blob IDs, so the client must confirm every
// required Blob before the checkpoint becomes visible.
service.scheduleStreamTimer(
stream,
providerTimerKey(streamTimerCheckpointBlobs, ""),
@@ -113,31 +122,6 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
return nil
}
func (service *Service) publishPendingCheckpoint(stream *ActiveStream) error {
if service == nil || stream == nil {
return nil
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
if pending == nil || pending.Published {
stream.mu.Unlock()
return nil
}
pending.Published = true
state := pending.State
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
stream.mu.Lock()
if stream.PendingCheckpoint == pending {
pending.Published = false
}
stream.mu.Unlock()
return err
}
return nil
}
func (service *Service) checkpointProjectionReady(stream *ActiveStream) bool {
if stream == nil {
return false
@@ -207,24 +191,18 @@ func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
}
stream.PendingCheckpoint = nil
state := pending.State
completion := clonePendingTurnCompletion(pending.Completion)
published := pending.Published
terminal := pending.Terminal
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
if !published {
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
if completion != nil {
log.Printf("forwarder checkpoint publish skipped before successful terminal request_id=%s err=%v", stream.RequestID, err)
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
if terminal.Kind != checkpointTerminalActionNone {
log.Printf("forwarder checkpoint publish skipped before terminal request_id=%s err=%v", stream.RequestID, err)
return service.finishCheckpointTerminalAction(stream, terminal)
}
return err
}
}
if completion != nil {
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
}
return nil
return service.finishCheckpointTerminalAction(stream, terminal)
}
func (service *Service) handleCheckpointBlobTimeout(stream *ActiveStream) error {
@@ -251,12 +229,23 @@ func (service *Service) finishAfterCheckpointSyncFailure(stream *ActiveStream, c
if cause != nil {
log.Printf("forwarder checkpoint blob sync skipped request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, cause)
}
if pending != nil && pending.Completion != nil {
return service.finishSuccessfulTurnAfterCheckpoint(stream, *pending.Completion)
if pending != nil {
return service.finishCheckpointTerminalAction(stream, pending.Terminal)
}
return nil
}
func (service *Service) finishCheckpointTerminalAction(stream *ActiveStream, terminal checkpointTerminalAction) error {
switch terminal.Kind {
case checkpointTerminalActionComplete:
return service.finishSuccessfulTurnAfterCheckpoint(stream, terminal.Completion)
case checkpointTerminalActionFail:
return service.finishFailedTurnAfterCheckpoint(stream, terminal.ErrorCode, terminal.ErrorMessage)
default:
return nil
}
}
func (service *Service) discardPendingCheckpoint(stream *ActiveStream, reason string) {
if stream == nil {
return
@@ -8,22 +8,43 @@ import (
"cursor/gen/agentv1"
)
func TestCheckpointBlobSyncPublishesNonTerminalCheckpointBeforeAcknowledgements(t *testing.T) {
func TestCheckpointBlobSyncWaitsForAcknowledgementsBeforePublishingNonTerminalCheckpoint(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
events := readCheckpointTestEvents(t, service, stream)
if len(events) != len(projection.Blobs)+1 {
t.Fatalf("events before ACK = %d, want %d Blob writes and one checkpoint", len(events), len(projection.Blobs))
if len(events) != len(projection.Blobs) {
t.Fatalf("events before ACK = %d, want %d Blob writes", len(events), len(projection.Blobs))
}
for _, event := range events[:len(projection.Blobs)] {
for _, event := range events {
if event.Message.GetKvServerMessage().GetSetBlobArgs() == nil {
t.Fatalf("event before ACK = %#v, want set_blob_args", event.Message)
}
}
if checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate(); checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
t.Fatalf("last event before ACK = %#v, want one Blob-backed turn", events[len(events)-1].Message)
stream.mu.Lock()
var firstRequestID uint32
for requestID := range stream.PendingCheckpointBlobWrites {
firstRequestID = requestID
break
}
stream.mu.Unlock()
if firstRequestID == 0 {
t.Fatal("checkpoint projection has no pending Blob writes")
}
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
Id: firstRequestID,
Message: &agentv1.KvClientMessage_SetBlobResult{
SetBlobResult: &agentv1.SetBlobResult{},
},
}); err != nil {
t.Fatalf("first Blob ACK error = %v", err)
}
for _, event := range readCheckpointTestEvents(t, service, stream) {
if event.Message.GetConversationCheckpointUpdate() != nil {
t.Fatal("checkpoint published after only a partial Blob acknowledgement")
}
}
acknowledgeCheckpointBlobs(t, service, stream)
@@ -98,7 +119,123 @@ func TestCheckpointBlobTimeoutDoesNotFailSuccessfulTurn(t *testing.T) {
}
}
func TestCancellationKeepsPublishedCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
func TestCheckpointBlobSyncPublishesCheckpointBeforeFailedTerminal(t *testing.T) {
service, stream, _ := testCheckpointBlobProjection(t)
if err := service.failActiveStream(
stream,
stream.ConversationID,
stream.RequestID,
"model-call-1",
"provider_error",
"provider failed",
); err != nil {
t.Fatalf("failActiveStream() error = %v", err)
}
for _, event := range readCheckpointTestEvents(t, service, stream) {
if event.Message.GetConversationCheckpointUpdate() != nil || event.End {
t.Fatalf("event before ACK = %#v, want only Blob writes", event)
}
}
stream.mu.Lock()
phaseBeforeACK := stream.Phase
statusBeforeACK := stream.Status
stream.mu.Unlock()
if phaseBeforeACK != TurnPhaseCheckpointing || isTerminalStreamStatus(statusBeforeACK) {
t.Fatalf("before ACK phase=%s status=%s, want checkpointing and non-terminal", phaseBeforeACK, statusBeforeACK)
}
acknowledgeCheckpointBlobs(t, service, stream)
events := readCheckpointTestEvents(t, service, stream)
checkpointIndex, endIndex := -1, -1
for index, event := range events {
switch {
case event.Message.GetConversationCheckpointUpdate() != nil:
checkpointIndex = index
case event.End:
endIndex = index
if event.TerminalErrorCode != "provider_error" || event.TerminalErrorMessage != "provider failed" {
t.Fatalf("terminal event = %#v, want provider error", event)
}
}
}
if checkpointIndex < 0 || endIndex <= checkpointIndex {
t.Fatalf("terminal order checkpoint=%d end=%d", checkpointIndex, endIndex)
}
stream.mu.Lock()
phaseAfterACK := stream.Phase
statusAfterACK := stream.Status
stream.mu.Unlock()
if phaseAfterACK != TurnPhaseFailed || statusAfterACK != StreamStatusFailed {
t.Fatalf("after ACK phase=%s status=%s, want failed", phaseAfterACK, statusAfterACK)
}
}
func TestCheckpointBlobTimeoutStillPublishesFailedTerminal(t *testing.T) {
service, stream, _ := testCheckpointBlobProjection(t)
if err := service.failActiveStream(
stream,
stream.ConversationID,
stream.RequestID,
"model-call-1",
"provider_error",
"provider failed",
); err != nil {
t.Fatalf("failActiveStream() error = %v", err)
}
if err := service.handleCheckpointBlobTimeout(stream); err != nil {
t.Fatalf("handleCheckpointBlobTimeout() error = %v", err)
}
events := readCheckpointTestEvents(t, service, stream)
var checkpoint, failedEnd bool
for _, event := range events {
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
failedEnd = failedEnd || event.End && event.TerminalErrorCode == "provider_error" && event.TerminalErrorMessage == "provider failed"
}
if checkpoint || !failedEnd {
t.Fatalf("timeout events checkpoint=%v failed_end=%v", checkpoint, failedEnd)
}
}
func TestManualCompactionNoopWaitsForCheckpointBeforeTerminal(t *testing.T) {
service, stream, _ := testCheckpointBlobProjection(t)
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
if err != nil {
t.Fatalf("snapshotCheckpointConversation() error = %v", err)
}
if _, err := service.store.SaveConversationWithEntries(stream.ConversationID, conversation, conversation.Entries); err != nil {
t.Fatalf("SaveConversationWithEntries() error = %v", err)
}
if err := service.finishManualCompactionNoop(stream); err != nil {
t.Fatalf("finishManualCompactionNoop() error = %v", err)
}
for _, event := range readCheckpointTestEvents(t, service, stream) {
if event.Message.GetInteractionUpdate().GetTurnEnded() != nil || event.End {
t.Fatalf("terminal event before checkpoint Blob ACK = %#v", event)
}
}
acknowledgeCheckpointBlobs(t, service, stream)
events := readCheckpointTestEvents(t, service, stream)
checkpointIndex, turnEndedIndex, endIndex := -1, -1, -1
for index, event := range events {
switch {
case event.Message.GetConversationCheckpointUpdate() != nil:
checkpointIndex = index
case event.Message.GetInteractionUpdate().GetTurnEnded() != nil:
turnEndedIndex = index
case event.End:
endIndex = index
}
}
if checkpointIndex < 0 || turnEndedIndex <= checkpointIndex || endIndex <= turnEndedIndex {
t.Fatalf("terminal order checkpoint=%d turn_ended=%d end=%d", checkpointIndex, turnEndedIndex, endIndex)
}
}
func TestCancellationDiscardsUnpublishedCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
@@ -110,8 +247,8 @@ func TestCancellationKeepsPublishedCheckpointAndIgnoresLateAcknowledgements(t *t
checkpointBeforeCancel++
}
}
if checkpointBeforeCancel != 1 {
t.Fatalf("checkpoints before cancel = %d, want 1", checkpointBeforeCancel)
if checkpointBeforeCancel != 0 {
t.Fatalf("checkpoints before cancel = %d, want 0", checkpointBeforeCancel)
}
stream.mu.Lock()
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
@@ -149,7 +286,7 @@ func TestCancellationKeepsPublishedCheckpointAndIgnoresLateAcknowledgements(t *t
stream.mu.Lock()
pending := stream.PendingCheckpoint
stream.mu.Unlock()
if checkpointCount != 1 || !canceledEnd || pending != nil {
if checkpointCount != 0 || !canceledEnd || pending != nil {
t.Fatalf("cancel events checkpoints=%d canceled_end=%v pending=%v", checkpointCount, canceledEnd, pending != nil)
}
}
+26 -61
View File
@@ -234,7 +234,7 @@ func (service *Service) buildLegacyCompactionPlan(base *compactionPlan, conversa
if conversation == nil || base == nil {
return nil, nil
}
candidates := buildContextCompactionCandidates(checkpointProjectionEntries(conversation.Entries), base.CurrentTurnSeq, base.CurrentRequestID)
candidates := buildContextCompactionCandidates(replayablePromptProjectionEntries(conversation.Entries), base.CurrentTurnSeq, base.CurrentRequestID)
if len(candidates) == 0 {
return nil, nil
}
@@ -260,7 +260,7 @@ func (service *Service) buildAutoCompactionPlanFromHistory(base *compactionPlan,
if err != nil {
return nil, err
}
currentCandidate, hasCurrentCandidate := buildCurrentTurnCompactionCandidate(checkpointProjectionEntries(conversation.Entries), base.CurrentTurnSeq, base.CurrentRequestID)
currentCandidate, hasCurrentCandidate := buildCurrentTurnCompactionCandidate(replayablePromptProjectionEntries(conversation.Entries), base.CurrentTurnSeq, base.CurrentRequestID)
if !hasCurrentCandidate {
return legacyPlan, nil
}
@@ -447,16 +447,8 @@ func (service *Service) handleCompactionEvent(stream *ActiveStream, payload *str
if err := service.completeManualCompactionTurn(stream); err != nil {
return service.failStream(stream, "unknown", err)
}
if err := service.broker.Publish(stream.RequestID, StreamEvent{
Message: buildTurnEndedMessage(0, 0, 0, 0),
}); err != nil {
return service.failStream(stream, "unknown", err)
}
if err := service.broker.Complete(stream.RequestID, "", ""); err != nil {
return service.failStream(stream, "unknown", err)
}
service.setTurnPhase(stream, TurnPhaseCompleted)
return nil
completion := manualCompactionTurnCompletion(stream)
return service.publishCheckpointWithCompletion(stream.RequestID, stream.ConversationID, &completion)
}
return service.requestProviderAction(stream, providerActionResume)
}
@@ -500,12 +492,8 @@ func (service *Service) finishManualCompactionNoop(stream *ActiveStream) error {
if err := service.completeManualCompactionTurn(stream); err != nil {
return err
}
if err := service.broker.Publish(stream.RequestID, StreamEvent{
Message: buildTurnEndedMessage(0, 0, 0, 0),
}); err != nil {
return err
}
return service.broker.Complete(stream.RequestID, "", "")
completion := manualCompactionTurnCompletion(stream)
return service.publishCheckpointWithCompletion(stream.RequestID, stream.ConversationID, &completion)
}
func (service *Service) completeManualCompactionTurn(stream *ActiveStream) error {
@@ -530,10 +518,21 @@ func (service *Service) completeManualCompactionTurn(stream *ActiveStream) error
if err := service.syncSummaryCarryForward(conversationID, requestID, modelCallID); err != nil {
return err
}
service.setTurnPhase(stream, TurnPhaseCompleted)
return nil
}
func manualCompactionTurnCompletion(stream *ActiveStream) pendingTurnCompletion {
if stream == nil {
return pendingTurnCompletion{}
}
return pendingTurnCompletion{
ConversationID: strings.TrimSpace(stream.ConversationID),
RequestID: strings.TrimSpace(stream.RequestID),
TurnSeq: stream.TurnSeq,
ModelCallID: "turn:" + strings.TrimSpace(stream.RequestID),
}
}
func (service *Service) publishSummaryCompleted(stream *ActiveStream, hookMessage string) error {
if service == nil || stream == nil {
return nil
@@ -568,6 +567,7 @@ func (service *Service) applyCompactionPlan(stream *ActiveStream, conversationID
if err != nil {
return err
}
originalEntryCount := len(candidateConversation.Entries)
if err := applyCompactionToConversation(candidateConversation, plan, summaryText); err != nil {
return err
}
@@ -582,9 +582,9 @@ func (service *Service) applyCompactionPlan(stream *ActiveStream, conversationID
if validationErr := validateCompactionCandidateBudget(recompiled, plan); validationErr != nil {
return validationErr
}
replacementEntries := append([]HistoryEntry(nil), candidateConversation.Entries...)
compactionEntries := append([]HistoryEntry(nil), candidateConversation.Entries[originalEntryCount:]...)
if service.store != nil {
persisted, err := service.store.ReplaceEntries(conversationID, replacementEntries, func(item *ConversationFile) error {
persisted, _, err := service.store.AppendEntriesWithUpdate(conversationID, resetEntrySequences(compactionEntries), func(item *ConversationFile) error {
if item == nil {
return nil
}
@@ -605,10 +605,7 @@ func (service *Service) applyCompactionPlan(stream *ActiveStream, conversationID
if item == nil {
return nil
}
item.Entries = nil
item.NextEntrySeq = 1
item.NextTurnSeq = 1
appendEntriesInPlace(item, resetEntrySequences(replacementEntries))
appendEntriesInPlace(item, resetEntrySequences(compactionEntries))
item.TokenDetailsUsedTokens = 0
clearConversationAutoCompactionState(item)
return nil
@@ -643,14 +640,13 @@ func applyCompactionToConversation(conversation *ConversationFile, plan *Pending
if conversation == nil || plan == nil {
return nil
}
replacementEntries, err := buildCompactedContextEntries(conversation, plan, summaryText)
compactionEntries, err := buildCompactedContextEntries(conversation, plan, summaryText)
if err != nil {
return err
}
conversation.Entries = nil
conversation.NextEntrySeq = 1
conversation.NextTurnSeq = 1
appendEntriesInPlace(conversation, resetEntrySequences(replacementEntries))
// Canonical history stays append-only. The prompt projector applies the
// latest summary marker when constructing model-visible replay.
appendEntriesInPlace(conversation, resetEntrySequences(compactionEntries))
conversation.TokenDetailsUsedTokens = 0
clearConversationAutoCompactionState(conversation)
if conversation.TokenDetailsMaxTokens == 0 {
@@ -671,38 +667,7 @@ func buildCompactedContextEntries(conversation *ConversationFile, plan *PendingC
if ok {
entries = append(entries, runtimeEntry)
}
if conversation == nil || !plan.PreserveCurrentTurnInputs {
return entries, nil
}
entries = append(entries, buildAutoCompactionPreservedCurrentTurnEntries(conversation.Entries, plan)...)
return entries, nil
}
func buildAutoCompactionPreservedCurrentTurnEntries(entries []HistoryEntry, plan *PendingCompaction) []HistoryEntry {
if len(entries) == 0 || plan == nil || !plan.PreserveCurrentTurnInputs {
return nil
}
latestToolCallID := latestCompletedToolCallIDForTurn(entries, plan.CurrentTurnSeq, plan.CurrentRequestID)
preservedIndexes := autoCompactionPreservedEntryIndexes(entries, plan.CurrentTurnSeq, plan.CurrentRequestID, latestToolCallID)
if len(preservedIndexes) == 0 {
return nil
}
preserved := make([]HistoryEntry, 0, len(preservedIndexes))
for index, entry := range entries {
if _, ok := preservedIndexes[index]; !ok {
continue
}
switch strings.TrimSpace(entry.Kind) {
case "compaction_summary", "compacted_summary", "compaction_request":
continue
case "tool_result":
if rewritten, ok := rewriteAutoCompactionToolResultEntry(entry, autoCompactionPreservedToolResultLimitBytes, false); ok {
entry = rewritten
}
}
preserved = append(preserved, entry)
}
return preserved
}
func newCompactionSummaryEntry(plan *PendingCompaction, summaryText string) HistoryEntry {
@@ -0,0 +1,206 @@
package forwarder
import (
"reflect"
"strings"
"testing"
"google.golang.org/protobuf/encoding/protojson"
"cursor/gen/agentv1"
)
func TestApplyCompactionToConversationPreservesCanonicalHistory(t *testing.T) {
conversation := compactionAppendOnlyConversation(t)
originalEntries := append([]HistoryEntry(nil), conversation.Entries...)
plan := &PendingCompaction{
Trigger: "manual",
CurrentTurnSeq: 2,
CurrentRequestID: "request-2",
}
if err := applyCompactionToConversation(conversation, plan, "earlier context summary"); err != nil {
t.Fatalf("applyCompactionToConversation() error = %v", err)
}
if len(conversation.Entries) <= len(originalEntries) {
t.Fatalf("entries after compaction = %d, want the %d original entries plus a summary marker", len(conversation.Entries), len(originalEntries))
}
if !reflect.DeepEqual(conversation.Entries[:len(originalEntries)], originalEntries) {
t.Fatal("compaction changed the canonical history prefix")
}
projector := NewHistoryProjector()
projection, err := projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if len(projection.State.GetTurns()) != 2 {
t.Fatalf("checkpoint turns after compaction = %d, want 2 visible turns", len(projection.State.GetTurns()))
}
replay, err := projector.ProjectPromptReplay(conversation)
if err != nil {
t.Fatalf("ProjectPromptReplay() error = %v", err)
}
if len(replay) != 1 || replay[0].Role != "user" || !strings.Contains(replay[0].Content, "earlier context summary") {
t.Fatalf("prompt replay after compaction = %#v, want only the compacted summary", replay)
}
}
func TestCompactedPromptProjectionPlacesSummaryBeforePreservedCurrentTurn(t *testing.T) {
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 1,
NextEntrySeq: 1,
}
appendEntriesInPlace(conversation, []HistoryEntry{
compactionTestUserEntry(t, 1, "request-1", "current question", "message-1"),
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", checkpointTestReadToolCall(t, nil)),
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "file contents", "", checkpointTestReadToolCall(t, nil)),
})
plan := &PendingCompaction{
Trigger: "auto",
CurrentTurnSeq: 1,
CurrentRequestID: "request-1",
PreserveCurrentTurnInputs: true,
}
if err := applyCompactionToConversation(conversation, plan, "current progress summary"); err != nil {
t.Fatalf("applyCompactionToConversation() error = %v", err)
}
projected := compactedPromptProjectionEntries(conversation.Entries)
promptKinds := make([]string, 0, len(projected))
for _, entry := range projected {
if isPromptReplayEntryKind(entry.Kind) {
promptKinds = append(promptKinds, entry.Kind)
}
}
want := []string{"compacted_summary", "user_message", "tool_call", "tool_result"}
if !reflect.DeepEqual(promptKinds, want) {
t.Fatalf("compacted prompt entry order = %#v, want %#v", promptKinds, want)
}
}
func TestCompactionPlanningDoesNotRecompactArchivedHistory(t *testing.T) {
conversation := compactionAppendOnlyConversation(t)
if err := applyCompactionToConversation(conversation, &PendingCompaction{
Trigger: "manual",
CurrentTurnSeq: 2,
CurrentRequestID: "request-2",
}, "archived history summary"); err != nil {
t.Fatalf("applyCompactionToConversation() error = %v", err)
}
appendEntriesInPlace(conversation, []HistoryEntry{
compactionTestUserEntry(t, 3, "request-3", "new question", "message-3"),
})
plan, err := (&Service{}).buildLegacyCompactionPlan(&compactionPlan{
CurrentTurnSeq: 3,
CurrentRequestID: "request-3",
}, conversation, false, 0)
if err != nil {
t.Fatalf("buildLegacyCompactionPlan() error = %v", err)
}
if plan != nil {
t.Fatalf("buildLegacyCompactionPlan() = %#v, want no already summarized candidates", plan)
}
}
func TestApplyCompactionPlanPersistsHistoryAppendOnly(t *testing.T) {
store := NewConversationFileStore(t.TempDir())
conversation := compactionAppendOnlyConversation(t)
if _, _, err := store.AppendEntries(conversation.ConversationID, resetEntrySequences(conversation.Entries)); err != nil {
t.Fatalf("AppendEntries() error = %v", err)
}
persisted, err := store.LoadConversation(conversation.ConversationID)
if err != nil {
t.Fatalf("initial LoadConversation() error = %v", err)
}
originalEntries := append([]HistoryEntry(nil), persisted.Entries...)
projector := NewHistoryProjector()
service := &Service{
store: store,
projector: projector,
compiler: compactionProjectionCompiler{projector: projector},
}
stream := &ActiveStream{
RequestID: "request-2",
ConversationID: conversation.ConversationID,
TurnSeq: 2,
Mode: agentv1.AgentMode_AGENT_MODE_AGENT,
CheckpointConversation: persisted,
}
plan := &PendingCompaction{
Trigger: "manual",
CurrentTurnSeq: 2,
CurrentRequestID: "request-2",
ContextWindowSize: 1_000_000,
}
if err := service.applyCompactionPlan(stream, conversation.ConversationID, plan, "persisted summary"); err != nil {
t.Fatalf("applyCompactionPlan() error = %v", err)
}
loaded, err := store.LoadConversation(conversation.ConversationID)
if err != nil {
t.Fatalf("LoadConversation() error = %v", err)
}
if len(loaded.Entries) <= len(originalEntries) {
t.Fatalf("persisted entries after compaction = %d, want more than %d", len(loaded.Entries), len(originalEntries))
}
for index := range originalEntries {
if !reflect.DeepEqual(loaded.Entries[index], originalEntries[index]) {
t.Fatalf("persisted history entry %d changed after compaction:\ngot %#v\nwant %#v", index, loaded.Entries[index], originalEntries[index])
}
}
}
type compactionProjectionCompiler struct {
projector *HistoryProjector
}
func (compiler compactionProjectionCompiler) Compile(conversation *ConversationFile, _ agentv1.AgentMode, _ string, _ string) (CompiledConversation, error) {
messages, err := compiler.projector.ProjectPromptReplay(conversation)
return CompiledConversation{Messages: messages}, err
}
func (compactionProjectionCompiler) DerivePromptContexts(*ConversationFile, agentv1.AgentMode, string) ([]PromptContextMessage, error) {
return nil, nil
}
func compactionAppendOnlyConversation(t *testing.T) *ConversationFile {
t.Helper()
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 1,
NextEntrySeq: 1,
TokenDetailsUsedTokens: 42_000,
TokenDetailsMaxTokens: 50_000,
}
appendEntriesInPlace(conversation, []HistoryEntry{
compactionTestUserEntry(t, 1, "request-1", "first question", "message-1"),
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
compactionTestUserEntry(t, 2, "request-2", "second question", "message-2"),
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
})
return conversation
}
func compactionTestUserEntry(t *testing.T, turnSeq int64, requestID string, text string, messageID string) HistoryEntry {
t.Helper()
payload, err := protojson.Marshal(&agentv1.UserMessage{Text: text, MessageId: messageID})
if err != nil {
t.Fatalf("marshal user message: %v", err)
}
return HistoryEntry{
TurnSeq: turnSeq,
RequestID: requestID,
Role: "user",
Kind: "user_message",
Payload: payload,
}
}
var _ PromptCompiler = compactionProjectionCompiler{}
+11 -2
View File
@@ -20,16 +20,21 @@ type DefaultPromptCompiler struct {
catalog ToolCatalog
reminders ReminderInjector
rules *UserRuleStore
blobs contentBlobReader
}
// NewPromptCompiler 创建默认 prompt 编译器。
func NewPromptCompiler(projector *HistoryProjector, catalog ToolCatalog, reminders ReminderInjector, rules *UserRuleStore) *DefaultPromptCompiler {
return &DefaultPromptCompiler{
func NewPromptCompiler(projector *HistoryProjector, catalog ToolCatalog, reminders ReminderInjector, rules *UserRuleStore, blobReaders ...contentBlobReader) *DefaultPromptCompiler {
compiler := &DefaultPromptCompiler{
projector: projector,
catalog: catalog,
reminders: reminders,
rules: rules,
}
if len(blobReaders) > 0 {
compiler.blobs = blobReaders[0]
}
return compiler
}
// Compile 生成当前 turn 应发送给 provider 的消息和工具集合。
@@ -86,6 +91,10 @@ func (compiler *DefaultPromptCompiler) Compile(conversation *ConversationFile, m
if err != nil {
return CompiledConversation{}, err
}
replayMessages, err = enrichProviderReadImages(replayMessages, conversation, compiler.blobs)
if err != nil {
return CompiledConversation{}, err
}
messages = append(messages, replayMessages...)
return CompiledConversation{
Mode: normalizedMode,
@@ -0,0 +1,111 @@
// content_blob_store.go 负责持久化 history 引用的内容寻址二进制数据。
package forwarder
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
)
const contentBlobDirectoryName = ".blobs"
// ContentBlobStore 使用 SHA-256 内容哈希保存不可变二进制数据。
type ContentBlobStore struct {
root string
}
// NewContentBlobStore 创建独立于 context.json 和 checkpoint 的内容寻址存储。
func NewContentBlobStore(historyRoot string) *ContentBlobStore {
historyRoot = strings.TrimSpace(historyRoot)
if historyRoot == "" {
return &ContentBlobStore{}
}
return &ContentBlobStore{root: filepath.Join(historyRoot, contentBlobDirectoryName, "sha256")}
}
// Put 校验内容哈希并幂等保存数据。
func (store *ContentBlobStore) Put(id []byte, data []byte) error {
if store == nil || strings.TrimSpace(store.root) == "" {
return fmt.Errorf("content blob store is not initialized")
}
normalizedID, err := normalizeContentBlobID(id)
if err != nil {
return err
}
digest := sha256.Sum256(data)
if !bytes.Equal(normalizedID, digest[:]) {
return fmt.Errorf("content blob id does not match payload sha256")
}
path := store.blobPath(normalizedID)
if existing, err := store.Get(normalizedID); err == nil {
if bytes.Equal(existing, data) {
return nil
}
return fmt.Errorf("content blob payload conflicts with existing id")
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(store.root, 0o700); err != nil {
return fmt.Errorf("create content blob directory: %w", err)
}
temporary, err := os.CreateTemp(store.root, ".blob-*")
if err != nil {
return fmt.Errorf("create content blob temporary file: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
_ = temporary.Close()
return fmt.Errorf("set content blob permissions: %w", err)
}
if _, err := temporary.Write(data); err != nil {
_ = temporary.Close()
return fmt.Errorf("write content blob: %w", err)
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return fmt.Errorf("sync content blob: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close content blob: %w", err)
}
if err := os.Rename(temporaryPath, path); err != nil {
return fmt.Errorf("commit content blob: %w", err)
}
return nil
}
// Get 读取内容并再次校验哈希,避免损坏数据进入模型请求。
func (store *ContentBlobStore) Get(id []byte) ([]byte, error) {
if store == nil || strings.TrimSpace(store.root) == "" {
return nil, fmt.Errorf("content blob store is not initialized")
}
normalizedID, err := normalizeContentBlobID(id)
if err != nil {
return nil, err
}
data, err := os.ReadFile(store.blobPath(normalizedID))
if err != nil {
return nil, err
}
digest := sha256.Sum256(data)
if !bytes.Equal(normalizedID, digest[:]) {
return nil, fmt.Errorf("content blob sha256 verification failed")
}
return append([]byte(nil), data...), nil
}
func (store *ContentBlobStore) blobPath(id []byte) string {
return filepath.Join(store.root, hex.EncodeToString(id))
}
func normalizeContentBlobID(id []byte) ([]byte, error) {
if len(id) != sha256.Size {
return nil, fmt.Errorf("content blob id must be %d bytes", sha256.Size)
}
return append([]byte(nil), id...), nil
}
@@ -0,0 +1,41 @@
package forwarder
import (
"bytes"
"crypto/sha256"
"testing"
)
func TestContentBlobStorePutGetIsIdempotent(t *testing.T) {
store := NewContentBlobStore(t.TempDir())
data := []byte("stable blob bytes")
id := sha256.Sum256(data)
if err := store.Put(id[:], data); err != nil {
t.Fatalf("first Put() error = %v", err)
}
if err := store.Put(id[:], append([]byte(nil), data...)); err != nil {
t.Fatalf("second Put() error = %v", err)
}
got, err := store.Get(id[:])
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if !bytes.Equal(got, data) {
t.Fatalf("Get() = %q, want %q", got, data)
}
got[0] ^= 0xff
again, err := store.Get(id[:])
if err != nil {
t.Fatalf("second Get() error = %v", err)
}
if !bytes.Equal(again, data) {
t.Fatalf("stored data was mutated: %q", again)
}
}
func TestContentBlobStoreRejectsMismatchedID(t *testing.T) {
store := NewContentBlobStore(t.TempDir())
if err := store.Put(bytes.Repeat([]byte{0xff}, sha256.Size), []byte("payload")); err == nil {
t.Fatal("Put() accepted mismatched content id")
}
}
+13 -1
View File
@@ -121,10 +121,15 @@ func (store *ConversationFileStore) LoadConversation(conversationID string) (*Co
// AppendEntries 把已经发生的语义事件追加到 context.json,并同步 state.json。
func (store *ConversationFileStore) AppendEntries(conversationID string, entries []HistoryEntry) (*ConversationFile, []HistoryEntry, error) {
return store.AppendEntriesWithUpdate(conversationID, entries, nil)
}
// AppendEntriesWithUpdate 原子追加 context entries,并在同一把会话锁内更新 state metadata。
func (store *ConversationFileStore) AppendEntriesWithUpdate(conversationID string, entries []HistoryEntry, update func(*ConversationFile) error) (*ConversationFile, []HistoryEntry, error) {
if store == nil {
return nil, nil, fmt.Errorf("conversation file store is nil")
}
if len(entries) == 0 {
if len(entries) == 0 && update == nil {
conversation, err := store.LoadConversation(conversationID)
return conversation, nil, err
}
@@ -162,6 +167,11 @@ func (store *ConversationFileStore) AppendEntries(conversationID string, entries
conversation.Mode = alias
}
assigned := appendEntriesInPlace(conversation, entries)
if update != nil {
if err := update(conversation); err != nil {
return nil, nil, err
}
}
deriveConversationLoopState(conversation)
if err := store.writeConversationLocked(normalizedConversationID, conversation); err != nil {
return nil, nil, err
@@ -762,6 +772,7 @@ func mergeConversationMetadata(target *ConversationFile, source *ConversationFil
target.CurrentPlanText = source.CurrentPlanText
target.CurrentPlans = clonePlanRegistryEntries(source.CurrentPlans)
target.CurrentTodos = cloneTodoItems(source.CurrentTodos)
target.ImportedTurnIDs = cloneByteSlices(source.ImportedTurnIDs)
target.LatestRequestPrefix = cloneConversationRequestPrefix(source.LatestRequestPrefix)
target.LastProviderCall = cloneConversationProviderCall(source.LastProviderCall)
if !source.CreatedAt.IsZero() && (target.CreatedAt.IsZero() || source.CreatedAt.Before(target.CreatedAt)) {
@@ -894,6 +905,7 @@ func cloneConversationFile(conversation *ConversationFile) *ConversationFile {
cloned := *conversation
cloned.CurrentPlans = clonePlanRegistryEntries(conversation.CurrentPlans)
cloned.CurrentTodos = cloneTodoItems(conversation.CurrentTodos)
cloned.ImportedTurnIDs = cloneByteSlices(conversation.ImportedTurnIDs)
cloned.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
cloned.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
cloned.Entries = append([]HistoryEntry(nil), conversation.Entries...)
@@ -0,0 +1,167 @@
package forwarder
import (
"crypto/sha256"
"fmt"
"google.golang.org/protobuf/proto"
"cursor/gen/agentv1"
modeladapter "cursor/internal/backend/agent/model"
promptengine "cursor/internal/backend/agent/prompt"
)
type importedBlobStore map[string][]byte
func newImportedBlobStore(items []*agentv1.PreFetchedBlob) (importedBlobStore, error) {
if len(items) == 0 {
return nil, nil
}
store := make(importedBlobStore, len(items))
for _, item := range items {
if item == nil || len(item.GetId()) == 0 {
continue
}
if len(item.GetId()) != sha256.Size {
return nil, fmt.Errorf("prefetched blob id length %d, want %d", len(item.GetId()), sha256.Size)
}
digest := sha256.Sum256(item.GetValue())
if string(digest[:]) != string(item.GetId()) {
return nil, fmt.Errorf("prefetched blob %x failed SHA-256 validation", item.GetId())
}
store[string(item.GetId())] = append([]byte(nil), item.GetValue()...)
}
return store, nil
}
func (store importedBlobStore) resolve(id []byte) ([]byte, bool) {
if len(id) == 0 || len(store) == 0 {
return nil, false
}
value, ok := store[string(id)]
return append([]byte(nil), value...), ok
}
func decodeImportedTurn(raw []byte, blobs importedBlobStore) (*agentv1.ConversationTurnStructure, []byte, error) {
if data, ok := blobs.resolve(raw); ok {
turn := &agentv1.ConversationTurnStructure{}
if err := proto.Unmarshal(data, turn); err != nil || turn.GetTurn() == nil {
return nil, nil, fmt.Errorf("decode imported turn blob %x: %w", raw, firstNonNilError(err, fmt.Errorf("turn payload is empty")))
}
return turn, append([]byte(nil), raw...), nil
}
turn := &agentv1.ConversationTurnStructure{}
if err := proto.Unmarshal(raw, turn); err == nil && turn.GetTurn() != nil {
return turn, nil, nil
}
if len(raw) == sha256.Size {
return nil, append([]byte(nil), raw...), nil
}
return nil, nil, fmt.Errorf("decode imported inline turn")
}
func decodeImportedUserMessage(raw []byte, blobs importedBlobStore) (*agentv1.UserMessage, error) {
data := raw
if resolved, ok := blobs.resolve(raw); ok {
data = resolved
} else if len(raw) == sha256.Size {
candidate := &agentv1.UserMessage{}
if err := proto.Unmarshal(raw, candidate); err != nil || !hasKnownUserMessageContent(candidate) {
return nil, fmt.Errorf("missing prefetched user message blob %x", raw)
}
return candidate, nil
}
message := &agentv1.UserMessage{}
if err := proto.Unmarshal(data, message); err != nil {
return nil, fmt.Errorf("decode imported turn user_message: %w", err)
}
return message, nil
}
func decodeImportedStep(raw []byte, blobs importedBlobStore) (*agentv1.ConversationStep, error) {
data := raw
if resolved, ok := blobs.resolve(raw); ok {
data = resolved
} else if len(raw) == sha256.Size {
candidate := &agentv1.ConversationStep{}
if err := proto.Unmarshal(raw, candidate); err != nil || candidate.GetMessage() == nil {
return nil, fmt.Errorf("missing prefetched conversation step blob %x", raw)
}
return candidate, nil
}
step := &agentv1.ConversationStep{}
if err := proto.Unmarshal(data, step); err != nil {
return nil, fmt.Errorf("decode imported turn step: %w", err)
}
if step.GetMessage() == nil {
return nil, fmt.Errorf("decode imported turn step: payload is empty")
}
return step, nil
}
func importedBlobTurnMessages(turn *agentv1.ConversationTurnStructure, blobs importedBlobStore) ([]modeladapter.Message, error) {
if turn == nil || turn.GetAgentConversationTurn() == nil {
return nil, nil
}
agentTurn := turn.GetAgentConversationTurn()
messages := make([]modeladapter.Message, 0, 1+len(agentTurn.GetSteps()))
if len(agentTurn.GetUserMessage()) > 0 {
userMessage, err := decodeImportedUserMessage(agentTurn.GetUserMessage(), blobs)
if err != nil {
return nil, err
}
if replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage); ok {
messages = append(messages, toModelMessage(replay))
}
}
for _, rawStep := range agentTurn.GetSteps() {
if len(rawStep) == 0 {
continue
}
step, err := decodeImportedStep(rawStep, blobs)
if err != nil {
return nil, err
}
for _, replay := range promptengine.BuildLegacyMessagesFromConversationStep(step) {
messages = append(messages, toModelMessage(replay))
}
}
return messages, nil
}
func importedTurnIDs(turns [][]byte, blobs importedBlobStore) ([][]byte, error) {
ids := make([][]byte, 0, len(turns))
for _, raw := range turns {
if len(raw) == 0 {
continue
}
_, id, err := decodeImportedTurn(raw, blobs)
if err != nil {
return nil, err
}
if len(id) > 0 {
ids = append(ids, id)
}
}
return ids, nil
}
func hasKnownUserMessageContent(message *agentv1.UserMessage) bool {
if message == nil {
return false
}
return message.GetText() != "" ||
message.GetMessageId() != "" ||
message.GetSelectedContext() != nil ||
message.GetRichText() != "" ||
len(message.GetConversationStateBlobId()) > 0 ||
len(message.GetTextBlobId()) > 0 ||
len(message.GetRichTextBlobId()) > 0
}
func firstNonNilError(err error, fallback error) error {
if err != nil {
return err
}
return fallback
}
@@ -0,0 +1,109 @@
package forwarder
import (
"crypto/sha256"
"testing"
"google.golang.org/protobuf/proto"
"cursor/gen/agentv1"
)
func TestImportedConversationStateRestoresBlobOnlyForkAndCheckpointPrefix(t *testing.T) {
parent := compactionAppendOnlyConversation(t)
parent.Entries = parent.Entries[:2]
parent.NextEntrySeq = 3
parent.NextTurnSeq = 2
projection, err := NewHistoryProjector().ProjectCheckpointProjection(parent)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
prefetched := make([]*agentv1.PreFetchedBlob, 0, len(projection.Blobs))
for _, blob := range projection.Blobs {
prefetched = append(prefetched, &agentv1.PreFetchedBlob{Id: blob.ID, Value: blob.Data})
}
state := proto.Clone(projection.State).(*agentv1.ConversationStateStructure)
state.RootPromptMessagesJson = nil
conversation, err := newRuntimeConversation("fork-conversation", agentv1.AgentMode_AGENT_MODE_AGENT)
if err != nil {
t.Fatalf("newRuntimeConversation() error = %v", err)
}
entries, err := (&Service{}).importConversationState(conversation, state, prefetched)
if err != nil {
t.Fatalf("importConversationState() error = %v", err)
}
if len(conversation.ImportedTurnIDs) != 1 || conversation.NextTurnSeq != 2 {
t.Fatalf("imported prefix turns=%d next_turn_seq=%d, want 1 and 2", len(conversation.ImportedTurnIDs), conversation.NextTurnSeq)
}
if len(entries) != 2 {
t.Fatalf("imported model entries = %d, want parent user and assistant", len(entries))
}
appendEntriesInPlace(conversation, append(entries,
compactionTestUserEntry(t, 2, "request-2", "fork question", "message-2"),
))
forkProjection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("fork ProjectCheckpointProjection() error = %v", err)
}
if len(forkProjection.State.GetTurns()) != 2 {
t.Fatalf("fork checkpoint turns = %d, want imported parent plus local fork turn", len(forkProjection.State.GetTurns()))
}
if string(forkProjection.State.GetTurns()[0]) != string(projection.State.GetTurns()[0]) {
t.Fatal("fork checkpoint did not preserve the imported parent turn ID as its prefix")
}
}
func TestImportedConversationStateRejectsUnresolvedBlobTurn(t *testing.T) {
turnID := sha256.Sum256([]byte("missing imported turn"))
conversation, err := newRuntimeConversation("fork-conversation", agentv1.AgentMode_AGENT_MODE_AGENT)
if err != nil {
t.Fatalf("newRuntimeConversation() error = %v", err)
}
if _, err := (&Service{}).importConversationState(conversation, &agentv1.ConversationStateStructure{
Turns: [][]byte{turnID[:]},
}, nil); err == nil {
t.Fatal("importConversationState() accepted an unresolved Blob turn")
}
}
func TestImportedTurnIDsPersistThroughConversationStore(t *testing.T) {
store := NewConversationFileStore(t.TempDir())
turnID := sha256.Sum256([]byte("parent turn"))
conversation, err := newRuntimeConversation("fork-conversation", agentv1.AgentMode_AGENT_MODE_AGENT)
if err != nil {
t.Fatalf("newRuntimeConversation() error = %v", err)
}
conversation.ImportedTurnIDs = [][]byte{turnID[:]}
persisted, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{
compactionTestUserEntry(t, 2, "request-2", "fork question", "message-2"),
})
if err != nil {
t.Fatalf("SaveConversationWithEntries() error = %v", err)
}
if len(persisted.ImportedTurnIDs) != 1 || string(persisted.ImportedTurnIDs[0]) != string(turnID[:]) {
t.Fatalf("persisted ImportedTurnIDs = %x, want %x", persisted.ImportedTurnIDs, turnID)
}
loaded, err := store.LoadConversation(conversation.ConversationID)
if err != nil {
t.Fatalf("LoadConversation() error = %v", err)
}
if len(loaded.ImportedTurnIDs) != 1 || string(loaded.ImportedTurnIDs[0]) != string(turnID[:]) {
t.Fatalf("loaded ImportedTurnIDs = %x, want %x", loaded.ImportedTurnIDs, turnID)
}
}
func TestRewindImportedTurnPrefixUsesClientForkPoint(t *testing.T) {
ids := make([][]byte, 3)
for index := range ids {
digest := sha256.Sum256([]byte{byte(index + 1)})
ids[index] = digest[:]
}
trimmed := rewindImportedTurnPrefix(ids, runRewindDecision{
TargetTurnSeq: 4,
HasClientTurnCount: true,
ClientTurnCount: 1,
})
if len(trimmed) != 1 || string(trimmed[0]) != string(ids[0]) {
t.Fatalf("rewindImportedTurnPrefix() = %x, want first imported turn only", trimmed)
}
}
@@ -2,6 +2,7 @@ package forwarder
import (
"encoding/json"
"errors"
"strings"
"testing"
)
@@ -125,6 +126,53 @@ func TestCancelPersistsInterruptedProviderOutputIdempotently(t *testing.T) {
}
}
func TestGenericProviderFailurePersistsAccumulatedOutput(t *testing.T) {
service, stream, _ := testCheckpointBlobProjection(t)
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
if err != nil {
t.Fatalf("snapshotCheckpointConversation() error = %v", err)
}
if _, err := service.store.SaveConversationWithEntries(stream.ConversationID, conversation, conversation.Entries); err != nil {
t.Fatalf("SaveConversationWithEntries() error = %v", err)
}
stream.mu.Lock()
stream.CurrentModelCallID = "model-call-1"
stream.ProviderActive = true
stream.ProviderAccumulatedText = "partial answer before transport failure"
stream.Status = StreamStatusStreaming
stream.Phase = TurnPhaseProviderRunning
stream.mu.Unlock()
if err := service.handleProviderDoneEvent(stream, &streamProviderEvent{
Done: true,
Err: errors.New("transport failed"),
}); err != nil {
t.Fatalf("handleProviderDoneEvent() error = %v", err)
}
persisted, err := service.store.LoadConversation(stream.ConversationID)
if err != nil {
t.Fatalf("LoadConversation() error = %v", err)
}
foundPartialOutput := false
for _, entry := range persisted.Entries {
if entry.Kind != "assistant_text" {
continue
}
var payload assistantTextPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
t.Fatalf("decode assistant entry: %v", err)
}
if payload.Text == "partial answer before transport failure" {
foundPartialOutput = true
break
}
}
if !foundPartialOutput {
t.Fatal("generic provider failure discarded accumulated assistant output")
}
}
func TestCancelPreservesPersistedTurnActivityWithoutLiveAccumulator(t *testing.T) {
service, stream, _ := testCheckpointBlobProjection(t)
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
+17 -13
View File
@@ -326,20 +326,24 @@ func compactedPromptProjectionEntries(entries []HistoryEntry) []HistoryEntry {
latestToolCallID := latestCompletedToolCallIDForTurn(entries, compactionPayload.CurrentTurnSeq, compactionPayload.CurrentRequestID)
preservedIndexes = autoCompactionPreservedEntryIndexes(entries, compactionPayload.CurrentTurnSeq, compactionPayload.CurrentRequestID, latestToolCallID)
}
filtered := make([]HistoryEntry, 0, len(entries)-compactionIndex)
for index, entry := range entries {
if index < compactionIndex && isPromptReplayEntryKind(entry.Kind) {
if _, ok := preservedIndexes[index]; !ok {
filtered := make([]HistoryEntry, 0, len(entries)-compactionIndex+len(preservedIndexes))
for index := 0; index < compactionIndex; index++ {
if !isPromptReplayEntryKind(entries[index].Kind) {
filtered = append(filtered, entries[index])
}
}
filtered = append(filtered, entries[compactionIndex])
for index := 0; index < compactionIndex; index++ {
if _, ok := preservedIndexes[index]; !ok || isCompactionSummaryKind(entries[index].Kind) {
continue
}
}
if index < compactionIndex {
entry := entries[index]
if rewritten, ok := compactedProjectionPreservedEntry(entry); ok {
entry = rewritten
}
}
filtered = append(filtered, entry)
}
filtered = append(filtered, entries[compactionIndex+1:]...)
return filtered
}
@@ -575,7 +579,7 @@ func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *Con
if err != nil {
return nil, err
}
state.Turns = turnIDs
state.Turns = append(cloneByteSlices(conversation.ImportedTurnIDs), turnIDs...)
replayMessages, err := projector.ProjectPromptReplay(conversation)
if err != nil {
return nil, err
@@ -1292,7 +1296,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
return filtered
}
func restoreImportedReplayUserMessages(messages []promptengine.Message, importedTurns [][]byte) []promptengine.Message {
func restoreImportedReplayUserMessages(messages []promptengine.Message, importedTurns [][]byte, blobs importedBlobStore) []promptengine.Message {
if len(messages) == 0 || len(importedTurns) == 0 {
return messages
}
@@ -1301,16 +1305,16 @@ func restoreImportedReplayUserMessages(messages []promptengine.Message, imported
if len(rawTurn) == 0 {
continue
}
turn := &agentv1.ConversationTurnStructure{}
if err := proto.Unmarshal(rawTurn, turn); err != nil {
turn, _, err := decodeImportedTurn(rawTurn, blobs)
if err != nil || turn == nil {
continue
}
agentTurn := turn.GetAgentConversationTurn()
if agentTurn == nil || len(agentTurn.GetUserMessage()) == 0 {
continue
}
userMessage := &agentv1.UserMessage{}
if err := proto.Unmarshal(agentTurn.GetUserMessage(), userMessage); err != nil {
userMessage, err := decodeImportedUserMessage(agentTurn.GetUserMessage(), blobs)
if err != nil {
continue
}
replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage)
@@ -0,0 +1,180 @@
package forwarder
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"image"
"image/color"
"image/png"
"reflect"
"strings"
"testing"
"google.golang.org/protobuf/encoding/protojson"
"cursor/gen/agentv1"
modeladapter "cursor/internal/backend/agent/model"
)
func TestReadImageProjectionIsProviderOnlyAndIdempotent(t *testing.T) {
imageData := validForwarderTestPNG(t)
blobID := sha256.Sum256(imageData)
store := NewContentBlobStore(t.TempDir())
if err := store.Put(blobID[:], imageData); err != nil {
t.Fatalf("Put() error = %v", err)
}
conversation := readImageConversation(t, blobID[:], len(imageData))
projector := NewHistoryProjector()
canonical, err := projector.ProjectPromptReplay(conversation)
if err != nil {
t.Fatalf("ProjectPromptReplay() error = %v", err)
}
if len(canonical) != 2 {
t.Fatalf("canonical message count = %d, want 2", len(canonical))
}
if len(canonical[1].ContentParts) != 0 {
t.Fatalf("canonical replay contains image parts: %#v", canonical[1].ContentParts)
}
first, err := enrichProviderReadImages(canonical, conversation, store)
if err != nil {
t.Fatalf("first enrichment error = %v", err)
}
second, err := enrichProviderReadImages(canonical, conversation, store)
if err != nil {
t.Fatalf("second enrichment error = %v", err)
}
if !reflect.DeepEqual(first, second) {
t.Fatalf("provider enrichment is not idempotent\nfirst=%#v\nsecond=%#v", first, second)
}
reenriched, err := enrichProviderReadImages(first, conversation, store)
if err != nil {
t.Fatalf("re-enrichment error = %v", err)
}
if !reflect.DeepEqual(first, reenriched) {
t.Fatalf("provider enrichment changed an already enriched projection\nfirst=%#v\nreenriched=%#v", first, reenriched)
}
assertProviderReadImageMessage(t, first[1], imageData)
first[1].ContentParts[1].Image.Data[0] ^= 0xff
if bytes.Equal(first[1].ContentParts[1].Image.Data, second[1].ContentParts[1].Image.Data) {
t.Fatal("separate enrichments share mutable image bytes")
}
contextJSON, err := json.Marshal(conversation)
if err != nil {
t.Fatalf("marshal conversation: %v", err)
}
if bytes.Contains(contextJSON, imageData) || strings.Contains(string(contextJSON), base64.StdEncoding.EncodeToString(imageData)) {
t.Fatal("canonical conversation contains raw image bytes")
}
checkpoint, err := projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
checkpointJSON, err := json.Marshal(checkpoint)
if err != nil {
t.Fatalf("marshal checkpoint: %v", err)
}
if bytes.Contains(checkpointJSON, imageData) || strings.Contains(string(checkpointJSON), base64.StdEncoding.EncodeToString(imageData)) {
t.Fatal("checkpoint contains raw image bytes")
}
}
func TestProviderReadImageEnrichmentLeavesTextReadUnchanged(t *testing.T) {
toolCall := &agentv1.ToolCall{
Tool: &agentv1.ToolCall_ReadToolCall{
ReadToolCall: &agentv1.ReadToolCall{
Args: &agentv1.ReadToolArgs{Path: "notes.txt"},
Result: &agentv1.ReadToolResult{
Result: &agentv1.ReadToolResult_Success{
Success: &agentv1.ReadToolSuccess{
Path: "notes.txt",
Output: &agentv1.ReadToolSuccess_Content{Content: "hello"},
},
},
},
},
},
}
encoded, err := protojson.Marshal(toolCall)
if err != nil {
t.Fatalf("marshal tool call: %v", err)
}
conversation := &ConversationFile{Entries: []HistoryEntry{
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"notes.txt"}`, "hello", "", encoded),
}}
messages := []modeladapter.Message{{Role: "tool", ToolCallID: "call-1", Name: "Read", Content: "hello"}}
got, err := enrichProviderReadImages(messages, conversation, NewContentBlobStore(t.TempDir()))
if err != nil {
t.Fatalf("enrichProviderReadImages() error = %v", err)
}
if !reflect.DeepEqual(got, messages) {
t.Fatalf("text read changed: got=%#v want=%#v", got, messages)
}
}
func readImageConversation(t *testing.T, blobID []byte, fileSize int) *ConversationFile {
t.Helper()
toolCall := &agentv1.ToolCall{
Tool: &agentv1.ToolCall_ReadToolCall{
ReadToolCall: &agentv1.ReadToolCall{
Args: &agentv1.ReadToolArgs{Path: "diagram.png"},
Result: &agentv1.ReadToolResult{
Result: &agentv1.ReadToolResult_Success{
Success: &agentv1.ReadToolSuccess{
FileSize: uint32(fileSize),
Path: "diagram.png",
Output: &agentv1.ReadToolSuccess_DataBlobId{DataBlobId: append([]byte(nil), blobID...)},
},
},
},
},
},
}
encoded, err := protojson.Marshal(toolCall)
if err != nil {
t.Fatalf("marshal tool call: %v", err)
}
return &ConversationFile{
ConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 2,
Entries: []HistoryEntry{
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"diagram.png"}`, "read binary bytes", "", encoded),
},
}
}
func assertProviderReadImageMessage(t *testing.T, message modeladapter.Message, imageData []byte) {
t.Helper()
if message.Role != "tool" || message.ToolCallID != "call-1" || message.Name != "Read" {
t.Fatalf("tool message metadata = %#v", message)
}
if len(message.ContentParts) != 2 {
t.Fatalf("content part count = %d, want text and image", len(message.ContentParts))
}
if message.ContentParts[0].Type != "text" || message.ContentParts[0].Text != message.Content {
t.Fatalf("text content part = %#v", message.ContentParts[0])
}
imagePart := message.ContentParts[1]
if imagePart.Type != "image" || imagePart.Image == nil {
t.Fatalf("image content part = %#v", imagePart)
}
if imagePart.Image.MIMEType != "image/png" || imagePart.Image.Path != "diagram.png" || !bytes.Equal(imagePart.Image.Data, imageData) {
t.Fatalf("image content = %#v", imagePart.Image)
}
}
func validForwarderTestPNG(t *testing.T) []byte {
t.Helper()
value := image.NewRGBA(image.Rect(0, 0, 2, 2))
value.Set(0, 0, color.RGBA{R: 0x44, G: 0x88, B: 0xcc, A: 0xff})
var encoded bytes.Buffer
if err := png.Encode(&encoded, value); err != nil {
t.Fatalf("encode test png: %v", err)
}
return encoded.Bytes()
}
@@ -0,0 +1,174 @@
// provider_read_images.go 负责在 provider 请求边界按 blob 引用补全 Read 图片。
package forwarder
import (
"bytes"
"encoding/json"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"strings"
"google.golang.org/protobuf/encoding/protojson"
"cursor/gen/agentv1"
modeladapter "cursor/internal/backend/agent/model"
)
type contentBlobReader interface {
Get(id []byte) ([]byte, error)
}
type providerReadImageReference struct {
blobID []byte
path string
fileSize uint32
}
// enrichProviderReadImages 只为本次 provider 请求加载图片,不修改 canonical history 投影。
func enrichProviderReadImages(messages []modeladapter.Message, conversation *ConversationFile, blobs contentBlobReader) ([]modeladapter.Message, error) {
cloned := cloneProviderEnrichmentMessages(messages)
references, err := collectProviderReadImageReferences(conversation)
if err != nil {
return nil, err
}
if len(references) == 0 {
return cloned, nil
}
for index := range cloned {
message := &cloned[index]
if strings.TrimSpace(message.Role) != "tool" {
continue
}
reference, ok := references[strings.TrimSpace(message.ToolCallID)]
if !ok {
continue
}
if blobs == nil {
return nil, fmt.Errorf("provider read image blob store is not initialized")
}
data, err := blobs.Get(reference.blobID)
if err != nil {
return nil, fmt.Errorf("load read image blob for tool call %s: %w", message.ToolCallID, err)
}
mimeType := validatedProviderReadImageMIMEType(data)
if mimeType == "" {
return nil, fmt.Errorf("read image blob for tool call %s is not a supported image", message.ToolCallID)
}
summary := "Read image file: " + reference.path
message.Content = summary
message.ContentParts = []modeladapter.ContentPart{
{Type: "text", Text: summary},
{
Type: "image",
Image: &modeladapter.ImageContent{
MIMEType: mimeType,
Path: reference.path,
Data: append([]byte(nil), data...),
},
},
}
}
return cloned, nil
}
func collectProviderReadImageReferences(conversation *ConversationFile) (map[string]providerReadImageReference, error) {
references := make(map[string]providerReadImageReference)
if conversation == nil {
return references, nil
}
for _, entry := range conversation.Entries {
if strings.TrimSpace(entry.Kind) != "tool_result" {
continue
}
var payload toolResultEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, fmt.Errorf("decode read image tool result entry: %w", err)
}
if len(payload.ToolCall) == 0 {
continue
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, fmt.Errorf("decode read image tool call: %w", err)
}
readToolCall := toolCall.GetReadToolCall()
if readToolCall == nil || readToolCall.GetResult().GetSuccess() == nil {
continue
}
success := readToolCall.GetResult().GetSuccess()
blobID := success.GetDataBlobId()
if len(blobID) == 0 {
continue
}
toolCallID := strings.TrimSpace(firstNonEmpty(payload.ToolCallID, entry.ToolCallID))
if toolCallID == "" {
continue
}
reference := providerReadImageReference{
blobID: append([]byte(nil), blobID...),
path: firstNonEmpty(strings.TrimSpace(success.GetPath()), strings.TrimSpace(readToolCall.GetArgs().GetPath())),
fileSize: success.GetFileSize(),
}
if existing, ok := references[toolCallID]; ok {
if !bytes.Equal(existing.blobID, reference.blobID) || existing.path != reference.path || existing.fileSize != reference.fileSize {
return nil, fmt.Errorf("conflicting read image references for tool call %s", toolCallID)
}
continue
}
references[toolCallID] = reference
}
return references, nil
}
func cloneProviderEnrichmentMessages(messages []modeladapter.Message) []modeladapter.Message {
if len(messages) == 0 {
return nil
}
cloned := make([]modeladapter.Message, 0, len(messages))
for _, message := range messages {
item := cloneReplayModelMessage(message)
if len(message.ContentParts) > 0 {
item.ContentParts = make([]modeladapter.ContentPart, len(message.ContentParts))
for index, part := range message.ContentParts {
item.ContentParts[index] = part
if part.Image != nil {
imageCopy := *part.Image
imageCopy.Data = append([]byte(nil), part.Image.Data...)
item.ContentParts[index].Image = &imageCopy
}
}
}
cloned = append(cloned, item)
}
return cloned
}
func validatedProviderReadImageMIMEType(data []byte) string {
if len(data) == 0 {
return ""
}
detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(data)))
configuration, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || configuration.Width <= 0 || configuration.Height <= 0 {
return ""
}
switch strings.ToLower(strings.TrimSpace(format)) {
case "png":
if detected == "image/png" {
return detected
}
case "jpeg":
if detected == "image/jpeg" {
return detected
}
case "gif":
if detected == "image/gif" {
return detected
}
}
return ""
}
+21
View File
@@ -224,6 +224,7 @@ func (service *Service) applyRunRewindToConversation(conversation *ConversationF
conversation.Entries = nil
conversation.NextEntrySeq = 1
conversation.NextTurnSeq = 1
conversation.ImportedTurnIDs = rewindImportedTurnPrefix(conversation.ImportedTurnIDs, decision)
appendEntriesInPlace(conversation, appendReplacementRunEntries(decision.PrefixEntries, entries))
applyRunRewindConversationState(conversation, intent, turnSeq)
deriveConversationLoopState(conversation)
@@ -269,10 +270,30 @@ func applyRunRewindMetadata(conversation *ConversationFile, source *Conversation
if source.TokenDetailsMaxTokens > 0 {
conversation.TokenDetailsMaxTokens = source.TokenDetailsMaxTokens
}
decision := runRewindDecision{TargetTurnSeq: turnSeq}
if intent.ConversationState != nil {
decision.HasClientTurnCount = true
decision.ClientTurnCount = len(intent.ConversationState.GetTurns())
}
conversation.ImportedTurnIDs = rewindImportedTurnPrefix(source.ImportedTurnIDs, decision)
}
applyRunRewindConversationState(conversation, intent, turnSeq)
}
func rewindImportedTurnPrefix(importedTurnIDs [][]byte, decision runRewindDecision) [][]byte {
keep := decision.TargetTurnSeq - 1
if decision.HasClientTurnCount {
keep = int64(decision.ClientTurnCount)
}
if keep <= 0 || len(importedTurnIDs) == 0 {
return nil
}
if keep > int64(len(importedTurnIDs)) {
keep = int64(len(importedTurnIDs))
}
return cloneByteSlices(importedTurnIDs[:keep])
}
func (service *Service) logRunRewindDecision(requestID string, conversationID string, eventName string, decision runRewindDecision) {
if service == nil || !decision.Evaluated {
return
@@ -50,7 +50,7 @@ func (service *Service) bootstrapRuntimeConversation(intent InboundIntent) (*Con
}
importedEntries := []HistoryEntry(nil)
if len(conversation.Entries) == 0 && intent.ConversationState != nil {
importedEntries, err = service.importConversationState(conversation, intent.ConversationState)
importedEntries, err = service.importConversationState(conversation, intent.ConversationState, intent.PreFetchedBlobs)
if err != nil {
return nil, agentv1.AgentMode_AGENT_MODE_AGENT, 0, nil, err
}
@@ -138,6 +138,7 @@ func (service *Service) syncConversationRecord(conversationID string, conversati
item.AutoCompactionReserveTokens = conversation.AutoCompactionReserveTokens
item.AutoCompactionTriggeredAt = conversation.AutoCompactionTriggeredAt
item.AutoCompactionSourceModelCallID = conversation.AutoCompactionSourceModelCallID
item.ImportedTurnIDs = cloneByteSlices(conversation.ImportedTurnIDs)
item.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
item.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
item.CreatedAt = conversation.CreatedAt
+51 -12
View File
@@ -248,6 +248,7 @@ func subagentModelOverrideSummaries(overrides map[string]runtimecore.SubagentMod
type Service struct {
store *ConversationFileStore
contentBlobs *ContentBlobStore
usageStore *UsageFileStore
codebaseIndexStore *CodebaseIndexStore
docsIndexStore *DocsIndexStore
@@ -274,6 +275,7 @@ type agentModelMemory interface {
func NewService(historyRoot string, resolver modeladapter.ChannelResolver) *Service {
projector := NewHistoryProjector()
store := NewConversationFileStore(historyRoot)
contentBlobs := NewContentBlobStore(historyRoot)
broker := NewStreamBroker()
rules := NewUserRuleStore(appdata.RulesRootPath())
var modelMemory agentModelMemory
@@ -287,12 +289,13 @@ func NewService(historyRoot string, resolver modeladapter.ChannelResolver) *Serv
debug := newDebugRecorder(historyRoot, broker, debugConfig)
service := &Service{
store: store,
contentBlobs: contentBlobs,
usageStore: NewUsageFileStore(historyRoot),
codebaseIndexStore: NewCodebaseIndexStore(appdata.CodebaseIndexRootPath()),
docsIndexStore: NewDocsIndexStore(appdata.DocsIndexRootPath()),
rules: rules,
projector: projector,
compiler: NewPromptCompiler(projector, NewToolCatalog(), NewReminderInjector(), rules),
compiler: NewPromptCompiler(projector, NewToolCatalog(), NewReminderInjector(), rules, contentBlobs),
provider: NewProviderGateway(resolver),
resolver: resolver,
modelMemory: modelMemory,
@@ -317,6 +320,7 @@ func newServiceWithDependencies(store *ConversationFileStore, projector *History
debug := newDebugRecorder(historyRoot, broker, nil)
return &Service{
store: store,
contentBlobs: NewContentBlobStore(historyRoot),
rules: NewUserRuleStore(appdata.RulesRootPath()),
projector: projector,
compiler: compiler,
@@ -559,6 +563,7 @@ func (service *Service) decodeInboundIntent(requestID string, message *agentv1.A
}
intent.ConversationID = conversationID
intent.ConversationState = runRequest.GetConversationState()
intent.PreFetchedBlobs = runRequest.GetPreFetchedBlobs()
intent.UserMessage = extractUserMessage(message)
intent.RequestContext = extractRequestContext(message)
if service.shouldIgnoreEmptyResumeRunRequest(requestID, runRequest, intent.UserMessage, intent.RequestContext) {
@@ -606,6 +611,7 @@ func (service *Service) decodeInboundIntent(requestID string, message *agentv1.A
intent.ConversationID = conversationID
intent.SubagentTypeName = strings.TrimSpace(prewarmRequest.GetSubagentTypeName())
intent.ConversationState = prewarmRequest.GetConversationState()
intent.PreFetchedBlobs = prewarmRequest.GetPreFetchedBlobs()
intent.Mode, intent.ModeSource, intent.HasExplicitMode, err = extractPrewarmMode(prewarmRequest)
if err != nil {
return InboundIntent{}, err
@@ -1012,6 +1018,9 @@ func (service *Service) handleExecResult(intent InboundIntent) error {
if !result.IsTerminal {
return nil
}
if err := service.persistExecContentBlobs(result.ContentBlobs); err != nil {
return err
}
markExecCompleted(stream, pending)
backgroundShellToolCallID := ""
if strings.TrimSpace(pending.ExecKind) == "shell" && shellToolCallIsBackgrounded(result.ToolCall) {
@@ -1050,6 +1059,21 @@ func (service *Service) handleExecResult(intent InboundIntent) error {
return service.reconcileStream(stream)
}
func (service *Service) persistExecContentBlobs(blobs []execbridge.ContentBlob) error {
if len(blobs) == 0 {
return nil
}
if service == nil || service.contentBlobs == nil {
return fmt.Errorf("content blob store is not initialized")
}
for _, blob := range blobs {
if err := service.contentBlobs.Put(blob.ID, blob.Data); err != nil {
return fmt.Errorf("persist exec content blob: %w", err)
}
}
return nil
}
// handleExecControl 处理执行桥控制面结果,例如 stream_close 或 throw。
func (service *Service) handleExecControl(intent InboundIntent) error {
stream, ok := service.broker.Get(intent.RequestID)
@@ -2246,6 +2270,15 @@ func (service *Service) finishSuccessfulTurnAfterCheckpoint(stream *ActiveStream
return nil
}
func (service *Service) finishFailedTurnAfterCheckpoint(stream *ActiveStream, terminalCode string, terminalMessage string) error {
if stream == nil {
return nil
}
err := service.broker.Fail(stream.RequestID, terminalCode, terminalMessage)
service.setTurnPhase(stream, TurnPhaseFailed)
return err
}
func (service *Service) failStreamIfNonTerminal(stream *ActiveStream, terminalCode string, cause error) error {
if stream == nil || cause == nil {
return nil
@@ -2265,6 +2298,10 @@ func (service *Service) publishCheckpoint(requestID string, conversationID strin
}
func (service *Service) publishCheckpointWithCompletion(requestID string, _ string, completion *pendingTurnCompletion) error {
return service.publishCheckpointWithTerminalAction(requestID, successfulCheckpointTerminalAction(completion))
}
func (service *Service) publishCheckpointWithTerminalAction(requestID string, terminal checkpointTerminalAction) error {
stream, ok := service.broker.Get(requestID)
if !ok || stream == nil {
return fmt.Errorf("request is not active: %s", requestID)
@@ -2282,7 +2319,7 @@ func (service *Service) publishCheckpointWithCompletion(requestID string, _ stri
}
projection.State.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, projection.State)
return service.queueCheckpointProjection(stream, projection, completion)
return service.queueCheckpointProjectionWithTerminal(stream, projection, terminal)
}
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
@@ -2422,18 +2459,20 @@ func (service *Service) failActiveStream(stream *ActiveStream, conversationID st
if cancel != nil {
cancel()
}
service.setTurnPhase(stream, TurnPhaseFailed)
var firstErr error
if err := service.syncSummaryCarryForward(conversationID, requestID, modelCallID); err != nil && firstErr == nil {
firstErr = err
if err := service.syncSummaryCarryForward(conversationID, requestID, modelCallID); err != nil {
log.Printf(
"forwarder summary sync before failed terminal skipped request_id=%s model_call_id=%s err=%v",
strings.TrimSpace(requestID),
strings.TrimSpace(modelCallID),
err,
)
}
if err := service.publishCheckpoint(requestID, conversationID); err != nil && firstErr == nil {
firstErr = err
terminal := failedCheckpointTerminalAction(terminalCode, terminalMessage)
if err := service.publishCheckpointWithTerminalAction(requestID, terminal); err != nil {
log.Printf("forwarder checkpoint queue before failed terminal skipped request_id=%s err=%v", strings.TrimSpace(requestID), err)
return service.finishFailedTurnAfterCheckpoint(stream, terminalCode, terminalMessage)
}
if err := service.broker.Fail(requestID, terminalCode, terminalMessage); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
return nil
}
// buildRunEntries 构造一次 run intent 需要写入 history 的首批 entry。
+28 -29
View File
@@ -45,13 +45,25 @@ func (snapshot turnUsageSnapshot) requestTokensTotal() int64 {
return snapshot.promptTokensTotal() + nonNegativeInt64(snapshot.OutputTokens)
}
func (service *Service) importConversationState(item *ConversationFile, state *agentv1.ConversationStateStructure) ([]HistoryEntry, error) {
func (service *Service) importConversationState(item *ConversationFile, state *agentv1.ConversationStateStructure, prefetchedBlobs []*agentv1.PreFetchedBlob) ([]HistoryEntry, error) {
if item == nil || state == nil {
return nil, nil
}
blobs, err := newImportedBlobStore(prefetchedBlobs)
if err != nil {
return nil, err
}
importedIDs, err := importedTurnIDs(state.GetTurns(), blobs)
if err != nil {
return nil, err
}
item.TokenDetailsUsedTokens = state.GetTokenDetails().GetUsedTokens()
item.ImportedTurnIDs = importedIDs
if minimumNextTurnSeq := int64(len(item.ImportedTurnIDs)) + 1; item.NextTurnSeq < minimumNextTurnSeq {
item.NextTurnSeq = minimumNextTurnSeq
}
entries := make([]HistoryEntry, 0, 2)
if messages, err := importedConversationStateModelMessages(state); err != nil {
if messages, err := importedConversationStateModelMessagesWithBlobs(state, blobs); err != nil {
return nil, err
} else {
for _, message := range messages {
@@ -105,6 +117,10 @@ func (service *Service) importConversationState(item *ConversationFile, state *a
}
func importedConversationStateModelMessages(state *agentv1.ConversationStateStructure) ([]modeladapter.Message, error) {
return importedConversationStateModelMessagesWithBlobs(state, nil)
}
func importedConversationStateModelMessagesWithBlobs(state *agentv1.ConversationStateStructure, blobs importedBlobStore) ([]modeladapter.Message, error) {
if state == nil {
return nil, nil
}
@@ -113,7 +129,7 @@ func importedConversationStateModelMessages(state *agentv1.ConversationStateStru
if err != nil {
return nil, fmt.Errorf("decode imported replay messages: %w", err)
}
decoded = restoreImportedReplayUserMessages(decoded, state.GetTurns())
decoded = restoreImportedReplayUserMessages(decoded, state.GetTurns(), blobs)
decoded = filterLegacyPlainWriteReplay(decoded)
decoded = filterInternalPromptContextReplay(decoded)
messages := make([]modeladapter.Message, 0, len(decoded))
@@ -133,35 +149,18 @@ func importedConversationStateModelMessages(state *agentv1.ConversationStateStru
if len(rawTurn) == 0 {
continue
}
turn := &agentv1.ConversationTurnStructure{}
if err := proto.Unmarshal(rawTurn, turn); err != nil {
return nil, fmt.Errorf("decode imported turn: %w", err)
turn, turnID, err := decodeImportedTurn(rawTurn, blobs)
if err != nil {
return nil, err
}
agentTurn := turn.GetAgentConversationTurn()
if agentTurn == nil {
continue
}
if rawUser := agentTurn.GetUserMessage(); len(rawUser) > 0 {
userMessage := &agentv1.UserMessage{}
if err := proto.Unmarshal(rawUser, userMessage); err != nil {
return nil, fmt.Errorf("decode imported turn user_message: %w", err)
}
if replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage); ok {
messages = append(messages, toModelMessage(replay))
}
}
for _, rawStep := range agentTurn.GetSteps() {
if len(rawStep) == 0 {
continue
}
step := &agentv1.ConversationStep{}
if err := proto.Unmarshal(rawStep, step); err != nil {
return nil, fmt.Errorf("decode imported turn step: %w", err)
}
for _, replay := range promptengine.BuildLegacyMessagesFromConversationStep(step) {
messages = append(messages, toModelMessage(replay))
if turn == nil && len(turnID) > 0 {
return nil, fmt.Errorf("missing prefetched turn blob %x", turnID)
}
turnMessages, err := importedBlobTurnMessages(turn, blobs)
if err != nil {
return nil, err
}
messages = append(messages, turnMessages...)
}
return normalizeReplayMessageSequence(messages), nil
}
+18 -2
View File
@@ -39,6 +39,7 @@ type ConversationFile struct {
CurrentPlanText string `json:"current_plan_text,omitempty"`
CurrentPlans map[string]*agentv1.PlanRegistryEntry `json:"current_plans,omitempty"`
CurrentTodos []*agentv1.TodoItem `json:"current_todos,omitempty"`
ImportedTurnIDs [][]byte `json:"imported_turn_ids,omitempty"`
LatestRequestPrefix *ConversationRequestPrefix `json:"latest_request_prefix,omitempty"`
LastProviderCall *ConversationProviderCall `json:"last_provider_call,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -224,11 +225,25 @@ type pendingTurnCompletion struct {
Disposition pendingCompletionDisposition
}
type checkpointTerminalActionKind uint8
const (
checkpointTerminalActionNone checkpointTerminalActionKind = iota
checkpointTerminalActionComplete
checkpointTerminalActionFail
)
type checkpointTerminalAction struct {
Kind checkpointTerminalActionKind
Completion pendingTurnCompletion
ErrorCode string
ErrorMessage string
}
type pendingCheckpointPublish struct {
State *agentv1.ConversationStateStructure
Required map[string]struct{}
Completion *pendingTurnCompletion
Published bool
Terminal checkpointTerminalAction
}
type PendingCompaction struct {
@@ -430,6 +445,7 @@ type InboundIntent struct {
SubagentTypeName string
SubagentModelOverrides map[string]runtimecore.SubagentModelOverrideSelection
ConversationState *agentv1.ConversationStateStructure
PreFetchedBlobs []*agentv1.PreFetchedBlob
UserMessage *agentv1.UserMessage
RequestContext *agentv1.RequestContext
ClientMessage *agentv1.AgentClientMessage
+9 -7
View File
@@ -141,8 +141,8 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
return nil, errors.New("模型适配器 tooltipData 不能为空")
case next.ModelID == "":
return nil, errors.New("模型适配器 modelID 不能为空")
case next.Type == "openai" && next.ReasoningEffort == "":
return nil, errors.New("模型适配器 reasoningEffort 仅支持 low、medium、high、xhigh、max")
case next.Type == "openai" && !isSupportedReasoningEffort(next.ReasoningEffort):
return nil, errors.New("模型适配器 reasoningEffort 仅支持空值、low、medium、high、xhigh、max")
case next.Type == "openai" && next.OpenAIEndpoint == "":
return nil, errors.New("模型适配器 openAIEndpoint 仅支持 /v1/responses、/v1/chat/completions 或 /custom(自定义路径)")
case next.Type == "openai" && next.OpenAIExtraParamsEnabled:
@@ -224,13 +224,15 @@ func validateHeadersJSON(value string) error {
}
func normalizeReasoningEffort(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "medium":
return "medium"
case "low", "high", "xhigh", "max":
return strings.ToLower(strings.TrimSpace(value))
}
func isSupportedReasoningEffort(value string) bool {
switch value {
case "", "low", "medium", "high", "xhigh", "max":
return true
default:
return ""
return false
}
}
@@ -58,3 +58,25 @@ func TestNormalizeModelAdapterConfigsUsesStableExplicitSort(t *testing.T) {
}
}
}
func TestNormalizeModelAdapterConfigsAllowsBlankReasoningEffort(t *testing.T) {
adapter := testModelAdapter("non-reasoning-model", 1)
adapter.ReasoningEffort = ""
adapters, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{adapter})
if err != nil {
t.Fatalf("NormalizeModelAdapterConfigs returned error: %v", err)
}
if got := adapters[0].ReasoningEffort; got != "" {
t.Fatalf("ReasoningEffort = %q, want blank", got)
}
}
func TestNormalizeModelAdapterConfigsRejectsUnknownReasoningEffort(t *testing.T) {
adapter := testModelAdapter("invalid-reasoning-effort", 1)
adapter.ReasoningEffort = "unsupported"
if _, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{adapter}); err == nil {
t.Fatal("NormalizeModelAdapterConfigs should reject an unknown reasoning effort")
}
}
+1 -1
View File
@@ -835,7 +835,7 @@ func defaultThinkingEffortForAdapter(adapter legacyruntime.ModelAdapterConfig) s
if strings.EqualFold(strings.TrimSpace(adapter.Type), "anthropic") {
return normalizeAvailableModelThinkingEffort(adapter.AnthropicThinkingEffort, true, "xhigh")
}
return normalizeAvailableModelThinkingEffort(adapter.ReasoningEffort, true, "medium")
return normalizeAvailableModelThinkingEffort(adapter.ReasoningEffort, true, "disabled")
}
func normalizeAvailableModelThinkingEffort(raw string, allowMax bool, fallback string) string {
@@ -28,6 +28,40 @@ func TestBuildCLIModelDetailsPreservesChannelMetadata(t *testing.T) {
}
}
func TestDefaultThinkingEffortForOpenAIAdapterUsesDisabledWhenUnset(t *testing.T) {
adapter := legacyruntime.ModelAdapterConfig{Type: "openai", ReasoningEffort: ""}
if got := defaultThinkingEffortForAdapter(adapter); got != "disabled" {
t.Fatalf("default thinking effort = %q, want disabled", got)
}
}
func TestBuildAvailableModelEntriesUsesDisabledVariantWhenReasoningEffortUnset(t *testing.T) {
entries := buildAvailableModelEntries([]legacyruntime.ModelAdapterConfig{{
ID: "channel-a",
DisplayName: "Model A",
ModelID: "model-a",
Type: "openai",
}})
if len(entries) != 1 {
t.Fatalf("entry count = %d, want 1", len(entries))
}
variants, ok := entries[0]["variants"].([]map[string]any)
if !ok {
t.Fatalf("variants type = %T, want []map[string]any", entries[0]["variants"])
}
if len(variants) == 0 {
t.Fatal("variants should not be empty")
}
if got := variants[0]["variantStringRepresentation"]; got != "channel-a:disabled" {
t.Fatalf("first variant representation = %#v, want channel-a:disabled", got)
}
if got := variants[0]["isDefaultNonMaxConfig"]; got != true {
t.Fatalf("disabled variant default flag = %#v, want true", got)
}
}
func TestEncodeCLIModelsUsesAgentModelDetailsWireFormat(t *testing.T) {
payload := map[string]any{"models": buildCLIModelDetails([]legacyruntime.ModelAdapterConfig{{ID: "channel-a", DisplayName: "Model A", APIKey: "provider-secret", BaseURL: "https://provider.example/v1"}})}
encoded, err := encodeMockProto("aiserver.v1.GetUsableModelsResponse", payload)
+42 -44
View File
@@ -1,6 +1,7 @@
package certs
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
@@ -9,33 +10,24 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
_ "embed"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"os"
"strings"
"sync"
"time"
)
// embeddedCACertPEM 表示当前模块中的 embeddedCACertPEM 状态值。
//
//go:embed ca.crt
var embeddedCACertPEM []byte
// embeddedCAKeyPEM 表示当前模块中的 embeddedCAKeyPEM 状态值。
//
//go:embed ca.key
var embeddedCAKeyPEM []byte
// Manager 定义了当前模块中的 Manager 类型。
type Manager struct {
// caCert 表示当前声明中的 caCert。
caCert *x509.Certificate
// caKey 表示当前声明中的 caKey。
caKey crypto.PrivateKey
// caCertPEM 保存可注入宿主信任存储的 CA 证书,不包含私钥。
caCertPEM []byte
// mu 表示当前声明中的 mu。
mu sync.Mutex
@@ -52,28 +44,26 @@ func NewManager(caCertPath, caKeyPath string) (*Manager, error) {
return NewManagerFromPEM(certPEM, keyPEM)
}
// NewEmbeddedManager 用于处理与 NewEmbeddedManager 相关的逻辑。
func NewEmbeddedManager() (*Manager, error) {
return NewManagerFromPEM(embeddedCACertPEM, embeddedCAKeyPEM)
}
// EmbeddedCACertPEM 用于处理与 EmbeddedCACertPEM 相关的逻辑。
func EmbeddedCACertPEM() []byte {
return cloneBytes(embeddedCACertPEM)
}
// EmbeddedCAKeyPEM 用于处理与 EmbeddedCAKeyPEM 相关的逻辑。
func EmbeddedCAKeyPEM() []byte {
return cloneBytes(embeddedCAKeyPEM)
}
// NewManagerFromPEM 用于处理与 NewManagerFromPEM 相关的逻辑。
func NewManagerFromPEM(caCertPEM, caKeyPEM []byte) (*Manager, error) {
caCert, caKey, err := loadCAFromPEM(caCertPEM, caKeyPEM)
if err != nil {
return nil, err
}
return &Manager{caCert: caCert, caKey: caKey, cache: make(map[string]*tls.Certificate)}, nil
return &Manager{
caCert: caCert,
caKey: caKey,
caCertPEM: cloneBytes(caCertPEM),
cache: make(map[string]*tls.Certificate),
}, nil
}
// CACertPEM 返回当前 Manager 使用的 CA 证书。返回值不包含私钥。
func (m *Manager) CACertPEM() []byte {
if m == nil {
return nil
}
return cloneBytes(m.caCertPEM)
}
// CATLSCertificate 用于处理与 CATLSCertificate 相关的逻辑。
@@ -185,19 +175,6 @@ func marshalPrivateKeyPEM(key any) ([]byte, error) {
}
}
// loadCAPEMFromFiles 用于处理与 loadCAPEMFromFiles 相关的逻辑。
func loadCAPEMFromFiles(certPath, keyPath string) ([]byte, []byte, error) {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, err
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, err
}
return certPEM, keyPEM, nil
}
// loadCAFromPEM 用于处理与 loadCAFromPEM 相关的逻辑。
func loadCAFromPEM(certPEM, keyPEM []byte) (*x509.Certificate, crypto.PrivateKey, error) {
certBlock, _ := pem.Decode(certPEM)
@@ -214,28 +191,49 @@ func loadCAFromPEM(certPEM, keyPEM []byte) (*x509.Certificate, crypto.PrivateKey
return nil, nil, errors.New("invalid CA key PEM")
}
var caKey crypto.PrivateKey
switch keyBlock.Type {
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, nil, err
}
return caCert, key, nil
caKey = key
case "EC PRIVATE KEY":
key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
if err != nil {
return nil, nil, err
}
return caCert, key, nil
caKey = key
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, nil, err
}
return caCert, key, nil
caKey = key
default:
return nil, nil, errors.New("unsupported CA key format")
}
if !caCert.IsCA || !caCert.BasicConstraintsValid || caCert.KeyUsage&x509.KeyUsageCertSign == 0 {
return nil, nil, errors.New("certificate is not a valid signing CA")
}
signer, ok := caKey.(crypto.Signer)
if !ok {
return nil, nil, errors.New("CA private key cannot sign certificates")
}
certPublicKey, err := x509.MarshalPKIXPublicKey(caCert.PublicKey)
if err != nil {
return nil, nil, fmt.Errorf("marshal CA certificate public key: %w", err)
}
privatePublicKey, err := x509.MarshalPKIXPublicKey(signer.Public())
if err != nil {
return nil, nil, fmt.Errorf("marshal CA private key public key: %w", err)
}
if !bytes.Equal(certPublicKey, privatePublicKey) {
return nil, nil, errors.New("CA certificate and private key do not match")
}
return caCert, caKey, nil
}
// normalizeHost 用于处理与 normalizeHost 相关的逻辑。
-27
View File
@@ -1,27 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAyh3jND/aFusuRjGTmhQtX2hkF1qroNjEEKCxWPlfprvdl8Tx
upxNv1TQcm+K9KsS7OxnKYP4Qtv068XLbaCCGuoA/xpor6enrT85KulBq0j8z/g1
y0VWxjz3xN9F9ND13h9yxDZCn76egbRkhFxpXow67jLsIlrqWDSltERlTKh2cJ1g
hTRuQNSr7jtKQgFAR3aQ6dTzOP4fCOtLYn63jL4+YcxdGoK66tx8eFHq8oBLYvsc
LNjjkaAHilZFwA4Jr7zHBofTRBg04eZum2UTaRqmSyT65ifXm4vRdQa4k1FS1gnq
hPMhIjST8b6RkxvbLNmFpkOfI9eCMRpFG7y+NQIDAQABAoIBAATU9ZVOcHmLSkop
zcBJerM09O2dAIziGb/XA55fqdJ728aQ0gGW0oIANlKCCaWjQFrTJP04VzNL/F01
l5EpnOqlTPxMRpPqc2cAI677sBL29fpH0gtnvzUSiI7Xkp3RcAtNH6qCrJGSlkn+
BMgoSGmW+yKuK3h/yWnt6kc2umA8fN+bHKhS3pI56PMW8qVnny9n92RaCA7Uf/4j
XDewIreiH5jRqRwrPbOpjDFmv+W18LWZQiTwwxfY6sRZiZpsfsHidzfFGUFZXMlq
2P3FCqoF4oMM1rRgBlHhDR7JHmSkFpZG639HJTXLllpyDbj3H86nsjIj9WZYKn+h
B9k9bcECgYEA1HDzRCqoRZh6cL46KiYjv+LwmEKMa3nPb4ljywgLbRkzTMyVs0MK
fDsDoTLFY9PBhveypU5gjTbQqtyBsDtbU+dH9Eks1FdL9P8bJf0ZOJFRiEB7uB4a
z3V9tcXHwH4l2bCWbWThQGFwRudDAoY0EH89oSA/WHayjOKe1Wi4ovUCgYEA848B
cYi+Qbkk+fOv9gSJn8KS1LH/jE28S/e7E4YkTfYUuu+7wr8bdRKUNpPIsLX0Fo9R
KpJX0Oyjjady9n/8ARZRmD8Upl+F7Sl7Ro6F8+nfqQUrxbVDiIL3b+aZF+cDfFrB
/xL5kyZZqTtFfP360tfYnlS6Sssd4E2Jsj0fJkECgYEA0gG+WbqZkgMDtwQ114jQ
elZLZRkUWwKVjzsQDZssQHNTBS6RJh619M0Z73aTLvYcL+IZFdT/GVoAuYc2JRLo
W28c8F6OFHMfwVeWbN1g20y8fqbQJtiLxF3vIYwcxStvG123tvisu8oXBeCDm7Ez
MsO2FtwcAsWECEXWojzdmSkCgYEA18GvPawtHnuszd+Z2Q5b/DKZb+Hex6N1Ura5
+qmyL333D0Kfyf0RjbxPn6l690+4UuPSuyu4r1Nx72KO7N6jlzL2RTBcUqX8NgOx
OOe4skJT5561EAdrM9sQ5wgYRpxW8ipUAGoGvNwUQV5ISFmVgIHFWz0jam5UoQcP
G94ZYgECgYBN4PB8BuAwMRqhTCLL5RJKtcdC/Ls9xdWrmswtQQ4OSBUTLGdeMhEN
E23F0d+NdtiTWDYRDJ7z6KAF8CcOwWMt2sNbrxgNRuDzyfLqbuVLOM8iY3xA50Nl
clZWujKILWp47+gA6/AqJLv2lA2LK8gM8zYzzm/nCuLCBLYH/hOiCA==
-----END RSA PRIVATE KEY-----
+191
View File
@@ -0,0 +1,191 @@
package certs
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
"time"
)
const legacySharedCASHA256 = "836E6BB84F6C3E63316DBB4EC257223AF09F7490E7AAE09030B8515ED61EE9FF"
// LoadOrCreateManager loads the installation-specific CA, generating it on
// first run. The private key is persisted only in the supplied local path.
func LoadOrCreateManager(certPath, keyPath string) (*Manager, []byte, error) {
certPEM, certErr := os.ReadFile(certPath)
keyPEM, keyErr := os.ReadFile(keyPath)
if certErr == nil && isLegacySharedCA(certPEM) {
return generateAndPersistManager(certPath, keyPath)
}
if certErr == nil && keyErr == nil {
manager, err := NewManagerFromPEM(certPEM, keyPEM)
if err != nil {
return nil, nil, fmt.Errorf("load installation CA: %w", err)
}
if err := os.Chmod(keyPath, 0o600); err != nil {
return nil, nil, fmt.Errorf("restrict installation CA private key permissions: %w", err)
}
return manager, manager.CACertPEM(), nil
}
if errors.Is(certErr, os.ErrNotExist) && errors.Is(keyErr, os.ErrNotExist) {
return generateAndPersistManager(certPath, keyPath)
}
// A key without a certificate cannot have been installed as a trusted root.
// This is safe to recover if the first-run write was interrupted.
if errors.Is(certErr, os.ErrNotExist) && keyErr == nil {
return generateAndPersistManager(certPath, keyPath)
}
if certErr != nil && !errors.Is(certErr, os.ErrNotExist) {
return nil, nil, fmt.Errorf("read installation CA certificate: %w", certErr)
}
if keyErr != nil && !errors.Is(keyErr, os.ErrNotExist) {
return nil, nil, fmt.Errorf("read installation CA private key: %w", keyErr)
}
return nil, nil, errors.New("installation CA is incomplete; both certificate and private key are required")
}
// NewGeneratedManager creates an in-memory CA suitable for short-lived tools.
func NewGeneratedManager() (*Manager, []byte, error) {
certPEM, keyPEM, err := generateCA()
if err != nil {
return nil, nil, err
}
manager, err := NewManagerFromPEM(certPEM, keyPEM)
if err != nil {
return nil, nil, err
}
return manager, manager.CACertPEM(), nil
}
func generateAndPersistManager(certPath, keyPath string) (*Manager, []byte, error) {
if filepath.Dir(certPath) != filepath.Dir(keyPath) {
return nil, nil, errors.New("installation CA certificate and key must share a directory")
}
certPEM, keyPEM, err := generateCA()
if err != nil {
return nil, nil, err
}
if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
return nil, nil, fmt.Errorf("create installation CA directory: %w", err)
}
// Write the private key first so a crash cannot leave a new certificate
// without the signing key needed by the proxy.
if err := writeLocalCAFile(keyPath, keyPEM, 0o600); err != nil {
return nil, nil, fmt.Errorf("persist installation CA private key: %w", err)
}
if err := writeLocalCAFile(certPath, certPEM, 0o644); err != nil {
return nil, nil, fmt.Errorf("persist installation CA certificate: %w", err)
}
manager, err := NewManagerFromPEM(certPEM, keyPEM)
if err != nil {
return nil, nil, err
}
return manager, manager.CACertPEM(), nil
}
func generateCA() ([]byte, []byte, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 3072)
if err != nil {
return nil, nil, fmt.Errorf("generate installation CA private key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("generate installation CA serial: %w", err)
}
publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return nil, nil, fmt.Errorf("marshal installation CA public key: %w", err)
}
subjectKeyID := sha256.Sum256(publicKeyDER)
now := time.Now()
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "Cursor BYOK Local CA",
Organization: []string{"Cursor BYOK"},
},
NotBefore: now.Add(-5 * time.Minute),
NotAfter: now.AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 0,
MaxPathLenZero: true,
SubjectKeyId: append([]byte(nil), subjectKeyID[:20]...),
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, nil, fmt.Errorf("create installation CA certificate: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
return certPEM, keyPEM, nil
}
func writeLocalCAFile(path string, data []byte, mode os.FileMode) error {
temp, err := os.CreateTemp(filepath.Dir(path), ".ca-*")
if err != nil {
return err
}
tempPath := temp.Name()
defer os.Remove(tempPath)
if err := temp.Chmod(mode); err != nil {
_ = temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(tempPath, path); err != nil {
return err
}
return os.Chmod(path, mode)
}
func isLegacySharedCA(certPEM []byte) bool {
block, _ := pem.Decode(certPEM)
if block == nil {
return false
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return false
}
sum := sha256.Sum256(cert.Raw)
return strings.EqualFold(hex.EncodeToString(sum[:]), legacySharedCASHA256)
}
// loadCAPEMFromFiles reads an explicitly supplied CA pair.
func loadCAPEMFromFiles(certPath, keyPath string) ([]byte, []byte, error) {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, err
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, err
}
return certPEM, keyPEM, nil
}
+125
View File
@@ -0,0 +1,125 @@
package certs
import (
"bytes"
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"runtime"
"testing"
)
func TestLoadOrCreateManagerPersistsAndReusesInstallationCA(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "ca.crt")
keyPath := filepath.Join(dir, "ca.key")
manager, certPEM, err := LoadOrCreateManager(certPath, keyPath)
if err != nil {
t.Fatalf("LoadOrCreateManager() error = %v", err)
}
if isLegacySharedCA(certPEM) {
t.Fatal("generated CA reused the legacy shared certificate")
}
keyInfo, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("stat private key: %v", err)
}
if runtime.GOOS != "windows" && keyInfo.Mode().Perm() != 0o600 {
t.Fatalf("private key mode = %o, want 600", keyInfo.Mode().Perm())
}
leaf, err := manager.CertificateForServerName("api2.cursor.sh")
if err != nil {
t.Fatalf("CertificateForServerName() error = %v", err)
}
ca := parseCertificatePEM(t, certPEM)
roots := x509.NewCertPool()
roots.AddCert(ca)
if _, err := leaf.Leaf.Verify(x509.VerifyOptions{DNSName: "api2.cursor.sh", Roots: roots}); err != nil {
t.Fatalf("verify generated leaf: %v", err)
}
_, reusedCertPEM, err := LoadOrCreateManager(certPath, keyPath)
if err != nil {
t.Fatalf("second LoadOrCreateManager() error = %v", err)
}
if !bytes.Equal(certPEM, reusedCertPEM) {
t.Fatal("installation CA changed between loads")
}
}
func TestLoadOrCreateManagerCreatesUniqueCAsPerInstallation(t *testing.T) {
firstDir := t.TempDir()
secondDir := t.TempDir()
_, firstCert, err := LoadOrCreateManager(filepath.Join(firstDir, "ca.crt"), filepath.Join(firstDir, "ca.key"))
if err != nil {
t.Fatalf("create first CA: %v", err)
}
_, secondCert, err := LoadOrCreateManager(filepath.Join(secondDir, "ca.crt"), filepath.Join(secondDir, "ca.key"))
if err != nil {
t.Fatalf("create second CA: %v", err)
}
if bytes.Equal(firstCert, secondCert) {
t.Fatal("separate installations received the same CA certificate")
}
}
func TestLoadOrCreateManagerReplacesLegacySharedCertificate(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "ca.crt")
keyPath := filepath.Join(dir, "ca.key")
legacyCert, err := os.ReadFile(filepath.Join("testdata", "legacy_shared_ca.crt"))
if err != nil {
t.Fatalf("read legacy certificate fixture: %v", err)
}
if !isLegacySharedCA(legacyCert) {
t.Fatal("legacy certificate fixture fingerprint changed")
}
if err := os.WriteFile(certPath, legacyCert, 0o644); err != nil {
t.Fatalf("write legacy certificate: %v", err)
}
_, generatedCert, err := LoadOrCreateManager(certPath, keyPath)
if err != nil {
t.Fatalf("migrate legacy certificate: %v", err)
}
if bytes.Equal(legacyCert, generatedCert) || isLegacySharedCA(generatedCert) {
t.Fatal("legacy shared certificate was not replaced")
}
if _, err := os.Stat(keyPath); err != nil {
t.Fatalf("generated private key missing: %v", err)
}
}
func TestNewManagerFromPEMRejectsMismatchedKey(t *testing.T) {
firstDir := t.TempDir()
secondDir := t.TempDir()
_, _, err := LoadOrCreateManager(filepath.Join(firstDir, "ca.crt"), filepath.Join(firstDir, "ca.key"))
if err != nil {
t.Fatalf("create first CA: %v", err)
}
_, _, err = LoadOrCreateManager(filepath.Join(secondDir, "ca.crt"), filepath.Join(secondDir, "ca.key"))
if err != nil {
t.Fatalf("create second CA: %v", err)
}
certPEM, _ := os.ReadFile(filepath.Join(firstDir, "ca.crt"))
keyPEM, _ := os.ReadFile(filepath.Join(secondDir, "ca.key"))
if _, err := NewManagerFromPEM(certPEM, keyPEM); err == nil {
t.Fatal("NewManagerFromPEM() accepted a mismatched private key")
}
}
func parseCertificatePEM(t *testing.T, certPEM []byte) *x509.Certificate {
t.Helper()
block, _ := pem.Decode(certPEM)
if block == nil {
t.Fatal("certificate PEM is invalid")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
return cert
}
+4
View File
@@ -5,6 +5,7 @@ import (
goruntime "runtime"
"cursor/internal/cursor"
"cursor/internal/logger"
)
// ApplyCursorSettings 用于处理与 ApplyCursorSettings 相关的逻辑。
@@ -21,6 +22,9 @@ func (s *ProxyService) ApplyCursorSettings() error {
if err != nil {
return fmt.Errorf("ensure ca cert file: %w", err)
}
if err := cursor.EnsureLegacySharedCACertRemoved(); err != nil {
logger.Errorf("remove legacy shared ca cert failed, continuing with installation CA: %v", err)
}
switch goruntime.GOOS {
case "windows":
@@ -950,6 +950,8 @@ func normalizeModelAdapterTestType(value string) string {
func normalizeModelAdapterTestReasoning(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "":
return ""
case "low", "medium", "high", "xhigh", "max":
return strings.ToLower(strings.TrimSpace(value))
default:
@@ -0,0 +1,15 @@
package client
import (
"testing"
serverconfig "cursor/internal/backend/server/config"
)
func TestNormalizeModelAdapterTestProviderReasoningPreservesBlank(t *testing.T) {
adapter := serverconfig.ModelAdapterConfig{Type: "openai", ReasoningEffort: ""}
if got := normalizeModelAdapterTestProviderReasoning(adapter); got != "" {
t.Fatalf("reasoning effort = %q, want blank", got)
}
}
+34
View File
@@ -16,6 +16,7 @@ import (
const (
darwinSecurityExe = "security"
darwinLoginKeychainName = "login.keychain-db"
legacySharedCASHA1 = "C14B7488C5AB83F098BEB2603F1135595A381FC0"
)
func getCertSHA1Fingerprint(certPEM []byte) (string, error) {
@@ -36,7 +37,10 @@ func isCACertInstalled(certPEM []byte) (bool, error) {
if err != nil {
return false, fmt.Errorf("获取证书指纹失败: %w", err)
}
return isCACertFingerprintInstalled(fingerprint)
}
func isCACertFingerprintInstalled(fingerprint string) (bool, error) {
out, err := exec.Command(darwinSecurityExe, "find-certificate", "-a", "-Z", darwinLoginKeychainName).CombinedOutput()
if err != nil {
return false, fmt.Errorf("检查 macOS 登录钥匙串失败: %w: %s", err, strings.TrimSpace(string(out)))
@@ -50,6 +54,36 @@ func isCACertInstalled(certPEM []byte) (bool, error) {
return installed, nil
}
// EnsureLegacySharedCACertRemoved removes the compromised CA shipped by older versions.
func EnsureLegacySharedCACertRemoved() error {
installed, err := isCACertFingerprintInstalled(legacySharedCASHA1)
if err != nil {
return fmt.Errorf("检查旧版共享 CA 失败: %w", err)
}
if !installed {
return nil
}
out, err := exec.Command(
darwinSecurityExe,
"delete-certificate",
"-Z", legacySharedCASHA1,
"-t",
darwinLoginKeychainName,
).CombinedOutput()
if err != nil {
return fmt.Errorf("从 macOS 登录钥匙串删除旧版共享 CA 失败: %w: %s", err, strings.TrimSpace(string(out)))
}
installed, err = isCACertFingerprintInstalled(legacySharedCASHA1)
if err != nil {
return fmt.Errorf("验证旧版共享 CA 删除状态失败: %w", err)
}
if installed {
return fmt.Errorf("删除命令已执行,但 macOS 登录钥匙串中仍存在旧版共享 CA")
}
logger.Infof("ensureLegacySharedCACertRemoved: legacy shared CA removed from macOS login keychain")
return nil
}
func installCACertToDarwinKeychain(certPEM []byte, certPath string) error {
fingerprint, err := getCertSHA1Fingerprint(certPEM)
if err != nil {
+27
View File
@@ -20,6 +20,7 @@ const (
windowsCertutilExe = "certutil.exe"
windowsPowerShellExe = "powershell.exe"
windowsUserCancelCode = 1223
legacySharedCASHA1 = "C14B7488C5AB83F098BEB2603F1135595A381FC0"
)
// getCertThumbprint 获取证书的SHA1指纹,用于唯一标识证书
@@ -51,7 +52,10 @@ func isCACertInstalled(certPEM []byte) (bool, error) {
if err != nil {
return false, fmt.Errorf("获取证书指纹失败: %w", err)
}
return isCACertThumbprintInstalled(thumbprint)
}
func isCACertThumbprintInstalled(thumbprint string) (bool, error) {
cmd := exec.Command(windowsCertutilExe, "-verifystore", windowsRootStoreName, thumbprint)
cmd.SysProcAttr = hideWindow()
output, err := cmd.CombinedOutput()
@@ -76,6 +80,29 @@ func isCACertInstalled(certPEM []byte) (bool, error) {
return false, nil
}
// EnsureLegacySharedCACertRemoved removes the compromised CA shipped by older versions.
func EnsureLegacySharedCACertRemoved() error {
installed, err := isCACertThumbprintInstalled(legacySharedCASHA1)
if err != nil {
return fmt.Errorf("检查旧版共享 CA 失败: %w", err)
}
if !installed {
return nil
}
if err := runElevatedCertutil("-delstore", windowsRootStoreName, legacySharedCASHA1); err != nil {
return fmt.Errorf("从 Windows 系统信任存储删除旧版共享 CA 失败: %w", err)
}
installed, err = isCACertThumbprintInstalled(legacySharedCASHA1)
if err != nil {
return fmt.Errorf("验证旧版共享 CA 删除状态失败: %w", err)
}
if installed {
return fmt.Errorf("删除命令已执行,但 Windows 系统信任存储中仍存在旧版共享 CA")
}
logger.Infof("ensureLegacySharedCACertRemoved: legacy shared CA removed from Windows system store")
return nil
}
func quotePowerShellLiteral(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
+5
View File
@@ -8,3 +8,8 @@ import "fmt"
func EnsureCACertInstalled(_ []byte, certPath string) error {
return fmt.Errorf("ensureCACertInstalled: 当前平台暂不支持,certPath=%s", certPath)
}
// EnsureLegacySharedCACertRemoved is a no-op on unsupported platforms.
func EnsureLegacySharedCACertRemoved() error {
return nil
}
+9 -7
View File
@@ -141,8 +141,8 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
return nil, errors.New("模型适配器 tooltipData 不能为空")
case next.ModelID == "":
return nil, errors.New("模型适配器 modelID 不能为空")
case next.Type == "openai" && next.ReasoningEffort == "":
return nil, errors.New("模型适配器 reasoningEffort 仅支持 low、medium、high、xhigh、max")
case next.Type == "openai" && !isSupportedReasoningEffort(next.ReasoningEffort):
return nil, errors.New("模型适配器 reasoningEffort 仅支持空值、low、medium、high、xhigh、max")
case next.Type == "openai" && next.OpenAIEndpoint == "":
return nil, errors.New("模型适配器 openAIEndpoint 仅支持 /v1/responses 或 /v1/chat/completions")
case next.Type == "openai" && next.OpenAIExtraParamsEnabled:
@@ -224,13 +224,15 @@ func validateHeadersJSON(value string) error {
}
func normalizeReasoningEffort(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "medium":
return "medium"
case "low", "high", "xhigh", "max":
return strings.ToLower(strings.TrimSpace(value))
}
func isSupportedReasoningEffort(value string) bool {
switch value {
case "", "low", "medium", "high", "xhigh", "max":
return true
default:
return ""
return false
}
}
+32
View File
@@ -0,0 +1,32 @@
package runtime
import "testing"
func testRuntimeModelAdapter(reasoningEffort string) ModelAdapterConfig {
return ModelAdapterConfig{
DisplayName: "non-reasoning-model",
Type: "openai",
BaseURL: "https://api.example.com/v1",
APIKey: "test-key",
TooltipData: "non-reasoning-model",
ModelID: "non-reasoning-model",
ReasoningEffort: reasoningEffort,
OpenAIEndpoint: "/v1/responses",
}
}
func TestNormalizeModelAdapterConfigsAllowsBlankReasoningEffort(t *testing.T) {
adapters, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{testRuntimeModelAdapter("")})
if err != nil {
t.Fatalf("NormalizeModelAdapterConfigs returned error: %v", err)
}
if got := adapters[0].ReasoningEffort; got != "" {
t.Fatalf("ReasoningEffort = %q, want blank", got)
}
}
func TestNormalizeModelAdapterConfigsRejectsUnknownReasoningEffort(t *testing.T) {
if _, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{testRuntimeModelAdapter("unsupported")}); err == nil {
t.Fatal("NormalizeModelAdapterConfigs should reject an unknown reasoning effort")
}
}
+3 -6
View File
@@ -8,9 +8,6 @@ QQ交流群:
Tg群组:
https://t.me/cursor_byok
- 修复检查点,支持Fork Chat
- 修复打断对话的上下文丢失问题
- 重构UI
- 支持拖动模型排序
- 支持一键拉模型
- 支持非主流chat端点
- 修复Read Image
- 支持不设置思考强度
- 修复证书安全问题,需要重启一下cursor生效