Compare commits

..
Author SHA1 Message Date
leookun d3adfffbd8 Implement checkpoint blob handling in forwarder service
- Added support for checkpointing phases and blob management in the forwarder.
- Introduced new types and methods for handling checkpoint blobs, including queuing and publishing checkpoints.
- Enhanced the projector to build checkpoint projections with content-addressed blobs.
- Implemented tests to ensure proper checkpoint blob synchronization and handling of cancellation scenarios.
2026-08-05 02:09:45 +08:00
leookun 4e9335d82f Implement tests for ProjectLegacyCheckpoint to ensure proper handling of conversation state and message imports. This includes verifying that no dangling inline blobs are present and that the correct user and assistant messages are imported. 2026-08-05 01:46:47 +08:00
leokunandGitHub 374ff9c217 Merge pull request #259 from leookun/release/0.0.45
Release/0.0.45
2026-08-05 01:26:22 +08:00
leookun 06ab0d8dae chore(release): update version to 0.0.45 and fix conversation disappearance issue
- Updated version number to 0.0.45 across all relevant files.
- Fixed an issue that could cause conversations to disappear.
2026-08-05 01:25:39 +08:00
leookun 622a57fed7 Revert "Merge pull request #239 from DedSecer/fix/cursor-fork-context"
This reverts commit 697fa99245, reversing
changes made to 5742073be5.
2026-08-04 23:49:49 +08:00
leokunandGitHub 639c452a00 Merge pull request #252 from leookun/release/0.0.44
chore(release): update version to 0.0.44 and enhance release notes
2026-08-03 23:18:11 +08:00
leookun 0c9a07e018 chore(release): update version to 0.0.44 and enhance release notes
- Updated version number to 0.0.44 across all relevant files.
- Added support for cursor-cli in release notes.
2026-08-03 23:17:44 +08:00
leokunandGitHub 1adca7f600 Merge pull request #241 from warelik/fix/cli-local-mode-endpoints
fix(backend): cover cursor-agent CLI local-mode endpoint surface — startup 404s, git-workspace tool 404s, telemetry
2026-08-03 22:58:47 +08:00
leookun 5df3e8f512 feat(cursor): update model handling to use agentv1 responses and enhance CLI model details
- Refactored response handling in newProtoMessage to utilize agentv1 for GetUsableModelsResponse and GetDefaultModelForCliResponse.
- Added new tests for buildCLIModelDetails and encodeCLIModels to ensure correct model details and encoding.
- Updated buildUsableModelsPayload and buildDefaultModelForCliPayload to leverage buildCLIModelDetails for improved model data structure.
2026-08-03 22:56:17 +08:00
leokunandGitHub a2e3a07773 Update README.md 2026-08-03 18:54:56 +08:00
leokunandGitHub 6b7c077448 Update README.md 2026-08-03 18:54:04 +08:00
leokunandGitHub c64911e8c6 Update README.md 2026-08-03 18:53:30 +08:00
leokunandGitHub d1d56bd6eb 更新 README.md 2026-08-03 12:31:41 +08:00
leokunandGitHub ee47f187f0 Merge pull request #249 from leookun/release/0.0.43
chore(release): update version to 0.0.43 and enhance release notes
2026-08-03 01:23:21 +08:00
leookun 424235f11d Merge branch 'main' into fix/cli-local-mode-endpoints
# Conflicts:
#	internal/backend/server/middleware.go
2026-08-03 00:39:56 +08:00
leokunandGitHub 58770aa206 Merge pull request #231 from octo-patch/octo/20260730-parameter-refresh-recvqgk2Qimxgy
fix(openai): recognize MiniMax in thinking disable
2026-08-03 00:35:29 +08:00
leokunandGitHub eb0ad7b636 Merge pull request #248 from leookun/codex/cursor-proxy-debugger
feat: add Cursor protocol debugger
2026-08-03 00:23:40 +08:00
leokunandGitHub 697fa99245 Merge pull request #239 from DedSecer/fix/cursor-fork-context
fix(forwarder): preserve Cursor fork context
2026-08-03 00:21:14 +08:00
leokun 1854a3ab9a feat(cursor): add independent Cursor protocol debugging proxy and build tasks
- Introduced a new task for running the Cursor protocol debugging proxy.
- Added a build task for the Cursor protocol debugging proxy.
- Updated documentation to include usage instructions for the debugging tool.
2026-08-03 00:19:18 +08:00
warelik 2c650b3765 fix(backend): cover cursor-agent CLI local-mode endpoint surface
Desktop Cursor works through the local proxy because it drives the agent
over BidiAppend/RunSSE plus the already-mocked unary endpoints. The
cursor-agent CLI speaks the same agent protocol but calls additional
unary endpoints that had no local handlers, so every request fell into a
wildcard route and came back as HTTP 404, which the Connect client maps
to '[unimplemented] HTTP 404'.

Three independent breaks, one visible symptom:

1. Startup: ServerConfigService/GetServerConfig (only the AiService
   variant was mocked), DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam
   and DashboardService/ListMarketplaces were missing, so the CLI aborted
   during session init.

2. Git workspaces: the CLI resolves the repo path-encryption key from
   indexingConfig.default{User,Team}PathEncryptionKey in GetServerConfig
   when no IDE-stored repo keys exist. The mock returned no indexingConfig,
   so repository identity init failed with 'No encryption key found'.

3. Tool execution: every fs tool executor (Ls/Grep/Glob/Shell) consults
   the ignore service, which calls getRepoBlockExcludeGlobs() ->
   DashboardService/GetTeamReposOrEmptyIfNotInTeam. The 404 propagated as
   the tool result error, so model answers arrived but every tool call
   returned '[unimplemented] HTTP 404'. Non-git workspaces skip the
   repo-block path, which is why tools only failed inside git repos.

Also close the remaining tolerated 404 noise so a CLI session produces
zero unimplemented responses: model listing (GetUsableModels,
GetDefaultModelForCli, GetDefaultModel), dashboard/plugin housekeeping
(GetGlobalCommands, GetEffectiveUserPlugins, RegisterMarketplaceAndPlugins,
GetCliDownloadUrl) and telemetry (AnalyticsService/SubmitLogs,
AnalyticsService/TrackEvents, OTLP /v1/traces).

Additional logging: PolicyMiddleware now includes the request path in the
per-request log line, which is what made this diagnosable from app.log.
2026-08-01 10:58:19 +03:00
DedSecerandCursor 14b286a466 fix(forwarder): preserve Cursor fork context
Store checkpoint turns as validated blobs and restore prefetched parent turns so forked conversations retain their replay history and rewind prefix.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 10:51:23 +08:00
octo-patch 828e0a9941 fix(openai): recognize MiniMax in thinking disable
Route the MiniMax OpenAI-compatible endpoint (global api.minimax.io and
China api.minimaxi.com hosts, plus the MiniMax-M3/MiniMax-M2.7 model ids)
to the thinking_type disable branch so that disabling thinking writes
thinking:{type:"disabled"} and drops reasoning_effort, matching the
existing MiMo/deepseek/glm behavior.
2026-07-30 13:25:22 +00:00
40 changed files with 4307 additions and 203 deletions
+23
View File
@@ -23,6 +23,29 @@ description: 本地模式实现指南
客户端是:/Users/leokun/Library/Application\ Support/Cursor
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
## 可选抓包调试工具
仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行:
```bash
go run ./cmd/cursor-proxy-debugger
```
默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看:
- `agent.v1.AgentService/RunSSE`
- `aiserver.v1.BidiService/BidiAppend`
- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据
- 同一 `request_id` 对应的上下行消息
开发者启动工具后,需要自行完成以下配置:
1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`
2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。
3.`http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。
## Cursor 客户端格式化快照
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
+1 -11
View File
@@ -20,7 +20,7 @@ https://t.me/cursor_byok
## 路线图
[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)
[详细使用教程](https://dcne38qm5vlg.feishu.cn/wiki/JeP7wdGnziBXuikNaF5czWbrn8c)
[详细使用教程](https://docs.leokun.cn)
## 后续
@@ -37,13 +37,3 @@ https://t.me/cursor_byok
## Star History
<a href="https://www.star-history.com/?repos=leookun%2Fcursor-byok&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
</picture>
</a>
+10
View File
@@ -178,6 +178,16 @@ tasks:
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
proxy-debugger:
summary: 启动独立 Cursor 协议调试代理
cmds:
- go run ./cmd/cursor-proxy-debugger
proxy-debugger:build:
summary: 构建独立 Cursor 协议调试代理
cmds:
- go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
ads:install:
summary: 安装广告页依赖
dir: '{{.TASKFILE_DIR}}/ads-page'
+1 -1
View File
@@ -8,7 +8,7 @@ info:
description: "Cursor助手"
copyright: "© 2026, Cursor助手"
comments: "Cursor助手"
version: "0.0.43"
version: "0.0.45"
dev_mode:
root_path: .
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.43</string>
<string>0.0.45</string>
<key>CFBundleVersion</key>
<string>0.0.43</string>
<string>0.0.45</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.43</string>
<string>0.0.45</string>
<key>CFBundleVersion</key>
<string>0.0.43</string>
<string>0.0.45</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
+1 -1
View File
@@ -6,7 +6,7 @@
name: "Cursor助手"
arch: ${GOARCH}
platform: "linux"
version: "0.0.43"
version: "0.0.45"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fixed": {
"file_version": "0.0.43"
"file_version": "0.0.45"
},
"info": {
"0000": {
"ProductVersion": "0.0.43",
"ProductVersion": "0.0.45",
"CompanyName": "Cursor助手",
"FileDescription": "Cursor助手",
"LegalCopyright": "© 2026, Cursor助手",
+1 -1
View File
@@ -14,7 +14,7 @@
!define INFO_PRODUCTNAME "Cursor助手"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.0.43"
!define INFO_PRODUCTVERSION "0.0.45"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, Cursor助手"
+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.43" processorArchitecture="*"/>
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.45" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
proxydebugger "cursor/cursor-proxy-debugger"
"github.com/pkg/browser"
)
func main() {
config := proxydebugger.Config{}
openBrowser := true
flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址")
flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址")
flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机")
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
flag.Parse()
server, err := proxydebugger.New(config)
if err != nil {
log.Fatal(err)
}
if err := server.Start(); err != nil {
log.Fatal(err)
}
fmt.Printf("Cursor 协议调试代理已启动\n")
fmt.Printf("代理地址: http://%s\n", server.ProxyAddr())
fmt.Printf("调试界面: %s\n", server.UIURL())
if openBrowser {
_ = browser.OpenURL(server.UIURL())
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
<-signals
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Close(shutdownContext); err != nil {
log.Printf("关闭调试代理失败:%v", err)
}
}
+58
View File
@@ -0,0 +1,58 @@
# Cursor Protocol Debugger
[中文](README.md) | [English](README.en.md)
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend` and `RunSSE` traffic. It does not modify Cursor, the system proxy, or the installed client.
## Start
Run the following command from the repository root:
```bash
go run ./cmd/cursor-proxy-debugger
```
Default addresses:
- HTTP/HTTPS proxy: `127.0.0.1:9090`
- Debugging UI: `http://127.0.0.1:9091`
- MITM target: `api2.cursor.sh`
The debugging UI opens automatically after startup.
## Configure Cursor
The tool does not modify Cursor automatically. After starting it, configure Cursor manually:
1. Open Cursor's proxy settings and set the proxy to the address printed by the tool. The default is `http://127.0.0.1:9090`.
2. Open Cursor's Network settings and enable HTTP/1.1.
3. Download the proxy CA certificate from `http://127.0.0.1:9091/api/ca.crt` and make sure Cursor trusts it.
Restore the original Cursor proxy and Network settings after debugging to avoid affecting normal network requests.
## Build
```bash
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
```
## Options
```text
-proxy-addr Proxy listen address; default: 127.0.0.1:9090
-ui-addr Debugging UI listen address; default: 127.0.0.1:9091
-target-host Host to decrypt and capture; default: api2.cursor.sh
-max-exchanges Maximum number of exchanges retained in memory; default: 200
-open Open the browser after startup; default: true
```
## Data Handling
- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
- Captured traffic is stored only in process memory and is discarded when the process exits.
- Sensitive HTTP headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
- Raw bodies are retained up to 2 MiB per side by default; forwarded traffic is never truncated.
+58
View File
@@ -0,0 +1,58 @@
# Cursor 协议调试器
[中文](README.md) | [English](README.en.md)
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend``RunSSE` 通信。它不会修改 Cursor、系统代理或已安装客户端。
## 启动
在仓库根目录运行:
```bash
go run ./cmd/cursor-proxy-debugger
```
默认监听:
- HTTP/HTTPS 代理:`127.0.0.1:9090`
- 调试界面:`http://127.0.0.1:9091`
- MITM 目标:`api2.cursor.sh`
启动后会自动打开调试界面。
## 配置 Cursor
工具不会自动修改 Cursor。启动后需要手动完成以下配置:
1. 打开 Cursor 的代理设置,将代理地址修改为工具启动时显示的地址,默认是 `http://127.0.0.1:9090`
2. 打开 Cursor 的 Network 设置,启用 HTTP/1.1。
3.`http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
调试结束后,请恢复原来的 Cursor 代理和 Network 设置,以免影响正常网络请求。
## 构建
```bash
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
```
## 参数
```text
-proxy-addr 代理监听地址,默认 127.0.0.1:9090
-ui-addr 调试界面监听地址,默认 127.0.0.1:9091
-target-host 需要解密的目标主机,默认 api2.cursor.sh
-max-exchanges 内存中保留的最大请求数,默认 200
-open 启动后是否打开浏览器,默认 true
```
## 数据处理
- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`
- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
- 抓包只保留在当前进程内存中;关闭进程后消失。
- `Authorization``Cookie``Set-Cookie` 等 HTTP 头在界面中默认隐藏。
- 单侧原始正文默认最多保留 2 MiB;代理转发的数据不会被截断。
+88
View File
@@ -0,0 +1,88 @@
package proxydebugger
import (
"bytes"
"encoding/hex"
"io"
"sync"
)
type captureReadCloser struct {
source io.ReadCloser
mu sync.Mutex
buffer bytes.Buffer
limit int
size int64
truncated bool
done bool
onChunk func([]byte)
onDone func(captured []byte, size int64, truncated bool, readErr error)
}
func newCaptureReadCloser(
source io.ReadCloser,
limit int,
onChunk func([]byte),
onDone func(captured []byte, size int64, truncated bool, readErr error),
) *captureReadCloser {
return &captureReadCloser{
source: source,
limit: limit,
onChunk: onChunk,
onDone: onDone,
}
}
func (reader *captureReadCloser) Read(payload []byte) (int, error) {
read, err := reader.source.Read(payload)
if read > 0 {
chunk := payload[:read]
reader.mu.Lock()
reader.size += int64(read)
remaining := reader.limit - reader.buffer.Len()
if remaining > 0 {
captured := read
if captured > remaining {
captured = remaining
}
_, _ = reader.buffer.Write(chunk[:captured])
}
if reader.buffer.Len() >= reader.limit && reader.size > int64(reader.buffer.Len()) {
reader.truncated = true
}
reader.mu.Unlock()
if reader.onChunk != nil {
reader.onChunk(append([]byte(nil), chunk...))
}
}
if err != nil {
reader.finish(err)
}
return read, err
}
func (reader *captureReadCloser) Close() error {
err := reader.source.Close()
reader.finish(err)
return err
}
func (reader *captureReadCloser) finish(readErr error) {
reader.mu.Lock()
if reader.done {
reader.mu.Unlock()
return
}
reader.done = true
captured := append([]byte(nil), reader.buffer.Bytes()...)
size := reader.size
truncated := reader.truncated
reader.mu.Unlock()
if reader.onDone != nil {
reader.onDone(captured, size, truncated, readErr)
}
}
func rawHex(payload []byte) string {
return hex.EncodeToString(payload)
}
+236
View File
@@ -0,0 +1,236 @@
package proxydebugger
import (
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
"cursor/gen/agentv1"
"cursor/gen/aiserverv1"
agentprotocol "cursor/internal/backend/agent/protocol"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
const maxConnectFrameBytes = 64 << 20
type connectFrameDecoder struct {
buffer []byte
messageType string
codec string
maxFrames int
frameCount int
onFrame func(FrameView)
}
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
return &connectFrameDecoder{
messageType: messageType,
codec: strings.TrimSpace(codec),
maxFrames: maxFrames,
onFrame: onFrame,
}
}
func (decoder *connectFrameDecoder) Write(payload []byte) {
if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
return
}
decoder.buffer = append(decoder.buffer, payload...)
for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
flags := decoder.buffer[0]
length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
if length < 0 || length > maxConnectFrameBytes {
decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
decoder.buffer = nil
return
}
if len(decoder.buffer) < 5+length {
return
}
framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
decoder.buffer = decoder.buffer[5+length:]
decoder.emit(decoder.decode(flags, framePayload))
}
}
func (decoder *connectFrameDecoder) Close() {
if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
decoder.emit(FrameView{
Length: len(decoder.buffer),
RawHex: clippedHex(decoder.buffer, 4096),
Error: "流结束时仍有不完整的 Connect 帧",
})
}
decoder.buffer = nil
}
func (decoder *connectFrameDecoder) emit(frame FrameView) {
frame.Index = decoder.frameCount
decoder.frameCount++
if decoder.onFrame != nil {
decoder.onFrame(frame)
}
}
func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
frame := FrameView{
Flags: flags,
Length: len(payload),
Compressed: flags&0x01 != 0,
EndStream: flags&0x02 != 0,
RawHex: clippedHex(payload, 4096),
}
decoded := payload
if frame.Compressed {
var err error
decoded, err = decompressPayload(payload, decoder.codec)
if err != nil {
frame.Error = err.Error()
return frame
}
}
if frame.EndStream {
frame.Kind = "end_stream"
frame.MessageType = "connect.error.v1.EndStreamResponse"
frame.JSON = prettyJSON(decoded)
return frame
}
message := newMessage(decoder.messageType)
if message == nil {
frame.Error = "未知的 protobuf 消息类型"
return frame
}
if err := proto.Unmarshal(decoded, message); err != nil {
frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
return frame
}
frame.MessageType = decoder.messageType
frame.Kind = activeOneofName(message)
if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
}
frame.JSON = marshalProtoJSON(message)
return frame
}
func decompressPayload(payload []byte, codec string) ([]byte, error) {
if codec != "" && !strings.EqualFold(codec, "gzip") {
return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
}
reader, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("gzip 解压失败:%w", err)
}
defer reader.Close()
decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
if err != nil {
return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
}
if len(decoded) > maxConnectFrameBytes {
return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
return decoded, nil
}
func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
var message proto.Message
switch path {
case "/aiserver.v1.BidiService/BidiAppend":
request := &aiserverv1.BidiAppendRequest{}
if err := proto.Unmarshal(payload, request); err != nil {
return "", "", "", err
}
requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
outer := marshalProtoJSON(request)
clientMessage, clientKind, decodeErr := agentprotocol.DecodeAgentClientMessage(request.GetData())
if decodeErr != nil || clientMessage == nil {
return outer, "bidi_append", requestID, decodeErr
}
combined := struct {
BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
AgentClientKind string `json:"agent_client_kind"`
AgentClient json.RawMessage `json:"agent_client_message"`
}{
BidiAppendRequest: json.RawMessage(outer),
AgentClientKind: clientKind,
AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
}
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
return string(formatted), clientKind, requestID, marshalErr
default:
message = nil
}
if message == nil {
return "", "", "", nil
}
return marshalProtoJSON(message), activeOneofName(message), "", nil
}
func newMessage(messageType string) proto.Message {
switch messageType {
case "aiserver.v1.BidiRequestId":
return &aiserverv1.BidiRequestId{}
case "agent.v1.AgentServerMessage":
return &agentv1.AgentServerMessage{}
default:
return nil
}
}
func marshalProtoJSON(message proto.Message) string {
if message == nil {
return ""
}
payload, err := (protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: false,
Indent: " ",
}).Marshal(message)
if err != nil {
return ""
}
return string(payload)
}
func activeOneofName(message proto.Message) string {
if message == nil {
return ""
}
reflected := message.ProtoReflect()
oneofs := reflected.Descriptor().Oneofs()
for index := 0; index < oneofs.Len(); index++ {
oneof := oneofs.Get(index)
field := reflected.WhichOneof(oneof)
if field != nil {
return string(field.Name())
}
}
return string(reflected.Descriptor().Name())
}
func prettyJSON(payload []byte) string {
var target any
if err := json.Unmarshal(payload, &target); err != nil {
return string(payload)
}
formatted, err := json.MarshalIndent(target, "", " ")
if err != nil {
return string(payload)
}
return string(formatted)
}
func clippedHex(payload []byte, max int) string {
if len(payload) > max {
return hex.EncodeToString(payload[:max]) + "..."
}
return hex.EncodeToString(payload)
}
+421
View File
@@ -0,0 +1,421 @@
package proxydebugger
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"cursor/internal/certs"
"github.com/elazarl/goproxy"
)
type exchangeContext struct {
id string
}
// Server runs the HTTPS debugging proxy and its local web UI.
type Server struct {
config Config
certManager *certs.Manager
store *exchangeStore
counter atomic.Uint64
proxyServer *http.Server
uiServer *http.Server
proxyLn net.Listener
uiLn net.Listener
runMu sync.Mutex
}
// New creates a standalone Cursor protocol debugger.
func New(config Config) (*Server, error) {
config = config.normalized()
if err := validateLoopbackAddress(config.UIAddr); err != nil {
return nil, err
}
manager, err := certs.NewEmbeddedManager()
if err != nil {
return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
}
server := &Server{
config: config,
certManager: manager,
store: newExchangeStore(config.MaxExchanges),
}
proxyHandler, err := server.newProxyHandler()
if err != nil {
return nil, err
}
server.proxyServer = &http.Server{
Handler: proxyHandler,
ErrorLog: log.New(io.Discard, "", 0),
}
server.uiServer = &http.Server{Handler: server.newUIHandler()}
return server, nil
}
// Start starts both listeners without modifying Cursor or system proxy settings.
func (server *Server) Start() error {
server.runMu.Lock()
defer server.runMu.Unlock()
if server.proxyLn != nil || server.uiLn != nil {
return errors.New("调试代理已经启动")
}
proxyListener, err := net.Listen("tcp", server.config.ProxyAddr)
if err != nil {
return fmt.Errorf("启动代理监听失败:%w", err)
}
uiListener, err := net.Listen("tcp", server.config.UIAddr)
if err != nil {
_ = proxyListener.Close()
return fmt.Errorf("启动调试界面失败:%w", err)
}
server.proxyLn = proxyListener
server.uiLn = uiListener
go func() { _ = server.proxyServer.Serve(proxyListener) }()
go func() { _ = server.uiServer.Serve(uiListener) }()
return nil
}
// Close stops both listeners.
func (server *Server) Close(ctx context.Context) error {
server.runMu.Lock()
proxyServer := server.proxyServer
uiServer := server.uiServer
server.proxyLn = nil
server.uiLn = nil
server.runMu.Unlock()
var errorsList []error
if proxyServer != nil {
if err := proxyServer.Shutdown(ctx); err != nil {
errorsList = append(errorsList, err)
}
}
if uiServer != nil {
if err := uiServer.Shutdown(ctx); err != nil {
errorsList = append(errorsList, err)
}
}
return errors.Join(errorsList...)
}
func (server *Server) ProxyAddr() string { return server.config.ProxyAddr }
func (server *Server) UIAddr() string { return server.config.UIAddr }
func (server *Server) UIURL() string { return "http://" + browserAddress(server.config.UIAddr) }
func (server *Server) newProxyHandler() (*goproxy.ProxyHttpServer, error) {
proxy := goproxy.NewProxyHttpServer()
proxy.Verbose = false
proxy.AllowHTTP2 = true
proxy.Logger = log.New(io.Discard, "", 0)
proxy.ConnectionErrHandler = func(_ io.Writer, context *goproxy.ProxyCtx, connectionErr error) {
id := exchangeID(context)
if id == "" {
return
}
server.store.update(id, func(exchange *Exchange) {
exchange.State = "error"
exchange.Error = connectionErr.Error()
exchange.DurationMS = elapsedMS(exchange.StartedAt)
})
}
proxy.Tr = &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 200,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
caCertificate, err := server.certManager.CATLSCertificate()
if err != nil {
return nil, fmt.Errorf("读取 MITM CA 失败:%w", err)
}
baseTLSConfig := goproxy.TLSConfigFromCA(caCertificate)
mitmAction := &goproxy.ConnectAction{
Action: goproxy.ConnectMitm,
TLSConfig: func(host string, context *goproxy.ProxyCtx) (*tls.Config, error) {
return baseTLSConfig(host, context)
},
}
proxy.OnRequest().HandleConnectFunc(func(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
if server.matchesTargetHost(host) {
return mitmAction, host
}
return goproxy.OkConnect, host
})
proxy.OnRequest().DoFunc(server.captureRequest)
proxy.OnResponse().DoFunc(server.captureResponse)
return proxy, nil
}
func (server *Server) captureRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) {
if request == nil || request.Method == http.MethodConnect || !server.matchesHTTPRequest(request) {
return request, nil
}
id := strconv.FormatUint(server.counter.Add(1), 10)
path := request.URL.Path
requestCodec := requestContentCodec(path, request.Header)
exchange := &Exchange{
ExchangeSummary: ExchangeSummary{
ID: id,
StartedAt: time.Now(),
Method: request.Method,
URL: request.URL.String(),
Host: request.URL.Host,
Path: path,
State: "pending",
},
Request: Payload{
Headers: sortedHeaders(request.Header),
ContentType: request.Header.Get("Content-Type"),
ContentCodec: requestCodec,
Frames: make([]FrameView, 0),
},
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
}
server.store.create(exchange)
context.UserData = exchangeContext{id: id}
if request.Body == nil {
server.finishRequestBody(id, path, requestCodec, nil, 0, false, nil)
return request, nil
}
var frameDecoder *connectFrameDecoder
if path == "/agent.v1.AgentService/RunSSE" {
frameDecoder = newConnectFrameDecoder(
"aiserver.v1.BidiRequestId",
requestCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendRequestFrame(id, frame) },
)
}
request.Body = newCaptureReadCloser(
request.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishRequestBody(id, path, requestCodec, captured, size, truncated, readErr)
},
)
return request, nil
}
func (server *Server) captureResponse(response *http.Response, context *goproxy.ProxyCtx) *http.Response {
id := exchangeID(context)
if id == "" || response == nil {
return response
}
server.store.update(id, func(exchange *Exchange) {
exchange.Status = response.StatusCode
exchange.State = "streaming"
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.Response.Headers = sortedHeaders(response.Header)
exchange.Response.ContentType = response.Header.Get("Content-Type")
exchange.Response.ContentCodec = responseContentCodec(response.Header)
})
if response.Body == nil {
server.finishResponseBody(id, nil, 0, false, nil)
return response
}
path := ""
if response.Request != nil && response.Request.URL != nil {
path = response.Request.URL.Path
}
var frameDecoder *connectFrameDecoder
if path == "/agent.v1.AgentService/RunSSE" {
frameDecoder = newConnectFrameDecoder(
"agent.v1.AgentServerMessage",
response.Header.Get("Connect-Content-Encoding"),
server.config.MaxFrames,
func(frame FrameView) { server.appendResponseFrame(id, frame) },
)
}
response.Body = newCaptureReadCloser(
response.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishResponseBody(id, captured, size, truncated, readErr)
},
)
return response
}
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
if path == "/aiserver.v1.BidiService/BidiAppend" && truncated {
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
} else if path == "/aiserver.v1.BidiService/BidiAppend" && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
if decodeErr == nil {
decodedJSON, kind, requestID, decodeErr = decodeUnary(path, decodePayload)
}
server.store.update(id, func(exchange *Exchange) {
exchange.RequestBytes = size
exchange.Request.Size = size
exchange.Request.RawHex = rawHex(captured)
exchange.Request.RawTruncated = truncated
if decodedJSON != "" {
exchange.Request.DecodedJSON = decodedJSON
}
if kind != "" {
exchange.RequestKind = kind
}
if requestID != "" {
exchange.RequestID = requestID
}
if decodeErr != nil {
exchange.Request.DecodeError = decodeErr.Error()
}
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.Error = readErr.Error()
}
})
}
func requestContentCodec(path string, headers http.Header) string {
if path == "/agent.v1.AgentService/RunSSE" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
func responseContentCodec(headers http.Header) string {
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
return codec
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
func (server *Server) finishResponseBody(id string, captured []byte, size int64, truncated bool, readErr error) {
server.store.update(id, func(exchange *Exchange) {
exchange.ResponseBytes = size
exchange.Response.Size = size
exchange.Response.RawHex = rawHex(captured)
exchange.Response.RawTruncated = truncated
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.State = "completed"
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.State = "error"
exchange.Error = readErr.Error()
}
})
}
func (server *Server) appendRequestFrame(id string, frame FrameView) {
server.store.update(id, func(exchange *Exchange) {
if len(exchange.Request.Frames) < server.config.MaxFrames {
exchange.Request.Frames = append(exchange.Request.Frames, frame)
}
if frame.Kind != "" {
exchange.RequestKind = frame.Kind
}
if frame.RequestID != "" {
exchange.RequestID = frame.RequestID
}
})
}
func (server *Server) appendResponseFrame(id string, frame FrameView) {
server.store.update(id, func(exchange *Exchange) {
if len(exchange.Response.Frames) < server.config.MaxFrames {
exchange.Response.Frames = append(exchange.Response.Frames, frame)
}
exchange.FrameCount = len(exchange.Response.Frames)
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.ResponseKind = frame.Kind
}
if frame.Error != "" {
exchange.Response.DecodeError = frame.Error
}
})
}
func (server *Server) matchesHTTPRequest(request *http.Request) bool {
if request == nil {
return false
}
host := request.Host
if request.URL != nil && request.URL.Host != "" {
host = request.URL.Host
}
return server.matchesTargetHost(host)
}
func (server *Server) matchesTargetHost(host string) bool {
host = strings.TrimSpace(strings.ToLower(host))
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
target := strings.TrimSpace(strings.ToLower(server.config.TargetHost))
return host == target
}
func exchangeID(context *goproxy.ProxyCtx) string {
if context == nil {
return ""
}
value, ok := context.UserData.(exchangeContext)
if !ok {
return ""
}
return value.id
}
func browserAddress(address string) string {
host, port, err := net.SplitHostPort(address)
if err != nil {
return address
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
func validateLoopbackAddress(address string) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("调试界面监听地址无效:%w", err)
}
if strings.EqualFold(host, "localhost") {
return nil
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return errors.New("调试界面只能监听本机回环地址")
}
return nil
}
+162
View File
@@ -0,0 +1,162 @@
package proxydebugger
import (
"sort"
"sync"
"time"
)
type exchangeStore struct {
mu sync.RWMutex
max int
order []string
exchanges map[string]*Exchange
subscribers map[chan storeEvent]struct{}
}
func newExchangeStore(max int) *exchangeStore {
return &exchangeStore{
max: max,
exchanges: make(map[string]*Exchange),
subscribers: make(map[chan storeEvent]struct{}),
}
}
func (store *exchangeStore) create(exchange *Exchange) {
store.mu.Lock()
store.exchanges[exchange.ID] = exchange
store.order = append([]string{exchange.ID}, store.order...)
for len(store.order) > store.max {
oldest := store.order[len(store.order)-1]
store.order = store.order[:len(store.order)-1]
delete(store.exchanges, oldest)
}
store.mu.Unlock()
store.publish(storeEvent{Type: "created", ID: exchange.ID})
}
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
store.mu.Lock()
if exchange := store.exchanges[id]; exchange != nil {
apply(exchange)
}
store.mu.Unlock()
store.publish(storeEvent{Type: "updated", ID: id})
}
func (store *exchangeStore) summaries() []ExchangeSummary {
store.mu.RLock()
defer store.mu.RUnlock()
result := make([]ExchangeSummary, 0, len(store.order))
for _, id := range store.order {
if exchange := store.exchanges[id]; exchange != nil {
result = append(result, exchange.ExchangeSummary)
}
}
return result
}
func (store *exchangeStore) get(id string) (Exchange, bool) {
store.mu.RLock()
defer store.mu.RUnlock()
exchange := store.exchanges[id]
if exchange == nil {
return Exchange{}, false
}
return cloneExchange(*exchange), true
}
func (store *exchangeStore) clear() {
store.mu.Lock()
store.order = nil
store.exchanges = make(map[string]*Exchange)
store.mu.Unlock()
store.publish(storeEvent{Type: "cleared"})
}
func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
updates := make(chan storeEvent, 32)
store.mu.Lock()
store.subscribers[updates] = struct{}{}
store.mu.Unlock()
return updates, func() {
store.mu.Lock()
if _, ok := store.subscribers[updates]; ok {
delete(store.subscribers, updates)
close(updates)
}
store.mu.Unlock()
}
}
func (store *exchangeStore) publish(event storeEvent) {
store.mu.RLock()
defer store.mu.RUnlock()
for subscriber := range store.subscribers {
select {
case subscriber <- event:
default:
}
}
}
func cloneExchange(exchange Exchange) Exchange {
exchange.Request = clonePayload(exchange.Request)
exchange.Response = clonePayload(exchange.Response)
return exchange
}
func clonePayload(payload Payload) Payload {
payload.Headers = append([]Header(nil), payload.Headers...)
payload.Frames = append([]FrameView(nil), payload.Frames...)
return payload
}
func elapsedMS(startedAt time.Time) int64 {
if startedAt.IsZero() {
return 0
}
return time.Since(startedAt).Milliseconds()
}
func sortedHeaders(headers map[string][]string) []Header {
result := make([]Header, 0, len(headers))
for name, values := range headers {
value := ""
for index, item := range values {
if index > 0 {
value += ", "
}
value += item
}
if isSensitiveHeader(name) && value != "" {
value = "[已隐藏]"
}
result = append(result, Header{Name: name, Value: value})
}
sort.Slice(result, func(left, right int) bool {
return result[left].Name < result[right].Name
})
return result
}
func isSensitiveHeader(name string) bool {
switch httpCanonicalLower(name) {
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
return true
default:
return false
}
}
func httpCanonicalLower(value string) string {
buffer := make([]byte, len(value))
for index := range value {
character := value[index]
if character >= 'A' && character <= 'Z' {
character += 'a' - 'A'
}
buffer[index] = character
}
return string(buffer)
}
+110
View File
@@ -0,0 +1,110 @@
package proxydebugger
import "time"
const (
defaultProxyAddr = "127.0.0.1:9090"
defaultUIAddr = "127.0.0.1:9091"
defaultTargetHost = "api2.cursor.sh"
defaultMaxExchanges = 200
defaultMaxCaptureBytes = 2 << 20
defaultMaxFrames = 2000
)
// Config controls the standalone proxy debugger.
type Config struct {
ProxyAddr string
UIAddr string
TargetHost string
MaxExchanges int
MaxCaptureBytes int
MaxFrames int
}
func (config Config) normalized() Config {
if config.ProxyAddr == "" {
config.ProxyAddr = defaultProxyAddr
}
if config.UIAddr == "" {
config.UIAddr = defaultUIAddr
}
if config.TargetHost == "" {
config.TargetHost = defaultTargetHost
}
if config.MaxExchanges <= 0 {
config.MaxExchanges = defaultMaxExchanges
}
if config.MaxCaptureBytes <= 0 {
config.MaxCaptureBytes = defaultMaxCaptureBytes
}
if config.MaxFrames <= 0 {
config.MaxFrames = defaultMaxFrames
}
return config
}
// ExchangeSummary is the compact request-list representation.
type ExchangeSummary struct {
ID string `json:"id"`
StartedAt time.Time `json:"startedAt"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
Path string `json:"path"`
Status int `json:"status"`
State string `json:"state"`
DurationMS int64 `json:"durationMs"`
RequestBytes int64 `json:"requestBytes"`
ResponseBytes int64 `json:"responseBytes"`
RequestID string `json:"requestId,omitempty"`
RequestKind string `json:"requestKind,omitempty"`
ResponseKind string `json:"responseKind,omitempty"`
FrameCount int `json:"frameCount"`
Error string `json:"error,omitempty"`
}
// Exchange contains the request and response detail shown by the debugger.
type Exchange struct {
ExchangeSummary
Request Payload `json:"request"`
Response Payload `json:"response"`
}
// Payload contains headers, captured raw bytes, and decoded protobuf frames.
type Payload struct {
Headers []Header `json:"headers"`
ContentType string `json:"contentType,omitempty"`
ContentCodec string `json:"contentCodec,omitempty"`
Size int64 `json:"size"`
RawHex string `json:"rawHex,omitempty"`
RawTruncated bool `json:"rawTruncated,omitempty"`
DecodedJSON string `json:"decodedJson,omitempty"`
DecodeError string `json:"decodeError,omitempty"`
Frames []FrameView `json:"frames,omitempty"`
}
// Header is a stable, sorted HTTP header pair.
type Header struct {
Name string `json:"name"`
Value string `json:"value"`
}
// FrameView describes one Connect streaming envelope.
type FrameView struct {
Index int `json:"index"`
Flags uint8 `json:"flags"`
Length int `json:"length"`
Compressed bool `json:"compressed"`
EndStream bool `json:"endStream"`
Kind string `json:"kind,omitempty"`
MessageType string `json:"messageType,omitempty"`
RequestID string `json:"requestId,omitempty"`
JSON string `json:"json,omitempty"`
RawHex string `json:"rawHex,omitempty"`
Error string `json:"error,omitempty"`
}
type storeEvent struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
+112
View File
@@ -0,0 +1,112 @@
package proxydebugger
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"strings"
"time"
"cursor/internal/certs"
)
//go:embed web/*
var webAssets embed.FS
func (server *Server) newUIHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/status", server.handleStatus)
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
mux.HandleFunc("GET /api/events", server.handleEvents)
mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
assets, _ := fs.Sub(webAssets, "web")
fileServer := http.FileServer(http.FS(assets))
mux.Handle("/", fileServer)
return securityHeaders(mux)
}
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, map[string]any{
"proxyAddr": server.config.ProxyAddr,
"uiAddr": server.config.UIAddr,
"targetHost": server.config.TargetHost,
"running": true,
})
}
func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, server.store.summaries())
}
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
id := strings.TrimSpace(request.PathValue("id"))
exchange, ok := server.store.get(id)
if !ok {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
return
}
writeJSON(writer, http.StatusOK, exchange)
}
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
server.store.clear()
writer.WriteHeader(http.StatusNoContent)
}
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
flusher, ok := writer.(http.Flusher)
if !ok {
http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
updates, unsubscribe := server.store.subscribe()
defer unsubscribe()
fmt.Fprint(writer, "event: ready\ndata: {}\n\n")
flusher.Flush()
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-request.Context().Done():
return
case event, open := <-updates:
if !open {
return
}
payload, _ := json.Marshal(event)
fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload)
flusher.Flush()
case <-heartbeat.C:
fmt.Fprint(writer, ": heartbeat\n\n")
flusher.Flush()
}
}
}
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())
}
func writeJSON(writer http.ResponseWriter, status int, payload any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(payload)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
next.ServeHTTP(writer, request)
})
}
+452
View File
@@ -0,0 +1,452 @@
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
const state = {
status: null,
exchanges: [],
selectedId: null,
selected: null,
search: "",
requestId: "",
endpoint: "all",
sortOrder: "desc",
paused: false,
pendingRefresh: false,
connection: { connected: false, key: "status.connecting", values: {} },
tabs: {
request: "body",
response: "frames",
},
};
const elements = {
statusDot: document.querySelector("#status-dot"),
statusText: document.querySelector("#status-text"),
proxyAddress: document.querySelector("#proxy-address"),
targetHost: document.querySelector("#target-host"),
connectionLabel: document.querySelector("#connection-label"),
trafficSummary: document.querySelector("#traffic-summary"),
searchInput: document.querySelector("#search-input"),
requestIdInput: document.querySelector("#request-id-input"),
endpointFilter: document.querySelector("#endpoint-filter"),
sortOrder: document.querySelector("#sort-order"),
requestCount: document.querySelector("#request-count"),
requestList: document.querySelector("#request-list"),
emptyState: document.querySelector("#empty-state"),
selectionSummary: document.querySelector("#selection-summary"),
requestContent: document.querySelector("#request-content"),
responseContent: document.querySelector("#response-content"),
pauseButton: document.querySelector("#pause-button"),
clearButton: document.querySelector("#clear-button"),
localeSelect: document.querySelector("#locale-select"),
workspace: document.querySelector("#workspace"),
splitter: document.querySelector("#horizontal-splitter"),
};
async function fetchJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
if (response.status === 204) return null;
return response.json();
}
async function loadStatus() {
state.status = await fetchJSON("/api/status");
elements.statusDot.classList.toggle("online", Boolean(state.status.running));
renderRuntimeStatus();
elements.proxyAddress.textContent = `http://${state.status.proxyAddr}`;
elements.targetHost.textContent = state.status.targetHost;
}
async function refreshList() {
state.exchanges = await fetchJSON("/api/exchanges");
renderList();
renderTrafficSummary();
if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
await refreshDetail(state.selectedId);
} else if (state.selectedId) {
state.selectedId = null;
state.selected = null;
renderDetail();
}
}
async function refreshDetail(id) {
if (!id) return;
try {
const detail = await fetchJSON(`/api/exchanges/${encodeURIComponent(id)}`);
if (state.selectedId !== id) return;
state.selected = detail;
renderDetail();
} catch (error) {
if (state.selectedId === id) {
state.selected = null;
renderDetailError(error);
}
}
}
function scheduleRefresh() {
if (state.paused) {
state.pendingRefresh = true;
return;
}
if (state.pendingRefresh) return;
state.pendingRefresh = true;
window.setTimeout(async () => {
state.pendingRefresh = false;
try {
await refreshList();
} catch (error) {
setConnectionState(false, "connection.refreshFailed", { message: error.message });
}
}, 90);
}
function connectEvents() {
const events = new EventSource("/api/events");
events.addEventListener("open", () => setConnectionState(true, "connection.live"));
events.addEventListener("update", scheduleRefresh);
events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
}
function setConnectionState(connected, key, values = {}) {
state.connection = { connected, key, values };
renderConnectionState();
}
function renderRuntimeStatus() {
if (!state.status) {
elements.statusText.textContent = t("status.connecting");
return;
}
elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
}
function renderConnectionState() {
const { connected, key, values } = state.connection;
elements.connectionLabel.textContent = t(key, values);
elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
}
function filteredExchanges() {
const query = state.search.trim().toLowerCase();
const requestId = state.requestId.trim().toLowerCase();
const direction = state.sortOrder === "asc" ? 1 : -1;
return state.exchanges
.filter((item) => {
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
if (!query) return true;
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
})
.sort((left, right) => {
const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime();
if (startedAtDelta !== 0) return startedAtDelta * direction;
return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction;
});
}
function renderList() {
const exchanges = filteredExchanges();
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
elements.requestList.innerHTML = exchanges
.map((item) => {
const selected = item.id === state.selectedId ? " selected" : "";
const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
const kind = item.responseKind || item.requestKind || "-";
return `<tr class="${selected.trim()}" data-id="${escapeHTML(item.id)}">
<td><span class="row-state ${escapeHTML(item.state)}"></span></td>
<td><code>${escapeHTML(item.id)}</code></td>
<td title="${escapeHTML(item.url)}"><code>${escapeHTML(item.url)}</code></td>
<td title="${escapeHTML(item.requestId || "")}"><code class="request-id-text">${escapeHTML(item.requestId || "-")}</code></td>
<td><span class="kind-text">${escapeHTML(kind)}</span></td>
<td><span class="method-text">${escapeHTML(item.method)}</span></td>
<td><span class="status-text ${statusClass}">${item.status || "-"}</span></td>
<td>${formatBytes(item.responseBytes)}</td>
<td>${formatDuration(item.durationMs)}</td>
</tr>`;
})
.join("");
}
function renderTrafficSummary() {
const totals = state.exchanges.reduce(
(result, item) => {
result.up += item.requestBytes || 0;
result.down += item.responseBytes || 0;
return result;
},
{ up: 0, down: 0 },
);
elements.trafficSummary.textContent = `${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
}
function renderDetail() {
if (!state.selected) {
elements.selectionSummary.innerHTML = `<span class="method-badge">POST</span><span class="status-badge">${escapeHTML(t("selection.waiting"))}</span><code>${escapeHTML(t("selection.prompt"))}</code>`;
elements.requestContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noRequest"))}</div>`;
elements.responseContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noResponse"))}</div>`;
return;
}
const item = state.selected;
const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
elements.selectionSummary.innerHTML = `<span class="method-badge">${escapeHTML(item.method)}</span><span class="status-badge ${statusClass}">${escapeHTML(item.status || formatState(item.state))}</span><code>${escapeHTML(item.url)}</code>`;
elements.requestContent.innerHTML = renderPayload(item.request, state.tabs.request);
elements.responseContent.innerHTML = renderPayload(item.response, state.tabs.response);
}
function renderDetailError(error) {
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
}
function renderPayload(payload, tab) {
if (!payload) return `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`;
if (tab === "headers") return renderHeaders(payload.headers);
if (tab === "frames") return renderFrames(payload.frames);
if (tab === "raw") {
const body = payload.rawHex ? formatHex(payload.rawHex) : t("notices.noRaw");
return `<pre class="hex-view">${escapeHTML(body)}</pre>${renderTruncated(payload.rawTruncated)}`;
}
if (payload.decodedJson) {
return `<pre class="code-view">${escapeHTML(payload.decodedJson)}</pre>${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`;
}
if (payload.frames?.length) return renderFrames(payload.frames);
if (payload.decodeError) return `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`;
return `<div class="notice">${escapeHTML(t("notices.noBody"))}</div>`;
}
function renderHeaders(headers = []) {
const items = Array.isArray(headers) ? headers : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noHeaders"))}</div>`;
return `<table class="headers-table"><tbody>${items
.map((header) => `<tr><th>${escapeHTML(header.name)}</th><td>${escapeHTML(header.value)}</td></tr>`)
.join("")}</tbody></table>`;
}
function renderFrames(frames = []) {
const items = Array.isArray(frames) ? frames : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`;
return `<div class="frame-list">${items
.map((frame) => {
const kind = frame.kind || frame.messageType || t("notices.unknown");
const flags = `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`;
const content = frame.json
? `<pre class="code-view">${escapeHTML(frame.json)}</pre>`
: `<pre class="hex-view">${escapeHTML(formatHex(frame.rawHex || ""))}</pre>`;
return `<details class="frame-item"${frame.index === items.length - 1 ? " open" : ""}>
<summary>
<span class="frame-index">#${frame.index}</span>
<span class="frame-kind" title="${escapeHTML(kind)}">${escapeHTML(kind)}</span>
<span class="frame-size">${formatBytes(frame.length)}</span>
<span class="frame-flags">${flags}${frame.compressed ? " gzip" : ""}</span>
</summary>
${frame.error ? `<div class="frame-error">${escapeHTML(frame.error)}</div>` : content}
</details>`;
})
.join("")}</div>`;
}
function renderDecodeError(error) {
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
}
function renderTruncated(truncated) {
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
}
function formatState(value) {
const key = {
pending: "state.pending",
streaming: "state.streaming",
completed: "state.completed",
error: "state.error",
}[value];
return key ? t(key) : value || "-";
}
function currentCopyText(side) {
const payload = state.selected?.[side];
if (!payload) return "";
const tab = state.tabs[side];
if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
if (tab === "raw") return payload.rawHex || "";
if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
return payload.decodedJson || "";
}
function formatHex(value) {
const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
const lines = [];
for (let index = 0; index < hex.length; index += 32) {
const chunk = hex.slice(index, index + 32);
const bytes = chunk.match(/.{1,2}/g) || [];
lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
}
return lines.join("\n");
}
function formatBytes(value) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDuration(value) {
const milliseconds = Number(value || 0);
if (milliseconds < 1000) return `${milliseconds} ms`;
return `${(milliseconds / 1000).toFixed(1)} s`;
}
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
elements.requestList.addEventListener("click", async (event) => {
const row = event.target.closest("tr[data-id]");
if (!row) return;
state.selectedId = row.dataset.id;
state.selected = null;
renderList();
renderDetail();
await refreshDetail(state.selectedId);
});
elements.searchInput.addEventListener("input", (event) => {
state.search = event.target.value;
renderList();
});
elements.requestIdInput.addEventListener("input", (event) => {
state.requestId = event.target.value;
renderList();
});
elements.endpointFilter.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.endpoint = button.dataset.value;
for (const item of elements.endpointFilter.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
elements.sortOrder.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.sortOrder = button.dataset.value;
for (const item of elements.sortOrder.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
document.querySelectorAll(".payload-panel").forEach((panel) => {
panel.querySelector(".tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (!button) return;
const side = panel.dataset.side;
state.tabs[side] = button.dataset.tab;
panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
renderDetail();
});
});
document.querySelectorAll("[data-copy-side]").forEach((button) => {
button.addEventListener("click", async () => {
const text = currentCopyText(button.dataset.copySide);
if (!text) return;
await navigator.clipboard.writeText(text);
button.textContent = t("actions.copied");
window.setTimeout(() => {
button.textContent = t("actions.copy");
}, 900);
});
});
function renderPauseState() {
elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
const actionKey = state.paused ? "actions.resume" : "actions.pause";
elements.pauseButton.title = t(actionKey);
elements.pauseButton.setAttribute("aria-label", t(actionKey));
}
elements.pauseButton.addEventListener("click", async () => {
state.paused = !state.paused;
elements.pauseButton.classList.toggle("active", state.paused);
renderPauseState();
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
if (!state.paused && state.pendingRefresh) {
state.pendingRefresh = false;
await refreshList();
}
});
function applyLocale() {
translateDocument();
elements.localeSelect.value = getLocale();
renderRuntimeStatus();
renderConnectionState();
renderPauseState();
renderList();
renderTrafficSummary();
renderDetail();
}
elements.localeSelect.addEventListener("change", (event) => {
setLocale(event.target.value);
applyLocale();
});
elements.clearButton.addEventListener("click", async () => {
await fetchJSON("/api/exchanges", { method: "DELETE" });
state.selectedId = null;
state.selected = null;
await refreshList();
renderDetail();
});
let draggingSplitter = false;
elements.splitter.addEventListener("pointerdown", (event) => {
draggingSplitter = true;
elements.splitter.classList.add("dragging");
elements.splitter.setPointerCapture(event.pointerId);
});
elements.splitter.addEventListener("pointermove", (event) => {
if (!draggingSplitter) return;
const bounds = elements.workspace.getBoundingClientRect();
const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
});
elements.splitter.addEventListener("pointerup", () => {
draggingSplitter = false;
elements.splitter.classList.remove("dragging");
});
async function bootstrap() {
applyLocale();
renderDetail();
try {
await Promise.all([loadStatus(), refreshList()]);
connectEvents();
} catch (error) {
setConnectionState(false, "connection.connectFailed", { message: error.message });
}
}
void bootstrap();
+178
View File
@@ -0,0 +1,178 @@
const SOURCE_LOCALE = "zh-CN";
const DEFAULT_LOCALE = "en-US";
const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
const SUPPORTED_LOCALES = [SOURCE_LOCALE, DEFAULT_LOCALE];
const messages = {
"zh-CN": {
"app.title": "Cursor 协议调试器",
"status.connecting": "正在连接",
"status.running": "代理运行中",
"status.stopped": "代理已停止",
"actions.downloadCA": "下载代理 CA 证书",
"actions.caCertificate": "CA 证书",
"actions.pause": "暂停界面更新",
"actions.resume": "继续界面更新",
"actions.clear": "清空",
"actions.copy": "复制",
"actions.copied": "已复制",
"language.label": "界面语言",
"filters.region": "请求过滤器",
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
"filters.requestIdPlaceholder": "按 Request ID 过滤",
"filters.endpoint": "接口过滤",
"filters.all": "全部",
"filters.sort": "排序方向",
"filters.ascending": "正序",
"filters.descending": "倒序",
"count.requests": "{count} 条",
"table.url": "网址",
"table.message": "消息",
"table.method": "方法",
"table.status": "状态",
"table.response": "响应",
"table.duration": "耗时",
"empty.waitingForCursor": "等待来自 Cursor 的请求",
"splitter.resize": "调整详情区域高度",
"selection.waiting": "等待选择",
"selection.prompt": "选择一条请求查看详情",
"panel.request": "请求",
"panel.response": "响应",
"panel.requestDetails": "请求详情",
"panel.responseDetails": "响应详情",
"tabs.headers": "标头",
"tabs.body": "正文",
"tabs.frames": "帧",
"tabs.raw": "原始",
"notices.noRequest": "暂无请求内容",
"notices.noResponse": "暂无响应内容",
"notices.noContent": "暂无内容",
"notices.noRaw": "暂无原始数据",
"notices.noBody": "暂无可显示的正文",
"notices.noHeaders": "暂无标头",
"notices.noFrames": "尚未收到完整帧",
"notices.unknown": "未识别",
"notices.truncated": "原始正文已达到本地抓取上限,转发内容未被截断",
"connection.live": "实时连接中",
"connection.retrying": "实时连接正在重试",
"connection.refreshFailed": "刷新失败:{message}",
"connection.paused": "界面更新已暂停",
"connection.connectFailed": "连接失败:{message}",
"state.pending": "等待中",
"state.streaming": "传输中",
"state.completed": "已完成",
"state.error": "错误",
},
"en-US": {
"app.title": "Cursor Protocol Debugger",
"status.connecting": "Connecting",
"status.running": "Proxy running",
"status.stopped": "Proxy stopped",
"actions.downloadCA": "Download proxy CA certificate",
"actions.caCertificate": "CA Certificate",
"actions.pause": "Pause UI updates",
"actions.resume": "Resume UI updates",
"actions.clear": "Clear",
"actions.copy": "Copy",
"actions.copied": "Copied",
"language.label": "Interface language",
"filters.region": "Request filters",
"filters.urlPlaceholder": "Filter by URL, message type, or status",
"filters.requestIdPlaceholder": "Filter by Request ID",
"filters.endpoint": "Endpoint filter",
"filters.all": "All",
"filters.sort": "Sort order",
"filters.ascending": "Oldest first",
"filters.descending": "Newest first",
"count.requests": "{count} requests",
"table.url": "URL",
"table.message": "Message",
"table.method": "Method",
"table.status": "Status",
"table.response": "Response",
"table.duration": "Duration",
"empty.waitingForCursor": "Waiting for requests from Cursor",
"splitter.resize": "Resize details area",
"selection.waiting": "No selection",
"selection.prompt": "Select a request to inspect its details",
"panel.request": "Request",
"panel.response": "Response",
"panel.requestDetails": "Request details",
"panel.responseDetails": "Response details",
"tabs.headers": "Headers",
"tabs.body": "Body",
"tabs.frames": "Frames",
"tabs.raw": "Raw",
"notices.noRequest": "No request content",
"notices.noResponse": "No response content",
"notices.noContent": "No content",
"notices.noRaw": "No raw data",
"notices.noBody": "No body available",
"notices.noHeaders": "No headers",
"notices.noFrames": "No complete frames received yet",
"notices.unknown": "Unknown",
"notices.truncated": "Raw body reached the local capture limit; forwarded data was not truncated",
"connection.live": "Live connection",
"connection.retrying": "Reconnecting live updates",
"connection.refreshFailed": "Refresh failed: {message}",
"connection.paused": "UI updates paused",
"connection.connectFailed": "Connection failed: {message}",
"state.pending": "Pending",
"state.streaming": "Streaming",
"state.completed": "Completed",
"state.error": "Error",
},
};
function matchLocale(locale) {
const normalized = String(locale || "").trim().replaceAll("_", "-").toLowerCase();
if (!normalized) return "";
const exact = SUPPORTED_LOCALES.find((candidate) => candidate.toLowerCase() === normalized);
if (exact) return exact;
return normalized.split("-")[0] === "zh" ? SOURCE_LOCALE : normalized.split("-")[0] === "en" ? DEFAULT_LOCALE : "";
}
function resolveInitialLocale() {
const stored = matchLocale(window.localStorage.getItem(STORAGE_KEY));
if (stored) return stored;
for (const candidate of navigator.languages || [navigator.language]) {
const matched = matchLocale(candidate);
if (matched) return matched;
}
return DEFAULT_LOCALE;
}
let currentLocale = resolveInitialLocale();
export function getLocale() {
return currentLocale;
}
export function t(key, values = {}) {
const template = messages[currentLocale]?.[key] || messages[SOURCE_LOCALE][key] || key;
return template.replace(/\{(\w+)\}/g, (_match, name) => String(values[name] ?? ""));
}
export function translateDocument(root = document) {
document.documentElement.lang = currentLocale;
document.title = t("app.title");
for (const element of root.querySelectorAll("[data-i18n]")) {
element.textContent = t(element.dataset.i18n);
}
for (const [attribute, dataAttribute] of [
["aria-label", "i18nAriaLabel"],
["placeholder", "i18nPlaceholder"],
["title", "i18nTitle"],
]) {
for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) {
element.setAttribute(attribute, t(element.dataset[dataAttribute]));
}
}
}
export function setLocale(locale) {
currentLocale = matchLocale(locale) || DEFAULT_LOCALE;
window.localStorage.setItem(STORAGE_KEY, currentLocale);
translateDocument();
return currentLocale;
}
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Cursor 协议调试器</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="app" class="app-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong data-i18n="app.title">Cursor 协议调试器</strong>
</div>
<div class="runtime-status" aria-live="polite">
<span id="status-dot" class="status-dot"></span>
<span id="status-text" data-i18n="status.connecting">正在连接</span>
<code id="proxy-address"></code>
</div>
<div class="toolbar-actions">
<label class="locale-picker">
<span class="visually-hidden" data-i18n="language.label">界面语言</span>
<select id="locale-select" aria-label="界面语言" title="界面语言" data-i18n-aria-label="language.label" data-i18n-title="language.label">
<option value="zh-CN">中文</option>
<option value="en-US">EN</option>
</select>
</label>
<a class="button secondary" href="/api/ca.crt" title="下载代理 CA 证书" data-i18n="actions.caCertificate" data-i18n-title="actions.downloadCA">CA 证书</a>
<button id="pause-button" class="icon-button" type="button" title="暂停界面更新" aria-label="暂停界面更新" data-i18n-title="actions.pause" data-i18n-aria-label="actions.pause"></button>
<button id="clear-button" class="button danger" type="button" data-i18n="actions.clear">清空</button>
</div>
</header>
<section class="filterbar" aria-label="请求过滤器" data-i18n-aria-label="filters.region">
<div class="search-box url-filter">
<span aria-hidden="true"></span>
<input id="search-input" type="search" placeholder="过滤 URL、请求类型或状态" data-i18n-placeholder="filters.urlPlaceholder" autocomplete="off" />
</div>
<div class="search-box request-id-filter">
<span aria-hidden="true"></span>
<input id="request-id-input" type="search" placeholder="按 Request ID 过滤" data-i18n-placeholder="filters.requestIdPlaceholder" autocomplete="off" />
</div>
<div id="endpoint-filter" class="segmented-control" role="group" aria-label="接口过滤" data-i18n-aria-label="filters.endpoint">
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
<button type="button" data-value="runsse">RunSSE</button>
<button type="button" data-value="bidiappend">BidiAppend</button>
</div>
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
<button class="active" type="button" data-value="desc" data-i18n="filters.descending">倒序</button>
</div>
<span id="request-count" class="request-count">0 条</span>
</section>
<main id="workspace" class="workspace">
<section class="request-list-pane">
<table class="request-table">
<thead>
<tr>
<th class="status-column"></th>
<th class="index-column">#</th>
<th data-i18n="table.url">网址</th>
<th class="request-id-column">Request ID</th>
<th class="kind-column" data-i18n="table.message">消息</th>
<th class="method-column" data-i18n="table.method">方法</th>
<th class="code-column" data-i18n="table.status">状态</th>
<th class="size-column" data-i18n="table.response">响应</th>
<th class="time-column" data-i18n="table.duration">耗时</th>
</tr>
</thead>
<tbody id="request-list"></tbody>
</table>
<div id="empty-state" class="empty-state" data-i18n="empty.waitingForCursor">等待来自 Cursor 的请求</div>
</section>
<div id="horizontal-splitter" class="horizontal-splitter" role="separator" aria-label="调整详情区域高度" data-i18n-aria-label="splitter.resize"></div>
<section id="detail-pane" class="detail-pane">
<div id="selection-summary" class="selection-summary">
<span class="method-badge">POST</span>
<span class="status-badge" data-i18n="selection.waiting">等待选择</span>
<code data-i18n="selection.prompt">选择一条请求查看详情</code>
</div>
<div class="detail-columns">
<section class="payload-panel" data-side="request">
<div class="panel-header">
<strong data-i18n="panel.request">请求</strong>
<nav class="tabs" aria-label="请求详情" data-i18n-aria-label="panel.requestDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="request" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="request-content" class="panel-content"></div>
</section>
<section class="payload-panel" data-side="response">
<div class="panel-header">
<strong data-i18n="panel.response">响应</strong>
<nav class="tabs" aria-label="响应详情" data-i18n-aria-label="panel.responseDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" class="active" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="response" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="response-content" class="panel-content"></div>
</section>
</div>
</section>
</main>
<footer class="statusbar">
<span id="connection-label" data-i18n="connection.live">实时连接中</span>
<span id="traffic-summary">↑ 0 B ↓ 0 B</span>
<span id="target-host"></span>
</footer>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>
+921
View File
@@ -0,0 +1,921 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #171818;
color: #dedfdd;
font-synthesis: none;
--surface-0: #171818;
--surface-1: #1d1f1f;
--surface-2: #242626;
--surface-3: #2c2f2f;
--border: #343737;
--border-strong: #454949;
--muted: #8d9390;
--text: #dedfdd;
--accent: #4ea58b;
--accent-soft: #25473d;
--cyan: #55a8ba;
--orange: #c88762;
--danger: #c56d65;
--selection: #245b73;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--surface-0);
}
button,
input,
select,
a {
font: inherit;
letter-spacing: 0;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: -1px;
}
.app-shell {
display: grid;
grid-template-rows: 48px 46px minmax(0, 1fr) 26px;
min-width: 760px;
background: var(--surface-0);
}
.topbar,
.filterbar,
.statusbar {
display: flex;
align-items: center;
border-color: var(--border);
background: var(--surface-1);
}
.topbar {
justify-content: space-between;
gap: 18px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
.brand,
.runtime-status,
.toolbar-actions {
display: flex;
align-items: center;
min-width: 0;
}
.brand {
gap: 9px;
white-space: nowrap;
}
.brand strong {
font-size: 14px;
font-weight: 650;
}
.brand-mark {
width: 12px;
height: 12px;
border: 2px solid var(--accent);
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--surface-1);
background: var(--accent);
}
.runtime-status {
justify-content: center;
gap: 7px;
min-width: 240px;
color: #bec3c0;
font-size: 12px;
}
.runtime-status code {
overflow: hidden;
max-width: 260px;
color: var(--muted);
font-family: var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7d8380;
}
.status-dot.online {
background: #42bd79;
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
}
.toolbar-actions {
justify-content: flex-end;
gap: 7px;
}
.locale-picker {
display: flex;
}
.locale-picker select {
width: 58px;
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
padding: 0 6px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-size: 12px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.button,
.icon-button,
.copy-button {
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.button {
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
}
.button:hover,
.icon-button:hover,
.copy-button:hover {
background: var(--surface-3);
}
.button.danger:hover {
border-color: #744640;
color: #f2b0aa;
}
.icon-button {
width: 31px;
padding: 0;
font-family: var(--mono);
font-weight: 700;
}
.icon-button.active {
border-color: var(--orange);
color: #f1bb98;
}
.filterbar {
gap: 10px;
padding: 7px 14px;
border-bottom: 1px solid var(--border);
}
.search-box {
display: flex;
align-items: center;
flex: 1;
min-width: 260px;
max-width: 640px;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
background: #191b1b;
color: var(--muted);
}
.search-box > span {
padding-left: 9px;
font-size: 17px;
}
.search-box input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
padding: 0 9px;
outline: 0;
background: transparent;
color: var(--text);
font-size: 12px;
}
.search-box input::placeholder {
color: #6f7572;
}
.url-filter {
min-width: 280px;
max-width: 420px;
}
.request-id-filter {
flex: 0 1 320px;
min-width: 220px;
max-width: 340px;
}
.segmented-control {
display: flex;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.segmented-control button {
min-width: 62px;
border: 0;
border-right: 1px solid var(--border);
padding: 0 10px;
background: #1b1d1d;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.segmented-control button:last-child {
border-right: 0;
}
.segmented-control button.active {
background: var(--accent-soft);
color: #bce8d9;
}
.sort-control button {
min-width: 52px;
}
.request-count {
margin-left: auto;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.workspace {
display: grid;
grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
min-height: 0;
overflow: hidden;
}
.request-list-pane {
position: relative;
min-height: 0;
overflow: auto;
background: #181a1a;
}
.request-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
}
.request-table thead {
position: sticky;
top: 0;
z-index: 2;
background: #202222;
}
.request-table th,
.request-table td {
height: 30px;
border-right: 1px solid #2c2f2f;
border-bottom: 1px solid #292c2c;
padding: 0 9px;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-table th {
color: #9da29f;
font-weight: 550;
}
.request-table tbody tr {
cursor: default;
}
.request-table tbody tr:hover {
background: #222525;
}
.request-table tbody tr.selected {
background: var(--selection);
color: #f3f8f8;
}
.request-table code {
font-family: var(--mono);
}
.status-column {
width: 30px;
}
.index-column {
width: 54px;
}
.request-id-column {
width: 250px;
}
.kind-column {
width: 180px;
}
.method-column {
width: 72px;
}
.code-column {
width: 66px;
}
.size-column {
width: 86px;
}
.time-column {
width: 74px;
}
.row-state {
display: block;
width: 8px;
height: 8px;
margin: auto;
border-radius: 50%;
background: #7c8380;
}
.row-state.streaming {
background: #45ba77;
}
.row-state.completed {
background: var(--cyan);
}
.row-state.error {
background: var(--danger);
}
.method-text {
color: #61b9df;
font-family: var(--mono);
font-weight: 650;
}
.status-text.success {
color: #68c991;
}
.status-text.error {
color: #e18b83;
}
.kind-text {
color: #d3a17f;
font-family: var(--mono);
}
.request-id-text {
color: #8bc2cc;
}
.empty-state {
position: absolute;
inset: 34px 0 0;
display: grid;
place-items: center;
color: #686e6b;
font-size: 13px;
}
.empty-state.hidden {
display: none;
}
.horizontal-splitter {
cursor: row-resize;
background: #343737;
}
.horizontal-splitter:hover,
.horizontal-splitter.dragging {
background: var(--cyan);
}
.detail-pane {
display: grid;
grid-template-rows: 38px minmax(0, 1fr);
min-height: 0;
background: var(--surface-0);
}
.selection-summary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 0 14px;
border-bottom: 1px solid var(--border);
background: #1b1d1d;
}
.selection-summary code {
overflow: hidden;
color: #aeb4b1;
font-family: var(--mono);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-badge,
.status-badge,
.frame-badge {
display: inline-flex;
align-items: center;
height: 22px;
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 0 7px;
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.method-badge {
border-color: #34667a;
color: #74c8e8;
}
.status-badge.success {
border-color: #3f7157;
color: #83d5a5;
}
.detail-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.payload-panel {
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
min-width: 0;
min-height: 0;
border-right: 1px solid var(--border);
}
.payload-panel:last-child {
border-right: 0;
}
.panel-header {
display: flex;
align-items: center;
min-width: 0;
border-bottom: 1px solid var(--border);
background: #202222;
}
.panel-header > strong {
padding: 0 10px;
color: #c9cdca;
font-size: 12px;
}
.tabs {
display: flex;
align-self: stretch;
}
.tabs button {
position: relative;
min-width: 46px;
border: 0;
padding: 0 9px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.tabs button:hover {
color: #d7dad8;
}
.tabs button.active {
color: #71c7e2;
}
.tabs button.active::after {
position: absolute;
right: 8px;
bottom: 0;
left: 8px;
height: 2px;
background: var(--cyan);
content: "";
}
.copy-button {
width: 48px;
height: 24px;
margin-right: 7px;
margin-left: auto;
font-size: 11px;
}
.panel-content {
min-height: 0;
overflow: auto;
background: #181a1a;
}
.code-view,
.hex-view {
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 12px 14px 30px;
color: #ccd1ce;
font: 11px/1.55 var(--mono);
tab-size: 2;
white-space: pre;
}
.hex-view {
color: #b6c2bd;
}
.headers-table {
width: 100%;
border-collapse: collapse;
font: 11px/1.4 var(--mono);
}
.headers-table th,
.headers-table td {
border-bottom: 1px solid #292c2c;
padding: 7px 10px;
text-align: left;
vertical-align: top;
}
.headers-table th {
width: 38%;
color: #62b3cd;
font-weight: 500;
overflow-wrap: anywhere;
}
.headers-table td {
color: #c5c9c6;
overflow-wrap: anywhere;
}
.frame-list {
min-width: 480px;
}
.frame-item {
border-bottom: 1px solid #292c2c;
}
.frame-item summary {
display: grid;
grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
align-items: center;
height: 32px;
padding: 0 10px;
color: #c5cac7;
cursor: pointer;
font: 11px var(--mono);
list-style: none;
}
.frame-item summary::-webkit-details-marker {
display: none;
}
.frame-item summary:hover {
background: #222525;
}
.frame-item[open] summary {
background: #242727;
}
.frame-index {
color: #747b77;
}
.frame-kind {
overflow: hidden;
color: #dfaa85;
text-overflow: ellipsis;
white-space: nowrap;
}
.frame-size,
.frame-flags {
color: #7faeb7;
text-align: right;
}
.frame-error {
margin: 10px 14px;
color: #ec968e;
font: 11px/1.5 var(--mono);
}
.notice {
padding: 14px;
color: #7d8581;
font: 12px/1.6 var(--mono);
}
.notice.error {
color: #df8b83;
}
.truncated-notice {
position: sticky;
bottom: 0;
padding: 5px 10px;
border-top: 1px solid #674f3f;
background: #3d3028;
color: #e5b28e;
font-size: 11px;
}
.statusbar {
justify-content: flex-end;
gap: 16px;
padding: 0 10px;
border-top: 1px solid var(--border);
color: #848b87;
font: 10px var(--mono);
}
.statusbar span:first-child {
margin-right: auto;
}
@media (max-width: 920px) {
.app-shell {
grid-template-rows: 48px 84px minmax(0, 1fr) 26px;
min-width: 0;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
}
.url-filter,
.request-id-filter {
flex: 1 1 300px;
max-width: none;
}
.runtime-status code,
.kind-column,
.request-table td:nth-child(5) {
display: none;
}
.detail-columns {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
overflow: auto;
}
.payload-panel {
min-height: 260px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
}
@media (max-width: 640px) {
.app-shell {
grid-template-rows: 82px 122px minmax(0, 1fr) 26px;
}
.topbar {
position: relative;
align-content: center;
flex-wrap: wrap;
gap: 4px 10px;
padding: 8px 10px;
}
.brand {
flex: 1;
overflow: hidden;
}
.brand strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
}
.runtime-status {
order: 3;
justify-content: flex-start;
width: 100%;
min-width: 0;
}
.runtime-status code {
display: block;
max-width: none;
}
.toolbar-actions {
gap: 4px;
}
.toolbar-actions .button {
padding: 0 7px;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 7px 10px;
}
.url-filter,
.request-id-filter {
flex: 0 0 100%;
width: 100%;
min-width: 0;
max-width: none;
}
.request-count {
order: 5;
margin-left: auto;
}
#endpoint-filter {
order: 3;
flex: 1;
}
.sort-control {
order: 4;
flex: 0 0 104px;
}
.segmented-control button {
flex: 1;
min-width: 0;
}
.workspace {
grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
}
.request-table th,
.request-table td {
padding: 0 6px;
}
.request-table .index-column,
.request-table th:nth-child(2),
.request-table td:nth-child(2),
.size-column,
.request-table th:nth-child(8),
.request-table td:nth-child(8),
.time-column,
.request-table th:nth-child(9),
.request-table td:nth-child(9) {
display: none;
}
.request-id-column {
width: 130px;
}
.method-column {
width: 58px;
}
.code-column {
width: 50px;
}
.selection-summary {
padding: 0 8px;
}
.detail-columns {
grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
}
.panel-header > strong {
width: 72px;
padding: 0 7px;
font-size: 11px;
}
.tabs {
overflow-x: auto;
}
.tabs button {
min-width: 42px;
padding: 0 6px;
}
.copy-button {
width: 42px;
margin-right: 4px;
}
.frame-list {
min-width: 0;
}
.frame-item summary {
grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
padding: 0 7px;
}
.statusbar {
gap: 8px;
}
#target-host {
display: none;
}
}
+3 -1
View File
@@ -1903,11 +1903,13 @@ func openAIThinkingDisableKind(baseURL string, modelID string, endpoint string)
strings.Contains(base, "zhipu") ||
strings.Contains(base, "xiaomimimo") ||
strings.Contains(base, "mimo") ||
strings.Contains(base, "minimax") ||
strings.Contains(model, "deepseek") ||
strings.Contains(model, "glm") ||
strings.Contains(model, "zai") ||
strings.Contains(model, "zhipu") ||
strings.Contains(model, "mimo"):
strings.Contains(model, "mimo") ||
strings.Contains(model, "minimax"):
return "thinking_type"
case openAIModelSupportsReasoningNone(model):
return "reasoning_none"
@@ -0,0 +1,95 @@
package modeladapter
import "testing"
// TestOpenAIThinkingDisableKindMiniMax verifies that the MiniMax OpenAI-compatible
// endpoint is routed to the thinking_type disable branch, so that disabling
// thinking writes thinking:{type:"disabled"} and drops reasoning_effort. This
// covers the global endpoint, the China endpoint and a custom proxy that only
// exposes the MiniMax model id, plus regression cases for the other branches.
func TestOpenAIThinkingDisableKindMiniMax(t *testing.T) {
tests := []struct {
name string
baseURL string
modelID string
endpoint string
want string
}{
// MiniMax global endpoint
{name: "minimax global base + M3", baseURL: "https://api.minimax.io/v1", modelID: "MiniMax-M3", endpoint: "/chat/completions", want: "thinking_type"},
{name: "minimax global base + M2.7", baseURL: "https://api.minimax.io/v1", modelID: "MiniMax-M2.7", endpoint: "/chat/completions", want: "thinking_type"},
// MiniMax China endpoint
{name: "minimax cn base + M3", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M3", endpoint: "/chat/completions", want: "thinking_type"},
{name: "minimax cn base + M2.7", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M2.7", endpoint: "/chat/completions", want: "thinking_type"},
// MiniMax model id only (custom base)
{name: "minimax model only (custom base)", baseURL: "https://custom.proxy.example.com/v1", modelID: "MiniMax-M3", endpoint: "/chat/completions", want: "thinking_type"},
// Regression: enable_thinking branch (qwen) is unaffected
{name: "qwen via dashscope", baseURL: "https://dashscope.aliyuncs.com/v1", modelID: "qwen-max", endpoint: "/chat/completions", want: "enable_thinking"},
// Regression: reasoning_none branch (gpt-5.1+/gpt-6) is unaffected
{name: "gpt-6", baseURL: "https://api.openai.com/v1", modelID: "gpt-6", endpoint: "/chat/completions", want: "reasoning_none"},
// Regression: unknown provider does not disable
{name: "unknown provider", baseURL: "https://api.unknown-llm.com/v1", modelID: "some-model", endpoint: "/chat/completions", want: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := openAIThinkingDisableKind(tc.baseURL, tc.modelID, tc.endpoint)
if got != tc.want {
t.Fatalf("openAIThinkingDisableKind(%q, %q, %q) = %q, want %q", tc.baseURL, tc.modelID, tc.endpoint, got, tc.want)
}
})
}
}
// TestApplyOpenAIThinkingDisableMiniMax verifies that when ThinkingEffort=disabled
// and the provider is MiniMax, applyOpenAIThinkingDisable writes
// thinking:{type:"disabled"} and deletes reasoning_effort on both the global and
// the China endpoint.
func TestApplyOpenAIThinkingDisableMiniMax(t *testing.T) {
cases := []struct {
name string
baseURL string
modelID string
}{
{name: "global endpoint", baseURL: "https://api.minimax.io/v1", modelID: "MiniMax-M3"},
{name: "china endpoint", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M3"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := StreamRequest{ThinkingEffort: "disabled", RequestKnobs: map[string]any{}}
body := map[string]any{
"model": tc.modelID,
"messages": []map[string]any{{"role": "user", "content": "hi"}},
"reasoning_effort": "high",
}
applyOpenAIThinkingDisable(body, req, tc.baseURL, tc.modelID, "/chat/completions")
thinking, ok := body["thinking"].(map[string]any)
if !ok {
t.Fatalf("expected body[thinking] to be map[string]any, got %T (%v)", body["thinking"], body["thinking"])
}
if thinking["type"] != "disabled" {
t.Fatalf("expected thinking.type=disabled, got %v", thinking["type"])
}
if _, stillPresent := body["reasoning_effort"]; stillPresent {
t.Fatalf("reasoning_effort should be deleted when thinking disabled, got %v", body["reasoning_effort"])
}
if got := req.RequestKnobs["thinking_disabled_provider_param"]; got != "thinking.type" {
t.Fatalf("expected request knob thinking_disabled_provider_param=thinking.type, got %v", got)
}
})
}
}
// TestApplyOpenAIThinkingDisableMiniMaxNotTriggered verifies that a non-disabled
// thinking effort does not inject the disable field for MiniMax.
func TestApplyOpenAIThinkingDisableMiniMaxNotTriggered(t *testing.T) {
req := StreamRequest{ThinkingEffort: "high", RequestKnobs: map[string]any{}}
body := map[string]any{"model": "MiniMax-M3", "reasoning_effort": "high"}
applyOpenAIThinkingDisable(body, req, "https://api.minimax.io/v1", "MiniMax-M3", "/chat/completions")
if _, present := body["thinking"]; present {
t.Fatalf("thinking should not be injected when ThinkingEffort != disabled, got %v", body["thinking"])
}
if body["reasoning_effort"] != "high" {
t.Fatalf("reasoning_effort should be preserved when not disabled, got %v", body["reasoning_effort"])
}
}
+7
View File
@@ -23,6 +23,7 @@ const (
TurnPhaseWaitingExternal TurnPhase = "waiting_external"
TurnPhaseAwaitingUser TurnPhase = "awaiting_user"
TurnPhaseCompacting TurnPhase = "compacting"
TurnPhaseCheckpointing TurnPhase = "checkpointing"
TurnPhaseCompleted TurnPhase = "completed"
TurnPhaseFailed TurnPhase = "failed"
TurnPhaseCanceled TurnPhase = "canceled"
@@ -66,6 +67,7 @@ const (
streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery"
streamTimerShellForeground streamTimerKind = "shell_foreground"
streamTimerShellTransportClose streamTimerKind = "shell_transport_close"
streamTimerCheckpointBlobs streamTimerKind = "checkpoint_blobs"
streamTimerOrphanCancel streamTimerKind = "orphan_cancel"
)
@@ -318,6 +320,9 @@ func (service *Service) handleStreamCommand(stream *ActiveStream, command stream
case streamCommandCancel:
return service.handleCancelIntent(command.Intent)
case streamCommandMetadata:
if strings.TrimSpace(command.Intent.Kind) == "kv_result" {
return service.handleCheckpointBlobResult(stream, command.Intent.KVClientMessage)
}
return service.handleMetadataIntent(command.Intent)
case streamCommandExecResult:
return service.handleExecResult(command.Intent)
@@ -1003,6 +1008,8 @@ func (service *Service) handleTimerEvent(stream *ActiveStream, payload *streamTi
return nil
}
return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed)
case streamTimerCheckpointBlobs:
return service.handleCheckpointBlobTimeout(stream)
case streamTimerOrphanCancel:
stream.mu.Lock()
subscriberCount := len(stream.Subscribers)
+8
View File
@@ -76,6 +76,12 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
if existing.BackgroundShellActions == nil {
existing.BackgroundShellActions = make(map[string]time.Time)
}
if existing.PendingCheckpointBlobWrites == nil {
existing.PendingCheckpointBlobWrites = make(map[uint32]string)
}
if existing.ConfirmedCheckpointBlobs == nil {
existing.ConfirmedCheckpointBlobs = make(map[string]struct{})
}
existing.UpdatedAt = time.Now().UTC()
existing.mu.Unlock()
return existing, nil
@@ -102,6 +108,8 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
BackgroundShellsByMessageID: make(map[uint32]string),
BackgroundShellsByExecID: make(map[string]string),
BackgroundShellActions: make(map[string]time.Time),
PendingCheckpointBlobWrites: make(map[uint32]string),
ConfirmedCheckpointBlobs: make(map[string]struct{}),
CreatedAt: now,
UpdatedAt: now,
}
@@ -0,0 +1,238 @@
package forwarder
import (
"encoding/hex"
"fmt"
"log"
"strings"
"time"
"google.golang.org/protobuf/proto"
"cursor/gen/agentv1"
)
const checkpointBlobWriteTimeout = 5 * time.Second
type pendingCheckpointBlobWrite struct {
requestID uint32
blob CheckpointBlob
}
func clonePendingTurnCompletion(completion *pendingTurnCompletion) *pendingTurnCompletion {
if completion == nil {
return nil
}
cloned := *completion
return &cloned
}
func (service *Service) queueCheckpointProjection(stream *ActiveStream, projection *CheckpointProjection, completion *pendingTurnCompletion) error {
if service == nil || stream == nil || projection == nil || projection.State == nil {
return nil
}
state, ok := proto.Clone(projection.State).(*agentv1.ConversationStateStructure)
if !ok || state == nil {
return fmt.Errorf("clone checkpoint state")
}
stream.mu.Lock()
if stream.PendingCheckpointBlobWrites == nil {
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
}
if stream.ConfirmedCheckpointBlobs == nil {
stream.ConfirmedCheckpointBlobs = make(map[string]struct{})
}
if completion == nil && stream.PendingCheckpoint != nil {
completion = stream.PendingCheckpoint.Completion
}
required := make(map[string]struct{}, len(projection.Blobs))
pendingKeys := make(map[string]struct{}, len(stream.PendingCheckpointBlobWrites))
for _, key := range stream.PendingCheckpointBlobWrites {
pendingKeys[key] = struct{}{}
}
toWrite := make([]pendingCheckpointBlobWrite, 0, len(projection.Blobs))
for _, blob := range projection.Blobs {
key := string(blob.ID)
if key == "" {
continue
}
required[key] = struct{}{}
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; confirmed {
continue
}
if _, pending := pendingKeys[key]; pending {
continue
}
stream.NextCheckpointBlobRequestID++
if stream.NextCheckpointBlobRequestID == 0 {
stream.NextCheckpointBlobRequestID++
}
requestID := stream.NextCheckpointBlobRequestID
stream.PendingCheckpointBlobWrites[requestID] = key
pendingKeys[key] = struct{}{}
toWrite = append(toWrite, pendingCheckpointBlobWrite{requestID: requestID, blob: blob})
}
stream.PendingCheckpoint = &pendingCheckpointPublish{
State: state,
Required: required,
Completion: clonePendingTurnCompletion(completion),
}
if completion != nil {
stream.Phase = TurnPhaseCheckpointing
}
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
for _, write := range toWrite {
if err := service.broker.Publish(stream.RequestID, StreamEvent{
Message: buildSetCheckpointBlobMessage(write.requestID, write.blob),
}); err != nil {
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("publish checkpoint blob: %w", err))
}
}
if service.checkpointProjectionReady(stream) {
return service.publishReadyCheckpoint(stream)
}
service.scheduleStreamTimer(
stream,
providerTimerKey(streamTimerCheckpointBlobs, ""),
checkpointBlobWriteTimeout,
streamTimerCheckpointBlobs,
"",
0,
"checkpoint blob write timeout",
)
return nil
}
func (service *Service) checkpointProjectionReady(stream *ActiveStream) bool {
if stream == nil {
return false
}
stream.mu.Lock()
defer stream.mu.Unlock()
if stream.PendingCheckpoint == nil {
return false
}
for key := range stream.PendingCheckpoint.Required {
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; !confirmed {
return false
}
}
return true
}
func (service *Service) handleCheckpointBlobResult(stream *ActiveStream, message *agentv1.KvClientMessage) error {
if service == nil || stream == nil || message == nil || message.GetSetBlobResult() == nil {
return nil
}
stream.mu.Lock()
key, ok := stream.PendingCheckpointBlobWrites[message.GetId()]
if ok {
delete(stream.PendingCheckpointBlobWrites, message.GetId())
}
required := false
if ok && stream.PendingCheckpoint != nil {
_, required = stream.PendingCheckpoint.Required[key]
}
if ok && message.GetSetBlobResult().GetError() == nil {
stream.ConfirmedCheckpointBlobs[key] = struct{}{}
}
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
if !ok {
return nil
}
if blobErr := message.GetSetBlobResult().GetError(); blobErr != nil && required {
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf(
"client rejected checkpoint blob %s: %s",
hex.EncodeToString([]byte(key)),
firstNonEmpty(strings.TrimSpace(blobErr.GetMessage()), "unknown error"),
))
}
if service.checkpointProjectionReady(stream) {
return service.publishReadyCheckpoint(stream)
}
return nil
}
func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
if service == nil || stream == nil {
return nil
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
if pending == nil {
stream.mu.Unlock()
return nil
}
for key := range pending.Required {
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; !confirmed {
stream.mu.Unlock()
return nil
}
}
stream.PendingCheckpoint = nil
state := pending.State
completion := clonePendingTurnCompletion(pending.Completion)
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
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)
}
return err
}
if completion != nil {
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
}
return nil
}
func (service *Service) handleCheckpointBlobTimeout(stream *ActiveStream) error {
if stream == nil {
return nil
}
stream.mu.Lock()
pendingCount := len(stream.PendingCheckpointBlobWrites)
stream.mu.Unlock()
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("%d checkpoint blob writes timed out", pendingCount))
}
func (service *Service) finishAfterCheckpointSyncFailure(stream *ActiveStream, cause error) error {
if stream == nil {
return nil
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
stream.PendingCheckpoint = nil
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
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)
}
return nil
}
func (service *Service) discardPendingCheckpoint(stream *ActiveStream, reason string) {
if stream == nil {
return
}
stream.mu.Lock()
stream.PendingCheckpoint = nil
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
if strings.TrimSpace(reason) != "" {
log.Printf("forwarder pending checkpoint discarded request_id=%s conversation_id=%s reason=%s", stream.RequestID, stream.ConversationID, strings.TrimSpace(reason))
}
}
@@ -0,0 +1,208 @@
package forwarder
import (
"testing"
"google.golang.org/protobuf/encoding/protojson"
"cursor/gen/agentv1"
)
func TestCheckpointBlobSyncPublishesCheckpointAfterAcknowledgements(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) {
t.Fatalf("events before ACK = %d, want %d Blob writes", len(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)
}
}
acknowledgeCheckpointBlobs(t, service, stream)
events = readCheckpointTestEvents(t, service, stream)
checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate()
if checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
t.Fatalf("last event checkpoint = %#v, want one Blob-backed turn", checkpoint)
}
}
func TestCheckpointBlobSyncPublishesCheckpointBeforeSuccessfulTerminal(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
completion := &pendingTurnCompletion{
RequestID: stream.RequestID,
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
}
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
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 TestCheckpointBlobTimeoutDoesNotFailSuccessfulTurn(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
completion := &pendingTurnCompletion{
RequestID: stream.RequestID,
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
}
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
if err := service.handleCheckpointBlobTimeout(stream); err != nil {
t.Fatalf("handleCheckpointBlobTimeout() error = %v", err)
}
events := readCheckpointTestEvents(t, service, stream)
var checkpoint, turnEnded, successfulEnd bool
for _, event := range events {
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
turnEnded = turnEnded || event.Message.GetInteractionUpdate().GetTurnEnded() != nil
successfulEnd = successfulEnd || event.End && event.TerminalErrorCode == ""
}
if checkpoint || !turnEnded || !successfulEnd {
t.Fatalf("timeout events checkpoint=%v turn_ended=%v successful_end=%v", checkpoint, turnEnded, successfulEnd)
}
}
func TestCancellationDiscardsPendingCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
stream.mu.Lock()
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
for requestID := range stream.PendingCheckpointBlobWrites {
requestIDs = append(requestIDs, requestID)
}
stream.mu.Unlock()
if err := service.handleCancelIntent(InboundIntent{
Kind: "cancel",
RequestID: stream.RequestID,
CancelReason: "user stopped",
}); err != nil {
t.Fatalf("handleCancelIntent() error = %v", err)
}
for _, requestID := range requestIDs {
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
Id: requestID,
Message: &agentv1.KvClientMessage_SetBlobResult{
SetBlobResult: &agentv1.SetBlobResult{},
},
}); err != nil {
t.Fatalf("late ACK %d error = %v", requestID, err)
}
}
events := readCheckpointTestEvents(t, service, stream)
var checkpoint, canceledEnd bool
for _, event := range events {
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
canceledEnd = canceledEnd || event.End && event.TerminalErrorCode == "canceled"
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
stream.mu.Unlock()
if checkpoint || !canceledEnd || pending != nil {
t.Fatalf("cancel events checkpoint=%v canceled_end=%v pending=%v", checkpoint, canceledEnd, pending != nil)
}
}
func testCheckpointBlobProjection(t *testing.T) (*Service, *ActiveStream, *CheckpointProjection) {
t.Helper()
broker := NewStreamBroker()
service := &Service{
store: NewConversationFileStore(t.TempDir()),
projector: NewHistoryProjector(),
broker: broker,
}
stream, err := broker.OpenStream(
"request-1", "conversation-1", 1, "default", "default",
agentv1.AgentMode_AGENT_MODE_AGENT, "hello",
)
if err != nil {
t.Fatalf("OpenStream() error = %v", err)
}
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 2,
NextEntrySeq: 3,
TokenDetailsMaxTokens: projectedConversationMaxTokens,
Entries: []HistoryEntry{
testCheckpointUserEntry(t),
newAssistantTextEntry(1, "request-1", "hi", "", ""),
},
}
projection, err := service.projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if err := service.replaceCheckpointConversation(stream, conversation); err != nil {
t.Fatalf("replaceCheckpointConversation() error = %v", err)
}
return service, stream, projection
}
func testCheckpointUserEntry(t *testing.T) HistoryEntry {
t.Helper()
payload, err := protojson.Marshal(&agentv1.UserMessage{Text: "hello", MessageId: "message-1"})
if err != nil {
t.Fatalf("marshal user message: %v", err)
}
return HistoryEntry{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: payload}
}
func acknowledgeCheckpointBlobs(t *testing.T, service *Service, stream *ActiveStream) {
t.Helper()
for {
stream.mu.Lock()
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
for requestID := range stream.PendingCheckpointBlobWrites {
requestIDs = append(requestIDs, requestID)
}
stream.mu.Unlock()
if len(requestIDs) == 0 {
return
}
for _, requestID := range requestIDs {
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
Id: requestID,
Message: &agentv1.KvClientMessage_SetBlobResult{
SetBlobResult: &agentv1.SetBlobResult{},
},
}); err != nil {
t.Fatalf("handleCheckpointBlobResult(%d) error = %v", requestID, err)
}
}
}
}
func readCheckpointTestEvents(t *testing.T, service *Service, stream *ActiveStream) []StreamEvent {
t.Helper()
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("ReadFromCursor() error = %v", err)
}
return events
}
+16
View File
@@ -245,6 +245,22 @@ func buildCheckpointMessage(state *agentv1.ConversationStateStructure) *agentv1.
}
}
func buildSetCheckpointBlobMessage(id uint32, blob CheckpointBlob) *agentv1.AgentServerMessage {
return &agentv1.AgentServerMessage{
Message: &agentv1.AgentServerMessage_KvServerMessage{
KvServerMessage: &agentv1.KvServerMessage{
Id: id,
Message: &agentv1.KvServerMessage_SetBlobArgs{
SetBlobArgs: &agentv1.SetBlobArgs{
BlobId: append([]byte(nil), blob.ID...),
BlobData: append([]byte(nil), blob.Data...),
},
},
},
},
}
}
// buildExecAbortMessage 构造对客户端执行桥的 abort 控制消息。
func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage {
return &agentv1.AgentServerMessage{
+236 -159
View File
@@ -2,6 +2,7 @@
package forwarder
import (
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
@@ -19,6 +20,51 @@ const projectedConversationMaxTokens = 130000
type HistoryProjector struct {
}
type CheckpointBlob struct {
ID []byte
Data []byte
}
type CheckpointProjection struct {
State *agentv1.ConversationStateStructure
Blobs []CheckpointBlob
}
type checkpointBlobGraph struct {
blobs map[[sha256.Size]byte][]byte
order [][sha256.Size]byte
}
func newCheckpointBlobGraph() *checkpointBlobGraph {
return &checkpointBlobGraph{blobs: make(map[[sha256.Size]byte][]byte)}
}
func (graph *checkpointBlobGraph) add(data []byte) []byte {
if graph == nil || len(data) == 0 {
return nil
}
id := sha256.Sum256(data)
if _, exists := graph.blobs[id]; !exists {
graph.blobs[id] = append([]byte(nil), data...)
graph.order = append(graph.order, id)
}
return append([]byte(nil), id[:]...)
}
func (graph *checkpointBlobGraph) list() []CheckpointBlob {
if graph == nil || len(graph.order) == 0 {
return nil
}
blobs := make([]CheckpointBlob, 0, len(graph.order))
for _, id := range graph.order {
blobs = append(blobs, CheckpointBlob{
ID: append([]byte(nil), id[:]...),
Data: append([]byte(nil), graph.blobs[id]...),
})
}
return blobs
}
// NewHistoryProjector 创建 history 投影器。
func NewHistoryProjector() *HistoryProjector {
return &HistoryProjector{}
@@ -475,6 +521,16 @@ func isHistoricalReplayToolResult(conversation *ConversationFile, entry HistoryE
// ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。
func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *ConversationFile) (*agentv1.ConversationStateStructure, error) {
projection, err := projector.ProjectCheckpointProjection(conversation)
if err != nil || projection == nil {
return nil, err
}
return projection.State, nil
}
// ProjectCheckpointProjection 同时返回 checkpoint 状态及其引用的内容寻址 Blob。
func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *ConversationFile) (*CheckpointProjection, error) {
blobs := newCheckpointBlobGraph()
state := &agentv1.ConversationStateStructure{
TokenDetails: &agentv1.ConversationTokenDetails{
UsedTokens: conversationTokenDetailsUsedTokens(conversation),
@@ -488,7 +544,7 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
if conversation == nil {
mode := agentv1.AgentMode_AGENT_MODE_AGENT
state.Mode = &mode
return state, nil
return &CheckpointProjection{State: state}, nil
}
mode, err := parseModeAlias(conversation.Mode)
if err != nil {
@@ -506,158 +562,11 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
if structuredState.HasTodos {
state.Todos = encodeConversationTodoBytes(structuredState.Todos)
}
grouped := make(map[int64][]HistoryEntry)
order := make([]int64, 0, conversation.NextTurnSeq)
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
if entry.TurnSeq <= 0 {
continue
}
if _, ok := grouped[entry.TurnSeq]; !ok {
order = append(order, entry.TurnSeq)
}
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
}
for _, turnSeq := range order {
entries := grouped[turnSeq]
var rawUserMessage []byte
var turnRequestID string
steps := make([][]byte, 0, len(entries))
seenToolCalls := make(map[string]struct{})
openToolCalls := make(map[string]struct{})
for _, entry := range entries {
if turnRequestID == "" {
turnRequestID = strings.TrimSpace(entry.RequestID)
}
switch strings.TrimSpace(entry.Kind) {
case "user_message":
userMessage := &agentv1.UserMessage{}
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
}
payload, err := proto.Marshal(userMessage)
if err != nil {
return nil, err
}
rawUserMessage = payload
case "assistant_text":
var payload assistantTextPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
continue
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
if strings.TrimSpace(payload.Text) == "" {
continue
}
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
Message: &agentv1.ConversationStep_AssistantMessage{
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
},
})
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
case "tool_call":
var payload toolCallEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{
ToolCall: toolCall,
},
})
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
seenToolCalls[toolCallID] = struct{}{}
openToolCalls[toolCallID] = struct{}{}
}
case "tool_result":
var payload toolResultEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
if _, ok := seenToolCalls[toolCallID]; ok {
delete(openToolCalls, toolCallID)
continue
}
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
if len(payload.ToolCall) == 0 {
continue
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{
ToolCall: toolCall,
},
})
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
}
if len(rawUserMessage) == 0 && len(steps) == 0 {
continue
}
agentTurn := &agentv1.AgentConversationTurnStructure{
UserMessage: rawUserMessage,
Steps: steps,
}
if turnRequestID != "" {
agentTurn.RequestId = &turnRequestID
}
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
AgentConversationTurn: agentTurn,
},
})
if err != nil {
return nil, err
}
state.Turns = append(state.Turns, turnPayload)
turnIDs, err := projectCheckpointTurnBlobs(conversation, blobs)
if err != nil {
return nil, err
}
state.Turns = turnIDs
replayMessages, err := projector.ProjectPromptReplay(conversation)
if err != nil {
return nil, err
@@ -685,15 +594,183 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
return nil, err
}
state.RootPromptMessagesJson = rootPromptMessages
return state, nil
return &CheckpointProjection{State: state, Blobs: blobs.list()}, nil
}
func marshalThinkingStep(text string) ([]byte, error) {
return proto.Marshal(&agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: text},
},
})
func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpointBlobGraph) ([][]byte, error) {
if conversation == nil || blobs == nil {
return nil, nil
}
grouped := make(map[int64][]HistoryEntry)
order := make([]int64, 0, conversation.NextTurnSeq)
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
if entry.TurnSeq <= 0 {
continue
}
if _, ok := grouped[entry.TurnSeq]; !ok {
order = append(order, entry.TurnSeq)
}
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
}
turnIDs := make([][]byte, 0, len(order))
for _, turnSeq := range order {
entries := grouped[turnSeq]
var userMessageID []byte
var turnRequestID string
stepIDs := make([][]byte, 0, len(entries))
seenToolCalls := make(map[string]struct{})
openToolCalls := make(map[string]struct{})
for _, entry := range entries {
if turnRequestID == "" {
turnRequestID = strings.TrimSpace(entry.RequestID)
}
switch strings.TrimSpace(entry.Kind) {
case "user_message":
userMessage := &agentv1.UserMessage{}
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
}
payload, err := proto.Marshal(userMessage)
if err != nil {
return nil, err
}
userMessageID = blobs.add(payload)
case "assistant_text":
var payload assistantTextPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
continue
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
if strings.TrimSpace(payload.Text) == "" {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_AssistantMessage{
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
case "tool_call":
var payload toolCallEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
seenToolCalls[toolCallID] = struct{}{}
openToolCalls[toolCallID] = struct{}{}
}
case "tool_result":
var payload toolResultEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
if _, ok := seenToolCalls[toolCallID]; ok {
delete(openToolCalls, toolCallID)
continue
}
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
if len(payload.ToolCall) == 0 {
continue
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
}
if len(userMessageID) == 0 && len(stepIDs) == 0 {
continue
}
agentTurn := &agentv1.AgentConversationTurnStructure{
UserMessage: userMessageID,
Steps: stepIDs,
}
if turnRequestID != "" {
agentTurn.RequestId = &turnRequestID
}
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
AgentConversationTurn: agentTurn,
},
})
if err != nil {
return nil, err
}
turnIDs = append(turnIDs, blobs.add(turnPayload))
}
return turnIDs, nil
}
func addCheckpointStepBlob(blobs *checkpointBlobGraph, step *agentv1.ConversationStep) ([]byte, error) {
payload, err := proto.Marshal(step)
if err != nil {
return nil, err
}
return blobs.add(payload), nil
}
func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 {
@@ -0,0 +1,140 @@
package forwarder
import (
"crypto/sha256"
"strings"
"testing"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"cursor/gen/agentv1"
)
func TestProjectCheckpointProjectionBuildsResolvableForkState(t *testing.T) {
userPayload, err := protojson.Marshal(&agentv1.UserMessage{
Text: "parent question",
MessageId: "message-1",
})
if err != nil {
t.Fatalf("marshal user message: %v", err)
}
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 2,
NextEntrySeq: 3,
TokenDetailsMaxTokens: projectedConversationMaxTokens,
Entries: []HistoryEntry{
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
newAssistantTextEntry(1, "request-1", "parent answer", "", ""),
},
}
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
state := projection.State
if len(state.GetTurns()) != 1 {
t.Fatalf("ProjectCheckpointProjection() turns = %d, want 1 Blob-backed turn", len(state.GetTurns()))
}
blobs := make(map[string][]byte, len(projection.Blobs))
for _, blob := range projection.Blobs {
digest := sha256.Sum256(blob.Data)
if len(blob.ID) != sha256.Size || string(blob.ID) != string(digest[:]) {
t.Fatalf("invalid content-addressed Blob id=%x", blob.ID)
}
blobs[string(blob.ID)] = blob.Data
}
turnPayload, ok := blobs[string(state.GetTurns()[0])]
if !ok {
t.Fatal("turn references a missing Blob")
}
turn := &agentv1.ConversationTurnStructure{}
if err := proto.Unmarshal(turnPayload, turn); err != nil {
t.Fatalf("decode turn Blob: %v", err)
}
agentTurn := turn.GetAgentConversationTurn()
if agentTurn == nil {
t.Fatal("turn Blob does not contain an agent turn")
}
if _, ok := blobs[string(agentTurn.GetUserMessage())]; !ok {
t.Fatal("turn references a missing user message Blob")
}
for _, stepID := range agentTurn.GetSteps() {
if _, ok := blobs[string(stepID)]; !ok {
t.Fatal("turn references a missing step Blob")
}
}
messages, err := importedConversationStateModelMessages(state)
if err != nil {
t.Fatalf("importedConversationStateModelMessages() error = %v", err)
}
if len(messages) != 2 {
t.Fatalf("imported messages = %d, want parent user and assistant context", len(messages))
}
if messages[0].Role != "user" || !strings.Contains(messages[0].Content, "parent question") {
t.Fatalf("first imported message = %#v", messages[0])
}
if messages[1].Role != "assistant" || messages[1].Content != "parent answer" {
t.Fatalf("second imported message = %#v", messages[1])
}
}
func TestProjectCheckpointProjectionKeepsForkPointIsolatedFromLaterHistory(t *testing.T) {
firstUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "first question", MessageId: "message-1"})
if err != nil {
t.Fatalf("marshal first user message: %v", err)
}
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 2,
NextEntrySeq: 3,
TokenDetailsMaxTokens: projectedConversationMaxTokens,
Entries: []HistoryEntry{
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: firstUser},
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
},
}
projector := NewHistoryProjector()
midpoint, err := projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("midpoint projection: %v", err)
}
secondUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "second question", MessageId: "message-2"})
if err != nil {
t.Fatalf("marshal second user message: %v", err)
}
appendEntriesInPlace(conversation, []HistoryEntry{
{TurnSeq: 2, RequestID: "request-2", Role: "user", Kind: "user_message", Payload: secondUser},
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
})
latest, err := projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("latest projection: %v", err)
}
midpointMessages, err := importedConversationStateModelMessages(midpoint.State)
if err != nil {
t.Fatalf("import midpoint messages: %v", err)
}
latestMessages, err := importedConversationStateModelMessages(latest.State)
if err != nil {
t.Fatalf("import latest messages: %v", err)
}
if len(midpoint.State.GetTurns()) != 1 || len(midpointMessages) != 2 {
t.Fatalf("midpoint turns=%d messages=%d, want 1 turn and 2 messages", len(midpoint.State.GetTurns()), len(midpointMessages))
}
if len(latest.State.GetTurns()) != 2 || len(latestMessages) != 4 {
t.Fatalf("latest turns=%d messages=%d, want 2 turns and 4 messages", len(latest.State.GetTurns()), len(latestMessages))
}
if midpointMessages[1].Content != "first answer" || latestMessages[3].Content != "second answer" {
t.Fatalf("fork snapshots are not isolated: midpoint=%#v latest=%#v", midpointMessages, latestMessages)
}
}
+21 -12
View File
@@ -858,9 +858,7 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
})
}
if hasCheckpoint {
if err := service.publishCheckpoint(stream.RequestID, stream.ConversationID); err != nil {
return err
}
service.discardPendingCheckpoint(stream, "checkpoint superseded by cancellation")
}
clearPendingProviderCompletion(stream)
stream.mu.Lock()
@@ -2122,9 +2120,15 @@ func (service *Service) completeSuccessfulTurn(stream *ActiveStream, completion
err,
)
}
if err := service.publishCheckpoint(requestID, conversationID); err != nil {
return err
return service.publishCheckpointWithCompletion(requestID, conversationID, &completion)
}
func (service *Service) finishSuccessfulTurnAfterCheckpoint(stream *ActiveStream, completion pendingTurnCompletion) error {
if stream == nil {
return nil
}
requestID := firstNonEmpty(strings.TrimSpace(completion.RequestID), strings.TrimSpace(stream.RequestID))
usage := completion.Usage
if err := service.broker.Publish(requestID, StreamEvent{
Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens),
}); err != nil {
@@ -2151,7 +2155,11 @@ func (service *Service) failStreamIfNonTerminal(stream *ActiveStream, terminalCo
}
// publishCheckpoint 按当前内存会话镜像投影出 checkpoint,并广播给所有 RunSSE 订阅者。
func (service *Service) publishCheckpoint(requestID string, _ string) error {
func (service *Service) publishCheckpoint(requestID string, conversationID string) error {
return service.publishCheckpointWithCompletion(requestID, conversationID, nil)
}
func (service *Service) publishCheckpointWithCompletion(requestID string, _ string, completion *pendingTurnCompletion) error {
stream, ok := service.broker.Get(requestID)
if !ok || stream == nil {
return fmt.Errorf("request is not active: %s", requestID)
@@ -2160,15 +2168,16 @@ func (service *Service) publishCheckpoint(requestID string, _ string) error {
if err != nil {
return err
}
state, err := service.projector.ProjectLegacyCheckpoint(conversation)
projection, err := service.projector.ProjectCheckpointProjection(conversation)
if err != nil {
return err
}
state.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, state)
return service.broker.Publish(requestID, StreamEvent{
Message: buildCheckpointMessage(state),
})
if projection == nil || projection.State == nil {
return fmt.Errorf("checkpoint projection is empty")
}
projection.State.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, projection.State)
return service.queueCheckpointProjection(stream, projection, completion)
}
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
+10
View File
@@ -163,6 +163,10 @@ type ActiveStream struct {
ProviderUsage turnUsageSnapshot
ProviderTerminalToolInvocation bool
PendingCompaction *PendingCompaction
PendingCheckpointBlobWrites map[uint32]string
ConfirmedCheckpointBlobs map[string]struct{}
NextCheckpointBlobRequestID uint32
PendingCheckpoint *pendingCheckpointPublish
Backlog []StreamEvent
Subscribers map[string]*StreamSubscriber
@@ -219,6 +223,12 @@ type pendingTurnCompletion struct {
Disposition pendingCompletionDisposition
}
type pendingCheckpointPublish struct {
State *agentv1.ConversationStateStructure
Required map[string]struct{}
Completion *pendingTurnCompletion
}
type PendingCompaction struct {
Trigger string
ContextTokens int64
+138
View File
@@ -319,6 +319,16 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
MockBuilder: upstream.ServerConfigMockBuilder,
})),
),
server.POST("/aiserver.v1.ServerConfigService/GetServerConfig",
server.Name("server_config_service_get_server_config"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "server_config_service_get_server_config",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetServerConfigResponse",
MockBuilder: upstream.ServerConfigMockBuilder,
})),
),
server.POST("/aiserver.v1.AiService/AvailableModels",
server.Name("available_models"),
server.ConnectUnary(),
@@ -329,6 +339,36 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
MockBuilder: upstream.AvailableModelsMockBuilder,
})),
),
server.POST("/aiserver.v1.AiService/GetUsableModels",
server.Name("usable_models"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "usable_models",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetUsableModelsResponse",
MockBuilder: upstream.UsableModelsMockBuilder,
})),
),
server.POST("/aiserver.v1.AiService/GetDefaultModelForCli",
server.Name("default_model_for_cli"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "default_model_for_cli",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetDefaultModelForCliResponse",
MockBuilder: upstream.DefaultModelForCliMockBuilder,
})),
),
server.POST("/aiserver.v1.AiService/GetDefaultModel",
server.Name("default_model"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "default_model",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetDefaultModelResponse",
MockBuilder: upstream.DefaultModelMockBuilder,
})),
),
server.POST("/aiserver.v1.AiService/GetDefaultModelNudgeData",
server.Name("default_model_nudge"),
server.ConnectUnary(),
@@ -359,6 +399,34 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
MockBuilder: upstream.FirstWindowStatsigDecisionMockBuilder,
})),
),
server.POST("/aiserver.v1.AnalyticsService/SubmitLogs",
server.Name("analytics_submit_logs"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "analytics_submit_logs",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.SubmitLogsResponse",
MockBuilder: upstream.SubmitLogsMockBuilder,
})),
),
server.POST("/aiserver.v1.AnalyticsService/TrackEvents",
server.Name("analytics_track_events"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "analytics_track_events",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.TrackEventsResponse",
MockBuilder: upstream.EmptyMockBuilder,
})),
),
server.POST("/v1/traces",
server.Name("otlp_traces"),
server.HTTP(),
server.Local(upstream.FixedStatusAction(routeDeps, upstream.CompatRouteConfig{
Name: "otlp_traces",
StatusCode: http.StatusOK,
})),
),
server.POST("/oauth/token",
server.Name("oauth_token"),
server.HTTP(),
@@ -477,6 +545,76 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
}),
)),
),
server.POST("/aiserver.v1.DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam",
server.Name("dashboard_get_team_admin_settings_or_empty"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_team_admin_settings_or_empty",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetTeamAdminSettingsResponse",
MockBuilder: upstream.EmptyMockBuilder,
})),
),
server.POST("/aiserver.v1.DashboardService/GetTeamReposOrEmptyIfNotInTeam",
server.Name("dashboard_get_team_repos_or_empty"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_team_repos_or_empty",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetTeamReposResponse",
MockBuilder: upstream.EmptyMockBuilder,
})),
),
server.POST("/aiserver.v1.DashboardService/ListMarketplaces",
server.Name("dashboard_list_marketplaces"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_list_marketplaces",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.ListMarketplacesResponse",
MockBuilder: upstream.EmptyMockBuilder,
})),
),
server.POST("/aiserver.v1.DashboardService/GetGlobalCommands",
server.Name("dashboard_get_global_commands"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_global_commands",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetGlobalCommandsResponse",
MockBuilder: upstream.EmptyMockBuilder,
})),
),
server.POST("/aiserver.v1.DashboardService/GetEffectiveUserPlugins",
server.Name("dashboard_get_effective_user_plugins"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_effective_user_plugins",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetEffectiveUserPluginsResponse",
MockBuilder: upstream.EmptyMockBuilder,
})),
),
server.POST("/aiserver.v1.DashboardService/RegisterMarketplaceAndPlugins",
server.Name("dashboard_register_marketplace_and_plugins"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_register_marketplace_and_plugins",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.RegisterMarketplaceAndPluginsResponse",
MockBuilder: upstream.EmptyMockBuilder,
})),
),
server.POST("/aiserver.v1.DashboardService/GetCliDownloadUrl",
server.Name("dashboard_get_cli_download_url"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_cli_download_url",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetCliDownloadUrlResponse",
MockBuilder: upstream.EmptyMockBuilder,
})),
),
server.POST("/aiserver.v1.DashboardService/GetMe",
server.Name("dashboard_get_me"),
server.ConnectUnary(),
@@ -193,6 +193,18 @@ func DefaultModelNudgeMockBuilder(reqCtx *RequestContext) (map[string]any, error
return buildDefaultModelNudgeDataPayload(reqCtx)
}
func UsableModelsMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildUsableModelsPayload(reqCtx)
}
func DefaultModelForCliMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildDefaultModelForCliPayload(reqCtx)
}
func DefaultModelMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildDefaultModelPayload(reqCtx)
}
func BootstrapStatsigMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildBootstrapStatsigPayload(reqCtx)
}
@@ -213,6 +225,18 @@ func DashboardManagedSkillsMockBuilder(reqCtx *RequestContext) (map[string]any,
return buildDashboardManagedSkillsPayload(reqCtx)
}
// EmptyMockBuilder возвращает пустой proto-ответ для ручек, где клиенту
// достаточно успешного "пусто": нет team-настроек, нет репозиториев,
// нет маркетплейсов/плагинов/команд, телеметрия принята без обработки.
func EmptyMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return map[string]any{}, nil
}
// SubmitLogsMockBuilder подтверждает приём логов телеметрии без обработки.
func SubmitLogsMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return map[string]any{"success": true}, nil
}
func DashboardGetMeMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildDashboardGetMePayload(reqCtx)
}
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"cursor/gen/agentv1"
"cursor/gen/aiserverv1"
"cursor/internal/logger"
"cursor/internal/netproxy"
@@ -426,6 +427,30 @@ func newProtoMessage(typeName string) (proto.Message, error) {
return &aiserverv1.GetUsageLimitStatusAndActiveGrantsResponse{}, nil
case "aiserver.v1.IsOnNewPricingResponse":
return &aiserverv1.IsOnNewPricingResponse{}, nil
case "aiserver.v1.GetTeamAdminSettingsResponse":
return &aiserverv1.GetTeamAdminSettingsResponse{}, nil
case "aiserver.v1.GetTeamReposResponse":
return &aiserverv1.GetTeamReposResponse{}, nil
case "aiserver.v1.ListMarketplacesResponse":
return &aiserverv1.ListMarketplacesResponse{}, nil
case "aiserver.v1.GetUsableModelsResponse":
return &agentv1.GetUsableModelsResponse{}, nil
case "aiserver.v1.GetDefaultModelForCliResponse":
return &agentv1.GetDefaultModelForCliResponse{}, nil
case "aiserver.v1.GetDefaultModelResponse":
return &aiserverv1.GetDefaultModelResponse{}, nil
case "aiserver.v1.GetGlobalCommandsResponse":
return &aiserverv1.GetGlobalCommandsResponse{}, nil
case "aiserver.v1.GetEffectiveUserPluginsResponse":
return &aiserverv1.GetEffectiveUserPluginsResponse{}, nil
case "aiserver.v1.RegisterMarketplaceAndPluginsResponse":
return &aiserverv1.RegisterMarketplaceAndPluginsResponse{}, nil
case "aiserver.v1.GetCliDownloadUrlResponse":
return &aiserverv1.GetCliDownloadUrlResponse{}, nil
case "aiserver.v1.SubmitLogsResponse":
return &aiserverv1.SubmitLogsResponse{}, nil
case "aiserver.v1.TrackEventsResponse":
return &aiserverv1.TrackEventsResponse{}, nil
default:
return nil, fmt.Errorf("unsupported proto message type %q", typeName)
}
+73 -3
View File
@@ -17,6 +17,13 @@ const (
modelRuntimeThinkingEffortParameterID = "thinking_effort"
// localPathEncryptionKey — стабильный ключ шифрования путей для индексации
// репозитория, который cursor-agent CLI запрашивает через GetServerConfig
// (indexingConfig.default{User,Team}PathEncryptionKey). Без него CLI
// не может инициализировать repo identity в git-воркспейсе и вызовы
// файловых инструментов падают с "[unimplemented] HTTP 404".
localPathEncryptionKey = "6f6e63652d6c6f63616c2d706174682d656e6372797074696f6e2d6b6579"
localUltraMembershipType = "ultra"
localUltraPaymentID = "local_ultra"
localUltraSubscriptionStatus = "active"
@@ -424,9 +431,13 @@ func buildServerTimePayload(*RequestContext) (map[string]any, error) {
func buildServerConfigPayload(*RequestContext) (map[string]any, error) {
return map[string]any{
"configVersion": "local_cli_sandbox_defaults_disabled_v2",
// "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED",
"configVersion": "local_cli_sandbox_defaults_disabled_v2",
"http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED",
"cliSandboxDefaultEnabled": true,
"indexingConfig": map[string]any{
"defaultUserPathEncryptionKey": localPathEncryptionKey,
"defaultTeamPathEncryptionKey": localPathEncryptionKey,
},
}, nil
}
@@ -440,6 +451,7 @@ func buildAvailableModelsPayload(reqCtx *RequestContext) (map[string]any, error)
if len(modelRefs) > 0 {
defaultModel = modelRefs[0]
}
modelEntries := buildAvailableModelEntries(adapters)
return map[string]any{
"backgroundComposerModelConfig": map[string]any{
"bestOfNDefaultModels": append([]string(nil), modelRefs...),
@@ -459,7 +471,7 @@ func buildAvailableModelsPayload(reqCtx *RequestContext) (map[string]any, error)
"defaultModel": defaultModel,
},
"disableUnusedModelsAfterNHours": availableModelsDisableUnusedHours,
"models": buildAvailableModelEntries(adapters),
"models": modelEntries,
"planExecutionModelConfig": map[string]any{
"defaultModel": defaultModel,
"fallbackModels": append([]string(nil), modelRefs...),
@@ -486,6 +498,35 @@ func buildDefaultModelNudgeDataPayload(reqCtx *RequestContext) (map[string]any,
}, nil
}
func buildUsableModelsPayload(reqCtx *RequestContext) (map[string]any, error) {
adapters, err := loadConfiguredModelAdapters(reqCtx)
if err != nil {
return nil, err
}
return map[string]any{"models": buildCLIModelDetails(adapters)}, nil
}
func buildDefaultModelForCliPayload(reqCtx *RequestContext) (map[string]any, error) {
adapters, err := loadConfiguredModelAdapters(reqCtx)
if err != nil {
return nil, err
}
models := buildCLIModelDetails(adapters)
if len(models) == 0 {
return map[string]any{"model": map[string]any{}}, nil
}
return map[string]any{"model": models[0]}, nil
}
func buildDefaultModelPayload(reqCtx *RequestContext) (map[string]any, error) {
adapters, err := loadConfiguredModelAdapters(reqCtx)
if err != nil {
return nil, err
}
defaultModel := firstModelAdapterRef(adapters)
return map[string]any{"model": defaultModel, "thinkingModel": defaultModel}, nil
}
func buildBootstrapStatsigPayload(reqCtx *RequestContext) (map[string]any, error) {
generatedAtMs := uint64(time.Now().UnixMilli())
authID := resolveBootstrapStatsigAuthID(reqCtx)
@@ -679,6 +720,25 @@ func buildAvailableModelEntries(adapters []legacyruntime.ModelAdapterConfig) []m
return output
}
func buildCLIModelDetails(adapters []legacyruntime.ModelAdapterConfig) []map[string]any {
models := make([]map[string]any, 0, len(adapters))
for _, adapter := range adapters {
channelID := strings.TrimSpace(adapter.ID)
if channelID == "" {
continue
}
models = append(models, map[string]any{
"modelId": channelID,
"displayModelId": channelID,
"apiKeyCredentials": map[string]any{
"apiKey": strings.TrimSpace(adapter.APIKey),
"baseUrl": strings.TrimSpace(adapter.BaseURL),
},
})
}
return models
}
func buildThinkingEffortParameterDefinitions(adapterType string) []map[string]any {
values := thinkingEffortValuesForAdapter(adapterType)
options := make([]map[string]any, 0, len(values))
@@ -823,6 +883,16 @@ func collectModelAdapterRefs(adapters []legacyruntime.ModelAdapterConfig) []stri
return output
}
// firstModelAdapterRef возвращает канал первого адаптера или пустую строку,
// если ни один адаптер не сконфигурирован.
func firstModelAdapterRef(adapters []legacyruntime.ModelAdapterConfig) string {
refs := collectModelAdapterRefs(adapters)
if len(refs) == 0 {
return ""
}
return refs[0]
}
func resolveBootstrapStatsigAuthID(reqCtx *RequestContext) string {
if reqCtx != nil {
if authID := authIDFromBearer(reqCtx.Headers.Get("authorization")); authID != "" {
@@ -2,9 +2,55 @@ package upstream
import (
"encoding/json"
"reflect"
"testing"
"cursor/gen/agentv1"
legacyruntime "cursor/internal/runtime"
"google.golang.org/protobuf/proto"
)
func TestBuildCLIModelDetailsPreservesChannelCredentials(t *testing.T) {
adapters := []legacyruntime.ModelAdapterConfig{
{ID: " channel-a ", ModelID: "model-a", APIKey: "provider-secret-a", BaseURL: "https://provider-a.example/v1"},
{ID: "channel-b", ModelID: "model-a"},
{ID: "", ModelID: "model-c"},
}
got := buildCLIModelDetails(adapters)
want := []map[string]any{
{"modelId": "channel-a", "displayModelId": "channel-a", "apiKeyCredentials": map[string]any{"apiKey": "provider-secret-a", "baseUrl": "https://provider-a.example/v1"}},
{"modelId": "channel-b", "displayModelId": "channel-b", "apiKeyCredentials": map[string]any{"apiKey": "", "baseUrl": ""}},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("build CLI model details: got %v, want %v", got, want)
}
}
func TestEncodeCLIModelsUsesAgentModelDetailsWireFormat(t *testing.T) {
payload := map[string]any{"models": buildCLIModelDetails([]legacyruntime.ModelAdapterConfig{{ID: "channel-a", APIKey: "provider-secret", BaseURL: "https://provider.example/v1"}})}
encoded, err := encodeMockProto("aiserver.v1.GetUsableModelsResponse", payload)
if err != nil {
t.Fatalf("encode CLI models: %v", err)
}
response := &agentv1.GetUsableModelsResponse{}
if err := proto.Unmarshal(encoded, response); err != nil {
t.Fatalf("decode CLI models with agent proto: %v", err)
}
if len(response.Models) != 1 {
t.Fatalf("decoded model count: got %d, want 1", len(response.Models))
}
model := response.Models[0]
if model.GetModelId() != "channel-a" || model.GetDisplayModelId() != "channel-a" {
t.Fatalf("decoded channel IDs: model=%q display=%q", model.GetModelId(), model.GetDisplayModelId())
}
if credentials := model.GetApiKeyCredentials(); credentials == nil || credentials.GetApiKey() != "provider-secret" || credentials.GetBaseUrl() != "https://provider.example/v1" {
t.Fatalf("decoded relay credentials: %#v", credentials)
}
}
func TestBuildBootstrapStatsigConfigJSONDisablesAlwaysLocalDecompositionGate(t *testing.T) {
payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id")
if err != nil {
+2 -7
View File
@@ -8,11 +8,6 @@ QQ交流群:
Tg群组:
https://t.me/cursor_byok
- 修复commands无法识别的问题 @DedSecer
- 修复 summarize 指令(主动压缩) @DedSecer
- 修复 MiniMax 禁止 thinking 的问题 @Octopus
- 支持Fork message (需要新开对话) @DedSecer
- 支持 @关联对话 @DedSecer
- 支持插件市场(须登录你的任意账号) @aike1202
- 新增支持 cursor debugger 调试器, 用法请看.agents/skills/coding-guidance/SKILL.md
- 支持cursor-cli
- 修复对话中错误可能导致的消失问题