mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 19:47:10 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c9a07e018 | ||
|
|
1adca7f600 | ||
|
|
5df3e8f512 | ||
|
|
a2e3a07773 | ||
|
|
6b7c077448 | ||
|
|
c64911e8c6 | ||
|
|
d1d56bd6eb | ||
|
|
ee47f187f0 | ||
|
|
424235f11d | ||
|
|
58770aa206 | ||
|
|
eb0ad7b636 | ||
|
|
697fa99245 | ||
|
|
1854a3ab9a | ||
|
|
2c650b3765 | ||
|
|
14b286a466 | ||
|
|
828e0a9941 |
@@ -23,6 +23,29 @@ description: 本地模式实现指南
|
|||||||
客户端是:/Users/leokun/Library/Application\ Support/Cursor
|
客户端是:/Users/leokun/Library/Application\ Support/Cursor
|
||||||
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
|
客户端 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 客户端格式化快照
|
||||||
|
|
||||||
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
|
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ https://t.me/cursor_byok
|
|||||||
## 路线图
|
## 路线图
|
||||||
|
|
||||||
[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)
|
[正式版路线图](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>
|
|
||||||
|
|||||||
@@ -178,6 +178,16 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
- 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:
|
ads:install:
|
||||||
summary: 安装广告页依赖
|
summary: 安装广告页依赖
|
||||||
dir: '{{.TASKFILE_DIR}}/ads-page'
|
dir: '{{.TASKFILE_DIR}}/ads-page'
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ info:
|
|||||||
description: "Cursor助手"
|
description: "Cursor助手"
|
||||||
copyright: "© 2026, Cursor助手"
|
copyright: "© 2026, Cursor助手"
|
||||||
comments: "Cursor助手"
|
comments: "Cursor助手"
|
||||||
version: "0.0.43"
|
version: "0.0.44"
|
||||||
|
|
||||||
dev_mode:
|
dev_mode:
|
||||||
root_path: .
|
root_path: .
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.0.43</string>
|
<string>0.0.44</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>0.0.43</string>
|
<string>0.0.44</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0.0</string>
|
<string>12.0.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.0.43</string>
|
<string>0.0.44</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>0.0.43</string>
|
<string>0.0.44</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0.0</string>
|
<string>12.0.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
name: "Cursor助手"
|
name: "Cursor助手"
|
||||||
arch: ${GOARCH}
|
arch: ${GOARCH}
|
||||||
platform: "linux"
|
platform: "linux"
|
||||||
version: "0.0.43"
|
version: "0.0.44"
|
||||||
section: "default"
|
section: "default"
|
||||||
priority: "extra"
|
priority: "extra"
|
||||||
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"fixed": {
|
"fixed": {
|
||||||
"file_version": "0.0.43"
|
"file_version": "0.0.44"
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"0000": {
|
"0000": {
|
||||||
"ProductVersion": "0.0.43",
|
"ProductVersion": "0.0.44",
|
||||||
"CompanyName": "Cursor助手",
|
"CompanyName": "Cursor助手",
|
||||||
"FileDescription": "Cursor助手",
|
"FileDescription": "Cursor助手",
|
||||||
"LegalCopyright": "© 2026, Cursor助手",
|
"LegalCopyright": "© 2026, Cursor助手",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
!define INFO_PRODUCTNAME "Cursor助手"
|
!define INFO_PRODUCTNAME "Cursor助手"
|
||||||
!endif
|
!endif
|
||||||
!ifndef INFO_PRODUCTVERSION
|
!ifndef INFO_PRODUCTVERSION
|
||||||
!define INFO_PRODUCTVERSION "0.0.43"
|
!define INFO_PRODUCTVERSION "0.0.44"
|
||||||
!endif
|
!endif
|
||||||
!ifndef INFO_COPYRIGHT
|
!ifndef INFO_COPYRIGHT
|
||||||
!define INFO_COPYRIGHT "© 2026, Cursor助手"
|
!define INFO_COPYRIGHT "© 2026, Cursor助手"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?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">
|
<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.44" processorArchitecture="*"/>
|
||||||
<dependency>
|
<dependency>
|
||||||
<dependentAssembly>
|
<dependentAssembly>
|
||||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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;代理转发的数据不会被截断。
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1903,11 +1903,13 @@ func openAIThinkingDisableKind(baseURL string, modelID string, endpoint string)
|
|||||||
strings.Contains(base, "zhipu") ||
|
strings.Contains(base, "zhipu") ||
|
||||||
strings.Contains(base, "xiaomimimo") ||
|
strings.Contains(base, "xiaomimimo") ||
|
||||||
strings.Contains(base, "mimo") ||
|
strings.Contains(base, "mimo") ||
|
||||||
|
strings.Contains(base, "minimax") ||
|
||||||
strings.Contains(model, "deepseek") ||
|
strings.Contains(model, "deepseek") ||
|
||||||
strings.Contains(model, "glm") ||
|
strings.Contains(model, "glm") ||
|
||||||
strings.Contains(model, "zai") ||
|
strings.Contains(model, "zai") ||
|
||||||
strings.Contains(model, "zhipu") ||
|
strings.Contains(model, "zhipu") ||
|
||||||
strings.Contains(model, "mimo"):
|
strings.Contains(model, "mimo") ||
|
||||||
|
strings.Contains(model, "minimax"):
|
||||||
return "thinking_type"
|
return "thinking_type"
|
||||||
case openAIModelSupportsReasoningNone(model):
|
case openAIModelSupportsReasoningNone(model):
|
||||||
return "reasoning_none"
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
TurnPhaseWaitingExternal TurnPhase = "waiting_external"
|
TurnPhaseWaitingExternal TurnPhase = "waiting_external"
|
||||||
TurnPhaseAwaitingUser TurnPhase = "awaiting_user"
|
TurnPhaseAwaitingUser TurnPhase = "awaiting_user"
|
||||||
TurnPhaseCompacting TurnPhase = "compacting"
|
TurnPhaseCompacting TurnPhase = "compacting"
|
||||||
|
TurnPhaseCheckpointing TurnPhase = "checkpointing"
|
||||||
TurnPhaseCompleted TurnPhase = "completed"
|
TurnPhaseCompleted TurnPhase = "completed"
|
||||||
TurnPhaseFailed TurnPhase = "failed"
|
TurnPhaseFailed TurnPhase = "failed"
|
||||||
TurnPhaseCanceled TurnPhase = "canceled"
|
TurnPhaseCanceled TurnPhase = "canceled"
|
||||||
@@ -66,6 +67,7 @@ const (
|
|||||||
streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery"
|
streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery"
|
||||||
streamTimerShellForeground streamTimerKind = "shell_foreground"
|
streamTimerShellForeground streamTimerKind = "shell_foreground"
|
||||||
streamTimerShellTransportClose streamTimerKind = "shell_transport_close"
|
streamTimerShellTransportClose streamTimerKind = "shell_transport_close"
|
||||||
|
streamTimerCheckpointBlobs streamTimerKind = "checkpoint_blobs"
|
||||||
streamTimerOrphanCancel streamTimerKind = "orphan_cancel"
|
streamTimerOrphanCancel streamTimerKind = "orphan_cancel"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -318,6 +320,9 @@ func (service *Service) handleStreamCommand(stream *ActiveStream, command stream
|
|||||||
case streamCommandCancel:
|
case streamCommandCancel:
|
||||||
return service.handleCancelIntent(command.Intent)
|
return service.handleCancelIntent(command.Intent)
|
||||||
case streamCommandMetadata:
|
case streamCommandMetadata:
|
||||||
|
if strings.TrimSpace(command.Intent.Kind) == "kv_result" {
|
||||||
|
return service.handleCheckpointBlobResult(stream, command.Intent.KVClientMessage)
|
||||||
|
}
|
||||||
return service.handleMetadataIntent(command.Intent)
|
return service.handleMetadataIntent(command.Intent)
|
||||||
case streamCommandExecResult:
|
case streamCommandExecResult:
|
||||||
return service.handleExecResult(command.Intent)
|
return service.handleExecResult(command.Intent)
|
||||||
@@ -1003,6 +1008,8 @@ func (service *Service) handleTimerEvent(stream *ActiveStream, payload *streamTi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed)
|
return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed)
|
||||||
|
case streamTimerCheckpointBlobs:
|
||||||
|
return service.handleCheckpointBlobTimeout(stream)
|
||||||
case streamTimerOrphanCancel:
|
case streamTimerOrphanCancel:
|
||||||
stream.mu.Lock()
|
stream.mu.Lock()
|
||||||
subscriberCount := len(stream.Subscribers)
|
subscriberCount := len(stream.Subscribers)
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
checkpointBlobWriteTimeout = 10 * time.Second
|
||||||
|
checkpointBlobCacheIdleTTL = 6 * time.Hour
|
||||||
|
checkpointBlobCacheMaxConversations = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
type checkpointBlobCacheEntry struct {
|
||||||
|
Confirmed map[string]struct{}
|
||||||
|
LastAccess time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointBlobKey(id []byte) string {
|
||||||
|
return string(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointBlobHex(key string) string {
|
||||||
|
return hex.EncodeToString([]byte(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) confirmedCheckpointBlob(conversationID string, key string) bool {
|
||||||
|
if service == nil || key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
service.checkpointBlobMu.Lock()
|
||||||
|
defer service.checkpointBlobMu.Unlock()
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
entry := service.checkpointBlobs[conversationID]
|
||||||
|
if entry == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
entry.LastAccess = time.Now().UTC()
|
||||||
|
_, ok := entry.Confirmed[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) confirmCheckpointBlob(conversationID string, key string) {
|
||||||
|
if service == nil || key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service.checkpointBlobMu.Lock()
|
||||||
|
defer service.checkpointBlobMu.Unlock()
|
||||||
|
if service.checkpointBlobs == nil {
|
||||||
|
service.checkpointBlobs = make(map[string]*checkpointBlobCacheEntry)
|
||||||
|
}
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
entry := service.checkpointBlobs[conversationID]
|
||||||
|
if entry == nil {
|
||||||
|
entry = &checkpointBlobCacheEntry{Confirmed: make(map[string]struct{})}
|
||||||
|
service.checkpointBlobs[conversationID] = entry
|
||||||
|
}
|
||||||
|
entry.Confirmed[key] = struct{}{}
|
||||||
|
entry.LastAccess = now
|
||||||
|
service.pruneCheckpointBlobCacheLocked(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) pruneCheckpointBlobCacheLocked(now time.Time) {
|
||||||
|
if service == nil || len(service.checkpointBlobs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := now.Add(-checkpointBlobCacheIdleTTL)
|
||||||
|
for conversationID, entry := range service.checkpointBlobs {
|
||||||
|
if entry == nil || entry.LastAccess.Before(cutoff) {
|
||||||
|
delete(service.checkpointBlobs, conversationID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(service.checkpointBlobs) > checkpointBlobCacheMaxConversations {
|
||||||
|
oldestConversationID := ""
|
||||||
|
oldestAccess := now
|
||||||
|
for conversationID, entry := range service.checkpointBlobs {
|
||||||
|
if entry == nil || oldestConversationID == "" || entry.LastAccess.Before(oldestAccess) {
|
||||||
|
oldestConversationID = conversationID
|
||||||
|
if entry != nil {
|
||||||
|
oldestAccess = entry.LastAccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestConversationID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(service.checkpointBlobs, oldestConversationID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointCompletionAction(completion *pendingTurnCompletion) checkpointTerminalAction {
|
||||||
|
if completion == nil {
|
||||||
|
return checkpointTerminalAction{kind: checkpointTerminalActionNone}
|
||||||
|
}
|
||||||
|
return checkpointTerminalAction{
|
||||||
|
kind: checkpointTerminalActionComplete,
|
||||||
|
completion: *clonePendingTurnCompletion(completion),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointCancellationAction(message string) checkpointTerminalAction {
|
||||||
|
return checkpointTerminalAction{
|
||||||
|
kind: checkpointTerminalActionCancel,
|
||||||
|
cancelMessage: firstNonEmpty(strings.TrimSpace(message), "[canceled] User aborted request"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeCheckpointTerminalAction(current checkpointTerminalAction, incoming checkpointTerminalAction) checkpointTerminalAction {
|
||||||
|
switch {
|
||||||
|
case incoming.kind == checkpointTerminalActionCancel:
|
||||||
|
return incoming
|
||||||
|
case current.kind == checkpointTerminalActionCancel:
|
||||||
|
return current
|
||||||
|
case incoming.kind == checkpointTerminalActionComplete:
|
||||||
|
return incoming
|
||||||
|
default:
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (action checkpointTerminalAction) completionValue() *pendingTurnCompletion {
|
||||||
|
if action.kind != checkpointTerminalActionComplete {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return clonePendingTurnCompletion(&action.completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) queueCheckpointProjection(stream *ActiveStream, projection *CheckpointProjection, terminalAction checkpointTerminalAction) 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]pendingCheckpointBlobWrite)
|
||||||
|
}
|
||||||
|
if stream.PendingCheckpointBlobRequests == nil {
|
||||||
|
stream.PendingCheckpointBlobRequests = make(map[string]uint32)
|
||||||
|
}
|
||||||
|
stream.NextCheckpointRevision++
|
||||||
|
if stream.NextCheckpointRevision == 0 {
|
||||||
|
stream.NextCheckpointRevision++
|
||||||
|
}
|
||||||
|
revision := stream.NextCheckpointRevision
|
||||||
|
if stream.PendingCheckpoint != nil {
|
||||||
|
terminalAction = mergeCheckpointTerminalAction(stream.PendingCheckpoint.TerminalAction, terminalAction)
|
||||||
|
}
|
||||||
|
required := make(map[string]struct{}, len(projection.Blobs))
|
||||||
|
toWrite := make([]struct {
|
||||||
|
requestID uint32
|
||||||
|
blob CheckpointBlob
|
||||||
|
}, 0, len(projection.Blobs))
|
||||||
|
for _, blob := range projection.Blobs {
|
||||||
|
key := checkpointBlobKey(blob.ID)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
required[key] = struct{}{}
|
||||||
|
if service.confirmedCheckpointBlob(stream.ConversationID, key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, pending := stream.PendingCheckpointBlobRequests[key]; pending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stream.NextCheckpointBlobRequestID++
|
||||||
|
if stream.NextCheckpointBlobRequestID == 0 {
|
||||||
|
stream.NextCheckpointBlobRequestID++
|
||||||
|
}
|
||||||
|
requestID := stream.NextCheckpointBlobRequestID
|
||||||
|
stream.PendingCheckpointBlobWrites[requestID] = pendingCheckpointBlobWrite{
|
||||||
|
Key: key,
|
||||||
|
Revision: revision,
|
||||||
|
}
|
||||||
|
stream.PendingCheckpointBlobRequests[key] = requestID
|
||||||
|
toWrite = append(toWrite, struct {
|
||||||
|
requestID uint32
|
||||||
|
blob CheckpointBlob
|
||||||
|
}{requestID: requestID, blob: blob})
|
||||||
|
}
|
||||||
|
stream.PendingCheckpoint = &pendingCheckpointPublish{
|
||||||
|
Revision: revision,
|
||||||
|
State: state,
|
||||||
|
Required: required,
|
||||||
|
TerminalAction: terminalAction,
|
||||||
|
}
|
||||||
|
if terminalAction.kind != checkpointTerminalActionNone {
|
||||||
|
stream.Phase = TurnPhaseCheckpointing
|
||||||
|
}
|
||||||
|
for requestID, write := range stream.PendingCheckpointBlobWrites {
|
||||||
|
if _, stillRequired := required[write.Key]; stillRequired {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(stream.PendingCheckpointBlobWrites, requestID)
|
||||||
|
delete(stream.PendingCheckpointBlobRequests, write.Key)
|
||||||
|
}
|
||||||
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
|
stream.mu.Unlock()
|
||||||
|
|
||||||
|
for _, item := range toWrite {
|
||||||
|
if err := service.broker.Publish(stream.RequestID, StreamEvent{
|
||||||
|
Message: buildSetCheckpointBlobMessage(item.requestID, item.blob),
|
||||||
|
}); err != nil {
|
||||||
|
service.discardPendingCheckpoint(stream, fmt.Errorf("publish checkpoint blob write: %w", err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toWrite) > 0 {
|
||||||
|
service.scheduleStreamTimer(
|
||||||
|
stream,
|
||||||
|
providerTimerKey(streamTimerCheckpointBlobs, ""),
|
||||||
|
checkpointBlobWriteTimeout,
|
||||||
|
streamTimerCheckpointBlobs,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
"checkpoint blob write timeout",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return service.publishReadyCheckpoint(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePendingTurnCompletion(completion *pendingTurnCompletion) *pendingTurnCompletion {
|
||||||
|
if completion == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *completion
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) handleCheckpointBlobResult(stream *ActiveStream, message *agentv1.KvClientMessage) error {
|
||||||
|
if service == nil || stream == nil || message == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := message.GetSetBlobResult()
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
write, ok := stream.PendingCheckpointBlobWrites[message.GetId()]
|
||||||
|
pendingRequiresBlob := false
|
||||||
|
if ok {
|
||||||
|
delete(stream.PendingCheckpointBlobWrites, message.GetId())
|
||||||
|
delete(stream.PendingCheckpointBlobRequests, write.Key)
|
||||||
|
if stream.PendingCheckpoint != nil {
|
||||||
|
_, pendingRequiresBlob = stream.PendingCheckpoint.Required[write.Key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conversationID := stream.ConversationID
|
||||||
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if result.GetError() != nil {
|
||||||
|
if !pendingRequiresBlob {
|
||||||
|
return service.publishReadyCheckpoint(stream)
|
||||||
|
}
|
||||||
|
return service.abandonPendingCheckpoint(stream, fmt.Errorf(
|
||||||
|
"write checkpoint blob %s: %s",
|
||||||
|
checkpointBlobHex(write.Key),
|
||||||
|
firstNonEmpty(result.GetError().GetMessage(), "client blob store rejected write"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
service.confirmCheckpointBlob(conversationID, write.Key)
|
||||||
|
return service.publishReadyCheckpoint(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 !service.confirmedCheckpointBlob(stream.ConversationID, key) {
|
||||||
|
stream.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.PendingCheckpoint = nil
|
||||||
|
state := pending.State
|
||||||
|
terminalAction := pending.TerminalAction
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch terminalAction.kind {
|
||||||
|
case checkpointTerminalActionComplete:
|
||||||
|
if completion := terminalAction.completionValue(); completion != nil {
|
||||||
|
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
|
||||||
|
}
|
||||||
|
case checkpointTerminalActionCancel:
|
||||||
|
return service.finishCanceledTurnAfterCheckpoint(stream, terminalAction.cancelMessage)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) discardPendingCheckpoint(stream *ActiveStream, cause error) {
|
||||||
|
if service == nil || stream == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
stream.PendingCheckpoint = nil
|
||||||
|
stream.PendingCheckpointBlobWrites = make(map[uint32]pendingCheckpointBlobWrite)
|
||||||
|
stream.PendingCheckpointBlobRequests = make(map[string]uint32)
|
||||||
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
|
stream.mu.Unlock()
|
||||||
|
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||||
|
if cause != nil {
|
||||||
|
log.Printf("forwarder pending checkpoint discarded request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) abandonPendingCheckpoint(stream *ActiveStream, cause error) error {
|
||||||
|
if service == nil || stream == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
pending := stream.PendingCheckpoint
|
||||||
|
stream.PendingCheckpoint = nil
|
||||||
|
stream.PendingCheckpointBlobWrites = make(map[uint32]pendingCheckpointBlobWrite)
|
||||||
|
stream.PendingCheckpointBlobRequests = make(map[string]uint32)
|
||||||
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
|
stream.mu.Unlock()
|
||||||
|
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||||
|
if cause != nil {
|
||||||
|
log.Printf("forwarder checkpoint blob sync abandoned request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, cause)
|
||||||
|
}
|
||||||
|
if pending != nil {
|
||||||
|
return service.failTerminalCheckpointSync(stream, cause)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) finishCanceledTurnAfterCheckpoint(stream *ActiveStream, message string) error {
|
||||||
|
if stream == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
service.setTurnPhase(stream, TurnPhaseCanceled)
|
||||||
|
return service.broker.Cancel(stream.RequestID, firstNonEmpty(strings.TrimSpace(message), "[canceled] User aborted request"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) failTerminalCheckpointSync(stream *ActiveStream, cause error) error {
|
||||||
|
if stream == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
message := "checkpoint synchronization failed"
|
||||||
|
if cause != nil && strings.TrimSpace(cause.Error()) != "" {
|
||||||
|
message = strings.TrimSpace(cause.Error())
|
||||||
|
}
|
||||||
|
service.setTurnPhase(stream, TurnPhaseFailed)
|
||||||
|
return service.broker.Fail(stream.RequestID, "checkpoint_sync_error", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) handleCheckpointBlobTimeout(stream *ActiveStream) error {
|
||||||
|
if stream == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
pendingCount := len(stream.PendingCheckpointBlobWrites)
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if pendingCount == 0 {
|
||||||
|
return service.publishReadyCheckpoint(stream)
|
||||||
|
}
|
||||||
|
return service.abandonPendingCheckpoint(stream, fmt.Errorf("%d checkpoint blob writes timed out", pendingCount))
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckpointBlobSyncPublishesCheckpointAfterAllWrites(t *testing.T) {
|
||||||
|
service, stream := testCheckpointBlobService(t)
|
||||||
|
projection, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "hello"),
|
||||||
|
newAssistantTextEntry(1, "request-1", "hi", "", ""),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.queueCheckpointProjection(stream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
|
||||||
|
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFromCursor() error = %v", err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, event := range events {
|
||||||
|
requestID := event.Message.GetKvServerMessage().GetId()
|
||||||
|
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||||
|
Id: requestID,
|
||||||
|
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||||
|
SetBlobResult: &agentv1.SetBlobResult{},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleCheckpointBlobResult(%d) error = %v", index, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events, err = service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFromCursor() after ACK error = %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != len(projection.Blobs)+1 {
|
||||||
|
t.Fatalf("events after ACK = %d, want %d", len(events), len(projection.Blobs)+1)
|
||||||
|
}
|
||||||
|
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 TestCheckpointBlobSyncRejectDoesNotPublishDanglingCheckpoint(t *testing.T) {
|
||||||
|
service, stream := testCheckpointBlobService(t)
|
||||||
|
projection, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "hello"),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(stream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
|
||||||
|
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil || len(events) == 0 {
|
||||||
|
t.Fatalf("blob write events = %d, err = %v", len(events), err)
|
||||||
|
}
|
||||||
|
requestID := events[0].Message.GetKvServerMessage().GetId()
|
||||||
|
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||||
|
Id: requestID,
|
||||||
|
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||||
|
SetBlobResult: &agentv1.SetBlobResult{Error: &agentv1.Error{Message: "disk full"}},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleCheckpointBlobResult() error = %v", err)
|
||||||
|
}
|
||||||
|
events, err = service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFromCursor() after rejection error = %v", err)
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
if event.Message.GetConversationCheckpointUpdate() != nil {
|
||||||
|
t.Fatal("rejected Blob write published a dangling checkpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminalCheckpointRejectionFailsInsteadOfCompleting(t *testing.T) {
|
||||||
|
service, stream := testCheckpointBlobService(t)
|
||||||
|
projection, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, stream.RequestID, "hello"),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("projection: %v", err)
|
||||||
|
}
|
||||||
|
completion := &pendingTurnCompletion{RequestID: stream.RequestID}
|
||||||
|
if err := service.queueCheckpointProjection(stream, projection, checkpointCompletionAction(completion)); err != nil {
|
||||||
|
t.Fatalf("queue checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
var requestID uint32
|
||||||
|
for pendingID := range stream.PendingCheckpointBlobWrites {
|
||||||
|
requestID = pendingID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if requestID == 0 {
|
||||||
|
t.Fatal("test did not queue a Blob write")
|
||||||
|
}
|
||||||
|
if err := rejectCheckpointBlob(service, stream, requestID, "disk full"); err != nil {
|
||||||
|
t.Fatalf("reject terminal checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFromCursor() error = %v", err)
|
||||||
|
}
|
||||||
|
var failed, completed bool
|
||||||
|
for _, event := range events {
|
||||||
|
if event.End && event.TerminalErrorCode == "checkpoint_sync_error" {
|
||||||
|
failed = true
|
||||||
|
}
|
||||||
|
if event.Message.GetInteractionUpdate().GetTurnEnded() != nil {
|
||||||
|
completed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !failed || completed {
|
||||||
|
t.Fatalf("terminal checkpoint events failed=%v completed=%v", failed, completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointBlobSyncMergesRevisionsWithoutObsoleteFailure(t *testing.T) {
|
||||||
|
service, stream := testCheckpointBlobService(t)
|
||||||
|
first, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "hello"),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
latest, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "hello"),
|
||||||
|
newAssistantTextEntry(1, "request-1", "latest answer", "", ""),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("latest ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(stream, first, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
|
||||||
|
t.Fatalf("queue first projection: %v", err)
|
||||||
|
}
|
||||||
|
firstEvents, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read first events: %v", err)
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(stream, latest, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
|
||||||
|
t.Fatalf("queue latest projection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
latestRequired := make(map[string]struct{}, len(latest.Blobs))
|
||||||
|
for _, blob := range latest.Blobs {
|
||||||
|
latestRequired[string(blob.ID)] = struct{}{}
|
||||||
|
}
|
||||||
|
var obsoleteRequestID uint32
|
||||||
|
for _, event := range firstEvents {
|
||||||
|
message := event.Message.GetKvServerMessage()
|
||||||
|
if message == nil || message.GetSetBlobArgs() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, required := latestRequired[string(message.GetSetBlobArgs().GetBlobId())]; !required {
|
||||||
|
obsoleteRequestID = message.GetId()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if obsoleteRequestID == 0 {
|
||||||
|
t.Fatal("test did not find an obsolete first-revision Blob write")
|
||||||
|
}
|
||||||
|
if err := rejectCheckpointBlob(service, stream, obsoleteRequestID, "obsolete write rejected"); err != nil {
|
||||||
|
t.Fatalf("reject obsolete Blob: %v", err)
|
||||||
|
}
|
||||||
|
if err := acknowledgePendingCheckpointBlobs(service, stream); err != nil {
|
||||||
|
t.Fatalf("acknowledge latest Blob writes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read merged events: %v", err)
|
||||||
|
}
|
||||||
|
checkpoints := 0
|
||||||
|
for _, event := range events {
|
||||||
|
if checkpoint := event.Message.GetConversationCheckpointUpdate(); checkpoint != nil {
|
||||||
|
checkpoints++
|
||||||
|
if len(checkpoint.GetTurns()) != len(latest.State.GetTurns()) || string(checkpoint.GetTurns()[0]) != string(latest.State.GetTurns()[0]) {
|
||||||
|
t.Fatalf("published checkpoint is not the latest revision: %#v", checkpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if checkpoints != 1 {
|
||||||
|
t.Fatalf("published checkpoints = %d, want exactly latest revision", checkpoints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointBlobSyncCarriesCompletionIntoLatestRevision(t *testing.T) {
|
||||||
|
service, stream := testCheckpointBlobService(t)
|
||||||
|
first, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "hello"),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first projection: %v", err)
|
||||||
|
}
|
||||||
|
latest, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "hello"),
|
||||||
|
newAssistantTextEntry(1, "request-1", "done", "", ""),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("latest projection: %v", err)
|
||||||
|
}
|
||||||
|
completion := &pendingTurnCompletion{
|
||||||
|
RequestID: stream.RequestID,
|
||||||
|
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(stream, first, checkpointCompletionAction(completion)); err != nil {
|
||||||
|
t.Fatalf("queue completion projection: %v", err)
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(stream, latest, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
|
||||||
|
t.Fatalf("queue latest projection: %v", err)
|
||||||
|
}
|
||||||
|
if err := acknowledgePendingCheckpointBlobs(service, stream); err != nil {
|
||||||
|
t.Fatalf("acknowledge latest Blob writes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read completion events: %v", err)
|
||||||
|
}
|
||||||
|
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 TestCheckpointBlobSyncTimeoutFailsStreamWithoutPublishingDanglingCheckpoint(t *testing.T) {
|
||||||
|
service, stream := testCheckpointBlobService(t)
|
||||||
|
projection, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "hello"),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("projection: %v", err)
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(stream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
|
||||||
|
t.Fatalf("queue checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
if err := service.handleCheckpointBlobTimeout(stream); err != nil {
|
||||||
|
t.Fatalf("timeout checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read timeout events: %v", err)
|
||||||
|
}
|
||||||
|
var failed bool
|
||||||
|
for _, event := range events {
|
||||||
|
if event.Message.GetConversationCheckpointUpdate() != nil {
|
||||||
|
t.Fatal("timed-out Blob dependency published a dangling checkpoint")
|
||||||
|
}
|
||||||
|
if event.End && event.TerminalErrorCode == "checkpoint_sync_error" {
|
||||||
|
failed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !failed {
|
||||||
|
t.Fatal("timed-out checkpoint did not fail the stream explicitly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointBlobSyncReusesConversationCacheAcrossRequests(t *testing.T) {
|
||||||
|
service, firstStream := testCheckpointBlobService(t)
|
||||||
|
projection, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "hello"),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("projection: %v", err)
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(firstStream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
|
||||||
|
t.Fatalf("queue first request: %v", err)
|
||||||
|
}
|
||||||
|
if err := acknowledgePendingCheckpointBlobs(service, firstStream); err != nil {
|
||||||
|
t.Fatalf("acknowledge first request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondStream, err := service.broker.OpenStream(
|
||||||
|
"request-2", firstStream.ConversationID, 2, "default", "default",
|
||||||
|
agentv1.AgentMode_AGENT_MODE_AGENT, "continue",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenStream() second request error = %v", err)
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(secondStream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
|
||||||
|
t.Fatalf("queue second request: %v", err)
|
||||||
|
}
|
||||||
|
events, err := service.broker.ReadFromCursor(secondStream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read second request events: %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 1 || events[0].Message.GetConversationCheckpointUpdate() == nil {
|
||||||
|
t.Fatalf("second request events = %#v, want cached immediate checkpoint", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancellationReplacesUnconfirmedCheckpointBeforeEnding(t *testing.T) {
|
||||||
|
service, stream := testCheckpointBlobService(t)
|
||||||
|
conversation := testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, stream.RequestID, "hello"),
|
||||||
|
})
|
||||||
|
if err := service.replaceCheckpointConversation(stream, conversation); err != nil {
|
||||||
|
t.Fatalf("replaceCheckpointConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
projection, err := service.projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(stream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
|
||||||
|
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
var staleRequestID uint32
|
||||||
|
for requestID := range stream.PendingCheckpointBlobWrites {
|
||||||
|
staleRequestID = requestID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if staleRequestID == 0 {
|
||||||
|
t.Fatal("test did not queue an unconfirmed Blob write")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.handleCancelIntent(InboundIntent{
|
||||||
|
Kind: "cancel",
|
||||||
|
RequestID: stream.RequestID,
|
||||||
|
CancelReason: "user stopped",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleCancelIntent() error = %v", err)
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
phase := stream.Phase
|
||||||
|
status := stream.Status
|
||||||
|
pendingCheckpoint := stream.PendingCheckpoint
|
||||||
|
pendingWrites := len(stream.PendingCheckpointBlobWrites)
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if phase != TurnPhaseCheckpointing || status != StreamStatusCreated {
|
||||||
|
t.Fatalf("before checkpoint ACK phase=%s status=%s, want checkpointing/created", phase, status)
|
||||||
|
}
|
||||||
|
if pendingCheckpoint == nil || pendingWrites == 0 {
|
||||||
|
t.Fatalf("before checkpoint ACK pending_checkpoint=%v pending_writes=%d", pendingCheckpoint != nil, pendingWrites)
|
||||||
|
}
|
||||||
|
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||||
|
Id: staleRequestID,
|
||||||
|
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||||
|
SetBlobResult: &agentv1.SetBlobResult{},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("stale Blob ACK error = %v", err)
|
||||||
|
}
|
||||||
|
if err := acknowledgePendingCheckpointBlobs(service, stream); err != nil {
|
||||||
|
t.Fatalf("acknowledge cancellation checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
phase = stream.Phase
|
||||||
|
status = stream.Status
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if phase != TurnPhaseCanceled || status != StreamStatusCanceled {
|
||||||
|
t.Fatalf("after checkpoint ACK phase=%s status=%s, want canceled", phase, status)
|
||||||
|
}
|
||||||
|
assertCanceledEndEvent(t, service, stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancellationMetadataFailureStillEndsStream(t *testing.T) {
|
||||||
|
service, stream := testCheckpointBlobService(t)
|
||||||
|
blockingPath := filepath.Join(t.TempDir(), "not-a-directory")
|
||||||
|
if err := os.WriteFile(blockingPath, []byte("block child creation"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
service.store = NewConversationFileStore(blockingPath)
|
||||||
|
if err := service.replaceCheckpointConversation(stream, testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, stream.RequestID, "hello"),
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatalf("replaceCheckpointConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.handleCancelIntent(InboundIntent{Kind: "cancel", RequestID: stream.RequestID}); err != nil {
|
||||||
|
t.Fatalf("handleCancelIntent() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := acknowledgePendingCheckpointBlobs(service, stream); err != nil {
|
||||||
|
t.Fatalf("acknowledge cancellation checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
assertCanceledEndEvent(t, service, stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointTerminalActionMergePriority(t *testing.T) {
|
||||||
|
complete := checkpointCompletionAction(&pendingTurnCompletion{RequestID: "complete"})
|
||||||
|
cancel := checkpointCancellationAction("user canceled")
|
||||||
|
none := checkpointTerminalAction{kind: checkpointTerminalActionNone}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
current checkpointTerminalAction
|
||||||
|
incoming checkpointTerminalAction
|
||||||
|
wantKind checkpointTerminalActionKind
|
||||||
|
wantID string
|
||||||
|
}{
|
||||||
|
{name: "none then complete", current: none, incoming: complete, wantKind: checkpointTerminalActionComplete, wantID: "complete"},
|
||||||
|
{name: "complete then none", current: complete, incoming: none, wantKind: checkpointTerminalActionComplete, wantID: "complete"},
|
||||||
|
{name: "complete then cancel", current: complete, incoming: cancel, wantKind: checkpointTerminalActionCancel},
|
||||||
|
{name: "cancel then complete", current: cancel, incoming: complete, wantKind: checkpointTerminalActionCancel},
|
||||||
|
{name: "cancel then none", current: cancel, incoming: none, wantKind: checkpointTerminalActionCancel},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
merged := mergeCheckpointTerminalAction(test.current, test.incoming)
|
||||||
|
if merged.kind != test.wantKind {
|
||||||
|
t.Fatalf("merged kind = %d, want %d", merged.kind, test.wantKind)
|
||||||
|
}
|
||||||
|
if test.wantID != "" {
|
||||||
|
completion := merged.completionValue()
|
||||||
|
if completion == nil || completion.RequestID != test.wantID {
|
||||||
|
t.Fatalf("merged completion = %#v, want request_id=%s", completion, test.wantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointTerminalActionIsMutuallyExclusive(t *testing.T) {
|
||||||
|
completion := &pendingTurnCompletion{RequestID: "request-1"}
|
||||||
|
action := checkpointCompletionAction(completion)
|
||||||
|
if action.kind != checkpointTerminalActionComplete || action.completionValue() == nil {
|
||||||
|
t.Fatalf("completion action = %#v", action)
|
||||||
|
}
|
||||||
|
empty := checkpointCompletionAction(nil)
|
||||||
|
if empty.kind != checkpointTerminalActionNone || empty.completionValue() != nil {
|
||||||
|
t.Fatalf("empty action = %#v", empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCanceledEndEvent(t *testing.T, service *Service, stream *ActiveStream) {
|
||||||
|
t.Helper()
|
||||||
|
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFromCursor() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
if event.End && event.TerminalErrorCode == "canceled" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("cancellation did not publish canceled end event")
|
||||||
|
}
|
||||||
|
|
||||||
|
func acknowledgePendingCheckpointBlobs(service *Service, stream *ActiveStream) error {
|
||||||
|
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 nil
|
||||||
|
}
|
||||||
|
for _, requestID := range requestIDs {
|
||||||
|
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||||
|
Id: requestID,
|
||||||
|
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||||
|
SetBlobResult: &agentv1.SetBlobResult{},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectCheckpointBlob(service *Service, stream *ActiveStream, requestID uint32, message string) error {
|
||||||
|
return service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||||
|
Id: requestID,
|
||||||
|
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||||
|
SetBlobResult: &agentv1.SetBlobResult{Error: &agentv1.Error{Message: message}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportedTurnIDsRemainCheckpointPrefix(t *testing.T) {
|
||||||
|
importedID := make([]byte, 32)
|
||||||
|
for index := range importedID {
|
||||||
|
importedID[index] = byte(index + 1)
|
||||||
|
}
|
||||||
|
conversation := testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 2, "request-2", "continued question"),
|
||||||
|
})
|
||||||
|
conversation.ImportedTurnIDs = [][]byte{importedID}
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(projection.State.GetTurns()) != 2 {
|
||||||
|
t.Fatalf("turns = %d, want imported prefix plus projected turn", len(projection.State.GetTurns()))
|
||||||
|
}
|
||||||
|
if string(projection.State.GetTurns()[0]) != string(importedID) {
|
||||||
|
t.Fatal("imported turn ID was not preserved as the checkpoint prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheckpointBlobService(t *testing.T) (*Service, *ActiveStream) {
|
||||||
|
t.Helper()
|
||||||
|
broker := NewStreamBroker()
|
||||||
|
service := &Service{
|
||||||
|
projector: NewHistoryProjector(),
|
||||||
|
broker: broker,
|
||||||
|
checkpointBlobs: make(map[string]*checkpointBlobCacheEntry),
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return service, stream
|
||||||
|
}
|
||||||
@@ -102,6 +102,8 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
|
|||||||
BackgroundShellsByMessageID: make(map[uint32]string),
|
BackgroundShellsByMessageID: make(map[uint32]string),
|
||||||
BackgroundShellsByExecID: make(map[string]string),
|
BackgroundShellsByExecID: make(map[string]string),
|
||||||
BackgroundShellActions: make(map[string]time.Time),
|
BackgroundShellActions: make(map[string]time.Time),
|
||||||
|
PendingCheckpointBlobWrites: make(map[uint32]pendingCheckpointBlobWrite),
|
||||||
|
PendingCheckpointBlobRequests: make(map[string]uint32),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 控制消息。
|
// buildExecAbortMessage 构造对客户端执行桥的 abort 控制消息。
|
||||||
func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage {
|
func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage {
|
||||||
return &agentv1.AgentServerMessage{
|
return &agentv1.AgentServerMessage{
|
||||||
|
|||||||
@@ -750,6 +750,7 @@ func mergeConversationMetadata(target *ConversationFile, source *ConversationFil
|
|||||||
target.CurrentPlanText = source.CurrentPlanText
|
target.CurrentPlanText = source.CurrentPlanText
|
||||||
target.CurrentPlans = clonePlanRegistryEntries(source.CurrentPlans)
|
target.CurrentPlans = clonePlanRegistryEntries(source.CurrentPlans)
|
||||||
target.CurrentTodos = cloneTodoItems(source.CurrentTodos)
|
target.CurrentTodos = cloneTodoItems(source.CurrentTodos)
|
||||||
|
target.ImportedTurnIDs = cloneByteSlices(source.ImportedTurnIDs)
|
||||||
target.LatestRequestPrefix = cloneConversationRequestPrefix(source.LatestRequestPrefix)
|
target.LatestRequestPrefix = cloneConversationRequestPrefix(source.LatestRequestPrefix)
|
||||||
target.LastProviderCall = cloneConversationProviderCall(source.LastProviderCall)
|
target.LastProviderCall = cloneConversationProviderCall(source.LastProviderCall)
|
||||||
if !source.CreatedAt.IsZero() && (target.CreatedAt.IsZero() || source.CreatedAt.Before(target.CreatedAt)) {
|
if !source.CreatedAt.IsZero() && (target.CreatedAt.IsZero() || source.CreatedAt.Before(target.CreatedAt)) {
|
||||||
@@ -882,6 +883,7 @@ func cloneConversationFile(conversation *ConversationFile) *ConversationFile {
|
|||||||
cloned := *conversation
|
cloned := *conversation
|
||||||
cloned.CurrentPlans = clonePlanRegistryEntries(conversation.CurrentPlans)
|
cloned.CurrentPlans = clonePlanRegistryEntries(conversation.CurrentPlans)
|
||||||
cloned.CurrentTodos = cloneTodoItems(conversation.CurrentTodos)
|
cloned.CurrentTodos = cloneTodoItems(conversation.CurrentTodos)
|
||||||
|
cloned.ImportedTurnIDs = cloneByteSlices(conversation.ImportedTurnIDs)
|
||||||
cloned.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
|
cloned.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
|
||||||
cloned.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
|
cloned.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
|
||||||
cloned.Entries = append([]HistoryEntry(nil), conversation.Entries...)
|
cloned.Entries = append([]HistoryEntry(nil), conversation.Entries...)
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
modeladapter "cursor/internal/backend/agent/model"
|
||||||
|
promptengine "cursor/internal/backend/agent/prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type importedBlobStore map[string][]byte
|
||||||
|
|
||||||
|
func newImportedBlobStore(items []*agentv1.PreFetchedBlob) (importedBlobStore, error) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
store := make(importedBlobStore, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil || len(item.GetId()) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(item.GetId()) != sha256.Size {
|
||||||
|
return nil, fmt.Errorf("prefetched blob id length %d, want %d", len(item.GetId()), sha256.Size)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(item.GetValue())
|
||||||
|
if string(digest[:]) != string(item.GetId()) {
|
||||||
|
return nil, fmt.Errorf("prefetched blob %x failed SHA-256 validation", item.GetId())
|
||||||
|
}
|
||||||
|
store[string(item.GetId())] = append([]byte(nil), item.GetValue()...)
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store importedBlobStore) resolve(id []byte) ([]byte, bool) {
|
||||||
|
if len(id) == 0 || len(store) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
value, ok := store[string(id)]
|
||||||
|
return append([]byte(nil), value...), ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeImportedTurn(raw []byte, blobs importedBlobStore) (*agentv1.ConversationTurnStructure, []byte, error) {
|
||||||
|
if data, ok := blobs.resolve(raw); ok {
|
||||||
|
turn := &agentv1.ConversationTurnStructure{}
|
||||||
|
if err := proto.Unmarshal(data, turn); err != nil || turn.GetTurn() == nil {
|
||||||
|
return nil, nil, fmt.Errorf("decode imported turn blob %x: %w", raw, firstNonNilError(err, fmt.Errorf("turn payload is empty")))
|
||||||
|
}
|
||||||
|
return turn, append([]byte(nil), raw...), nil
|
||||||
|
}
|
||||||
|
turn := &agentv1.ConversationTurnStructure{}
|
||||||
|
if err := proto.Unmarshal(raw, turn); err == nil && turn.GetTurn() != nil {
|
||||||
|
return turn, nil, nil
|
||||||
|
}
|
||||||
|
if len(raw) == sha256.Size {
|
||||||
|
return nil, append([]byte(nil), raw...), nil
|
||||||
|
}
|
||||||
|
return nil, nil, fmt.Errorf("decode imported inline turn")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeImportedUserMessage(raw []byte, blobs importedBlobStore) (*agentv1.UserMessage, error) {
|
||||||
|
data := raw
|
||||||
|
if resolved, ok := blobs.resolve(raw); ok {
|
||||||
|
data = resolved
|
||||||
|
} else if len(raw) == sha256.Size {
|
||||||
|
candidate := &agentv1.UserMessage{}
|
||||||
|
if err := proto.Unmarshal(raw, candidate); err != nil || !hasKnownUserMessageContent(candidate) {
|
||||||
|
return nil, fmt.Errorf("missing prefetched user message blob %x", raw)
|
||||||
|
}
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
message := &agentv1.UserMessage{}
|
||||||
|
if err := proto.Unmarshal(data, message); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode imported turn user_message: %w", err)
|
||||||
|
}
|
||||||
|
return message, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeImportedStep(raw []byte, blobs importedBlobStore) (*agentv1.ConversationStep, error) {
|
||||||
|
data := raw
|
||||||
|
if resolved, ok := blobs.resolve(raw); ok {
|
||||||
|
data = resolved
|
||||||
|
} else if len(raw) == sha256.Size {
|
||||||
|
candidate := &agentv1.ConversationStep{}
|
||||||
|
if err := proto.Unmarshal(raw, candidate); err != nil || candidate.GetMessage() == nil {
|
||||||
|
return nil, fmt.Errorf("missing prefetched conversation step blob %x", raw)
|
||||||
|
}
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
step := &agentv1.ConversationStep{}
|
||||||
|
if err := proto.Unmarshal(data, step); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode imported turn step: %w", err)
|
||||||
|
}
|
||||||
|
if step.GetMessage() == nil {
|
||||||
|
return nil, fmt.Errorf("decode imported turn step: payload is empty")
|
||||||
|
}
|
||||||
|
return step, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importedBlobTurnMessages(turn *agentv1.ConversationTurnStructure, blobs importedBlobStore) ([]modeladapter.Message, error) {
|
||||||
|
if turn == nil || turn.GetAgentConversationTurn() == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
agentTurn := turn.GetAgentConversationTurn()
|
||||||
|
messages := make([]modeladapter.Message, 0, 1+len(agentTurn.GetSteps()))
|
||||||
|
if len(agentTurn.GetUserMessage()) > 0 {
|
||||||
|
userMessage, err := decodeImportedUserMessage(agentTurn.GetUserMessage(), blobs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage); ok {
|
||||||
|
messages = append(messages, toModelMessage(replay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rawStep := range agentTurn.GetSteps() {
|
||||||
|
if len(rawStep) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
step, err := decodeImportedStep(rawStep, blobs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, replay := range promptengine.BuildLegacyMessagesFromConversationStep(step) {
|
||||||
|
messages = append(messages, toModelMessage(replay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importedTurnIDs(turns [][]byte, blobs importedBlobStore) ([][]byte, error) {
|
||||||
|
ids := make([][]byte, 0, len(turns))
|
||||||
|
for _, raw := range turns {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, id, err := decodeImportedTurn(raw, blobs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(id) > 0 {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasKnownUserMessageContent(message *agentv1.UserMessage) bool {
|
||||||
|
if message == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return message.GetText() != "" ||
|
||||||
|
message.GetMessageId() != "" ||
|
||||||
|
message.GetSelectedContext() != nil ||
|
||||||
|
message.GetRichText() != "" ||
|
||||||
|
len(message.GetConversationStateBlobId()) > 0 ||
|
||||||
|
len(message.GetTextBlobId()) > 0 ||
|
||||||
|
len(message.GetRichTextBlobId()) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonNilError(err error, fallback error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
package forwarder
|
package forwarder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,6 +20,51 @@ const projectedConversationMaxTokens = 130000
|
|||||||
type HistoryProjector struct {
|
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 投影器。
|
// NewHistoryProjector 创建 history 投影器。
|
||||||
func NewHistoryProjector() *HistoryProjector {
|
func NewHistoryProjector() *HistoryProjector {
|
||||||
return &HistoryProjector{}
|
return &HistoryProjector{}
|
||||||
@@ -475,6 +521,16 @@ func isHistoricalReplayToolResult(conversation *ConversationFile, entry HistoryE
|
|||||||
|
|
||||||
// ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。
|
// ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。
|
||||||
func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *ConversationFile) (*agentv1.ConversationStateStructure, error) {
|
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{
|
state := &agentv1.ConversationStateStructure{
|
||||||
TokenDetails: &agentv1.ConversationTokenDetails{
|
TokenDetails: &agentv1.ConversationTokenDetails{
|
||||||
UsedTokens: conversationTokenDetailsUsedTokens(conversation),
|
UsedTokens: conversationTokenDetailsUsedTokens(conversation),
|
||||||
@@ -488,7 +544,7 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
|||||||
if conversation == nil {
|
if conversation == nil {
|
||||||
mode := agentv1.AgentMode_AGENT_MODE_AGENT
|
mode := agentv1.AgentMode_AGENT_MODE_AGENT
|
||||||
state.Mode = &mode
|
state.Mode = &mode
|
||||||
return state, nil
|
return &CheckpointProjection{State: state}, nil
|
||||||
}
|
}
|
||||||
mode, err := parseModeAlias(conversation.Mode)
|
mode, err := parseModeAlias(conversation.Mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -506,158 +562,11 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
|||||||
if structuredState.HasTodos {
|
if structuredState.HasTodos {
|
||||||
state.Todos = encodeConversationTodoBytes(structuredState.Todos)
|
state.Todos = encodeConversationTodoBytes(structuredState.Todos)
|
||||||
}
|
}
|
||||||
grouped := make(map[int64][]HistoryEntry)
|
turnIDs, err := projectCheckpointTurnBlobs(conversation, blobs)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rawUserMessage = payload
|
state.Turns = append(cloneByteSlices(conversation.ImportedTurnIDs), turnIDs...)
|
||||||
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)
|
|
||||||
}
|
|
||||||
replayMessages, err := projector.ProjectPromptReplay(conversation)
|
replayMessages, err := projector.ProjectPromptReplay(conversation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -685,15 +594,183 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
state.RootPromptMessagesJson = rootPromptMessages
|
state.RootPromptMessagesJson = rootPromptMessages
|
||||||
return state, nil
|
return &CheckpointProjection{State: state, Blobs: blobs.list()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func marshalThinkingStep(text string) ([]byte, error) {
|
func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpointBlobGraph) ([][]byte, error) {
|
||||||
return proto.Marshal(&agentv1.ConversationStep{
|
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{
|
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: text},
|
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 {
|
func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 {
|
||||||
@@ -1141,60 +1218,6 @@ func shouldPersistToolResultName(toolName string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterCheckpointTurns(rawTurns [][]byte) [][]byte {
|
|
||||||
if len(rawTurns) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
filtered := make([][]byte, 0, len(rawTurns))
|
|
||||||
for _, rawTurn := range rawTurns {
|
|
||||||
if len(rawTurn) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
turn := &agentv1.ConversationTurnStructure{}
|
|
||||||
if err := proto.Unmarshal(rawTurn, turn); err != nil {
|
|
||||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
agentTurn := turn.GetAgentConversationTurn()
|
|
||||||
if agentTurn == nil {
|
|
||||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
nextSteps := make([][]byte, 0, len(agentTurn.GetSteps()))
|
|
||||||
for _, rawStep := range agentTurn.GetSteps() {
|
|
||||||
if len(rawStep) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
step := &agentv1.ConversationStep{}
|
|
||||||
if err := proto.Unmarshal(rawStep, step); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if toolCall := step.GetToolCall(); toolCall != nil && !shouldPersistToolResultName(inferToolName(toolCall)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nextSteps = append(nextSteps, append([]byte(nil), rawStep...))
|
|
||||||
}
|
|
||||||
if len(agentTurn.GetUserMessage()) == 0 && len(nextSteps) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
encoded, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
|
||||||
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
|
||||||
AgentConversationTurn: &agentv1.AgentConversationTurnStructure{
|
|
||||||
UserMessage: append([]byte(nil), agentTurn.GetUserMessage()...),
|
|
||||||
Steps: nextSteps,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, encoded)
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []promptengine.Message {
|
func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []promptengine.Message {
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -1231,7 +1254,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreImportedReplayUserMessages(messages []promptengine.Message, importedTurns [][]byte) []promptengine.Message {
|
func restoreImportedReplayUserMessages(messages []promptengine.Message, importedTurns [][]byte, blobs importedBlobStore) []promptengine.Message {
|
||||||
if len(messages) == 0 || len(importedTurns) == 0 {
|
if len(messages) == 0 || len(importedTurns) == 0 {
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
@@ -1240,16 +1263,16 @@ func restoreImportedReplayUserMessages(messages []promptengine.Message, imported
|
|||||||
if len(rawTurn) == 0 {
|
if len(rawTurn) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
turn := &agentv1.ConversationTurnStructure{}
|
turn, _, err := decodeImportedTurn(rawTurn, blobs)
|
||||||
if err := proto.Unmarshal(rawTurn, turn); err != nil {
|
if err != nil || turn == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
agentTurn := turn.GetAgentConversationTurn()
|
agentTurn := turn.GetAgentConversationTurn()
|
||||||
if agentTurn == nil || len(agentTurn.GetUserMessage()) == 0 {
|
if agentTurn == nil || len(agentTurn.GetUserMessage()) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
userMessage := &agentv1.UserMessage{}
|
userMessage, err := decodeImportedUserMessage(agentTurn.GetUserMessage(), blobs)
|
||||||
if err := proto.Unmarshal(agentTurn.GetUserMessage(), userMessage); err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage)
|
replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage)
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
modeladapter "cursor/internal/backend/agent/model"
|
||||||
|
promptengine "cursor/internal/backend/agent/prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionBuildsBlobBackedTurns(t *testing.T) {
|
||||||
|
toolCall := testEditToolCall(t, "file.txt")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
entries []HistoryEntry
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no tools",
|
||||||
|
entries: []HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "hello"),
|
||||||
|
newAssistantTextEntry(1, "request-1", "hi", "", ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "completed tool call",
|
||||||
|
entries: []HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "edit the file"),
|
||||||
|
newToolCallEntry(1, "request-1", "call-1", "Edit", "", "", toolCall),
|
||||||
|
newToolResultEntry(1, "request-1", "call-1", "Edit", `{"path":"file.txt"}`, "edited", "", toolCall),
|
||||||
|
newAssistantTextEntry(1, "request-1", "done", "", ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unfinished tool call",
|
||||||
|
entries: []HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "edit the file"),
|
||||||
|
newToolCallEntry(1, "request-1", "call-1", "Edit", "", "", toolCall),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "orphan tool result",
|
||||||
|
entries: []HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "edit the file"),
|
||||||
|
newToolResultEntry(1, "request-1", "call-1", "Edit", `{"path":"file.txt"}`, "edited", "", toolCall),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
conversation := testConversation(test.entries)
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(projection.State.GetTurns()) != 1 {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() turns = %d, want 1 Blob ID", len(projection.State.GetTurns()))
|
||||||
|
}
|
||||||
|
assertCheckpointBlobGraph(t, projection)
|
||||||
|
messages, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeReplayMessages() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) == 0 {
|
||||||
|
t.Fatal("ProjectCheckpointProjection() removed all root prompt replay history")
|
||||||
|
}
|
||||||
|
if messages[0].Role != "user" || messages[0].Content == "" {
|
||||||
|
t.Fatalf("first replay message = %#v, want retained user history", messages[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectLegacyCheckpointLargeModelHistoryUsesRootReplay(t *testing.T) {
|
||||||
|
entries := make([]HistoryEntry, 0, 400)
|
||||||
|
for turn := int64(1); turn <= 200; turn++ {
|
||||||
|
requestID := fmt.Sprintf("request-%d", turn)
|
||||||
|
entries = append(entries,
|
||||||
|
testModelMessageEntry(t, turn, requestID, modeladapter.Message{Role: "user", Content: fmt.Sprintf("question %d", turn)}),
|
||||||
|
testModelMessageEntry(t, turn, requestID, modeladapter.Message{Role: "assistant", Content: fmt.Sprintf("answer %d", turn)}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := NewHistoryProjector().ProjectLegacyCheckpoint(testConversation(entries))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectLegacyCheckpoint() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(state.GetTurns()) != 0 {
|
||||||
|
t.Fatalf("ProjectLegacyCheckpoint() model-only turns = %d, want 0", len(state.GetTurns()))
|
||||||
|
}
|
||||||
|
messages, err := promptengine.DecodeReplayMessages(state.GetRootPromptMessagesJson())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeReplayMessages() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 400 {
|
||||||
|
t.Fatalf("decoded replay messages = %d, want 400", len(messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectLegacyCheckpointSnapshotIsIsolatedFromLaterHistory(t *testing.T) {
|
||||||
|
conversation := testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "first question"),
|
||||||
|
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
|
||||||
|
})
|
||||||
|
projector := NewHistoryProjector()
|
||||||
|
midpointProjection, err := projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("midpoint ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
midpoint := midpointProjection.State
|
||||||
|
|
||||||
|
appendEntriesInPlace(conversation, []HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 2, "request-2", "second question"),
|
||||||
|
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
|
||||||
|
})
|
||||||
|
latestProjection, err := projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("latest ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
latest := latestProjection.State
|
||||||
|
|
||||||
|
midpointMessages, err := promptengine.DecodeReplayMessages(midpoint.GetRootPromptMessagesJson())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode midpoint replay: %v", err)
|
||||||
|
}
|
||||||
|
latestMessages, err := promptengine.DecodeReplayMessages(latest.GetRootPromptMessagesJson())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode latest replay: %v", err)
|
||||||
|
}
|
||||||
|
if len(midpointMessages) != 2 {
|
||||||
|
t.Fatalf("midpoint replay messages = %d, want 2", len(midpointMessages))
|
||||||
|
}
|
||||||
|
if len(latestMessages) != 4 {
|
||||||
|
t.Fatalf("latest replay messages = %d, want 4", len(latestMessages))
|
||||||
|
}
|
||||||
|
if len(midpoint.GetTurns()) != 1 || len(latest.GetTurns()) != 2 {
|
||||||
|
t.Fatalf("checkpoint turn counts = (%d, %d), want (1, 2)", len(midpoint.GetTurns()), len(latest.GetTurns()))
|
||||||
|
}
|
||||||
|
assertCheckpointBlobGraph(t, midpointProjection)
|
||||||
|
assertCheckpointBlobGraph(t, latestProjection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionKeepsVisibleTurnsAcrossCompaction(t *testing.T) {
|
||||||
|
summaryPayload, err := json.Marshal(compactionSummaryEntryPayload{Summary: "first turn summarized"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal compaction summary: %v", err)
|
||||||
|
}
|
||||||
|
conversation := testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "first question"),
|
||||||
|
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
|
||||||
|
{TurnSeq: 0, Role: "system", Kind: "compaction_summary", Payload: summaryPayload},
|
||||||
|
testUserMessageEntry(t, 2, "request-2", "second question"),
|
||||||
|
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(projection.State.GetTurns()) != 2 {
|
||||||
|
t.Fatalf("visible turns after compaction = %d, want 2", len(projection.State.GetTurns()))
|
||||||
|
}
|
||||||
|
assertCheckpointBlobGraph(t, projection)
|
||||||
|
|
||||||
|
messages, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeReplayMessages() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 3 {
|
||||||
|
t.Fatalf("compacted root replay messages = %d, want summary plus latest turn", len(messages))
|
||||||
|
}
|
||||||
|
if messages[0].Role != "user" || messages[0].Content != "<conversation_summary>\nfirst turn summarized\n</conversation_summary>" {
|
||||||
|
t.Fatalf("first compacted replay message = %#v", messages[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportedConversationStateRejectsBlobTurnIDsWithoutPrefetchedData(t *testing.T) {
|
||||||
|
turnID := sha256.Sum256([]byte("imported turn"))
|
||||||
|
state := &agentv1.ConversationStateStructure{Turns: [][]byte{turnID[:]}}
|
||||||
|
if _, err := importedConversationStateModelMessages(state, nil); err == nil {
|
||||||
|
t.Fatal("importedConversationStateModelMessages() accepted unresolved Blob turn")
|
||||||
|
}
|
||||||
|
conversation := testConversation(nil)
|
||||||
|
service := &Service{}
|
||||||
|
if _, err := service.importConversationState(conversation, state, nil); err == nil {
|
||||||
|
t.Fatal("importConversationState() accepted unresolved Blob turn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportedConversationStateRestoresBlobOnlyForkFromPrefetchedBlobs(t *testing.T) {
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(testConversation([]HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 1, "request-1", "parent question"),
|
||||||
|
newAssistantTextEntry(1, "request-1", "parent answer", "", ""),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
prefetched := make([]*agentv1.PreFetchedBlob, 0, len(projection.Blobs))
|
||||||
|
for _, blob := range projection.Blobs {
|
||||||
|
prefetched = append(prefetched, &agentv1.PreFetchedBlob{Id: blob.ID, Value: blob.Data})
|
||||||
|
}
|
||||||
|
state := proto.Clone(projection.State).(*agentv1.ConversationStateStructure)
|
||||||
|
state.RootPromptMessagesJson = nil
|
||||||
|
conversation := testConversation(nil)
|
||||||
|
entries, err := (&Service{}).importConversationState(conversation, state, prefetched)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("importConversationState() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(conversation.ImportedTurnIDs) != 1 {
|
||||||
|
t.Fatalf("ImportedTurnIDs = %d, want 1", len(conversation.ImportedTurnIDs))
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("imported model entries = %d, want user and assistant", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportedInlineTurnWithSHA256LengthIsNotMisclassified(t *testing.T) {
|
||||||
|
var rawTurn []byte
|
||||||
|
for size := 1; size <= 128; size++ {
|
||||||
|
rawUser, err := proto.Marshal(&agentv1.UserMessage{Text: strings.Repeat("x", size), MessageId: "inline"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal user message: %v", err)
|
||||||
|
}
|
||||||
|
rawTurn, err = proto.Marshal(&agentv1.ConversationTurnStructure{
|
||||||
|
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
||||||
|
AgentConversationTurn: &agentv1.AgentConversationTurnStructure{UserMessage: rawUser},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal turn: %v", err)
|
||||||
|
}
|
||||||
|
if len(rawTurn) == sha256.Size {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rawTurn) != sha256.Size {
|
||||||
|
t.Fatal("test could not construct a 32-byte inline turn")
|
||||||
|
}
|
||||||
|
ids, err := importedTurnIDs([][]byte{rawTurn}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("importedTurnIDs() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(ids) != 0 {
|
||||||
|
t.Fatal("32-byte inline turn was misclassified as a Blob ID")
|
||||||
|
}
|
||||||
|
messages, err := importedConversationStateModelMessages(&agentv1.ConversationStateStructure{Turns: [][]byte{rawTurn}}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("importedConversationStateModelMessages() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 1 || messages[0].Role != "user" {
|
||||||
|
t.Fatalf("inline turn messages = %#v, want one user message", messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportedTurnIDsPersistThroughConversationStore(t *testing.T) {
|
||||||
|
store := NewConversationFileStore(t.TempDir())
|
||||||
|
turnID := sha256.Sum256([]byte("parent turn"))
|
||||||
|
conversation := testConversation(nil)
|
||||||
|
conversation.ImportedTurnIDs = [][]byte{turnID[:]}
|
||||||
|
persisted, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{
|
||||||
|
testUserMessageEntry(t, 2, "request-2", "fork question"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(persisted.ImportedTurnIDs) != 1 || string(persisted.ImportedTurnIDs[0]) != string(turnID[:]) {
|
||||||
|
t.Fatalf("persisted ImportedTurnIDs = %x, want %x", persisted.ImportedTurnIDs, turnID)
|
||||||
|
}
|
||||||
|
loaded, err := store.LoadConversation(conversation.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded.ImportedTurnIDs) != 1 || string(loaded.ImportedTurnIDs[0]) != string(turnID[:]) {
|
||||||
|
t.Fatalf("loaded ImportedTurnIDs = %x, want %x", loaded.ImportedTurnIDs, turnID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewindImportedTurnPrefixUsesClientForkPoint(t *testing.T) {
|
||||||
|
ids := make([][]byte, 4)
|
||||||
|
for index := range ids {
|
||||||
|
digest := sha256.Sum256([]byte(fmt.Sprintf("turn-%d", index+1)))
|
||||||
|
ids[index] = digest[:]
|
||||||
|
}
|
||||||
|
trimmed := rewindImportedTurnPrefix(ids, runRewindDecision{
|
||||||
|
TargetTurnSeq: 4,
|
||||||
|
HasClientTurnCount: true,
|
||||||
|
ClientTurnCount: 2,
|
||||||
|
})
|
||||||
|
if len(trimmed) != 2 || string(trimmed[0]) != string(ids[0]) || string(trimmed[1]) != string(ids[1]) {
|
||||||
|
t.Fatalf("rewindImportedTurnPrefix() = %x, want first two IDs", trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewindImportedTurnPrefixClearsAllIDsAtClientTurnZero(t *testing.T) {
|
||||||
|
ids := make([][]byte, 2)
|
||||||
|
for index := range ids {
|
||||||
|
digest := sha256.Sum256([]byte(fmt.Sprintf("turn-%d", index+1)))
|
||||||
|
ids[index] = digest[:]
|
||||||
|
}
|
||||||
|
trimmed := rewindImportedTurnPrefix(ids, runRewindDecision{
|
||||||
|
TargetTurnSeq: 3,
|
||||||
|
HasClientTurnCount: true,
|
||||||
|
ClientTurnCount: 0,
|
||||||
|
})
|
||||||
|
if trimmed != nil {
|
||||||
|
t.Fatalf("rewindImportedTurnPrefix() = %x, want nil at client turn zero", trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewindImportedTurnPrefixUsesTargetWithoutClientCount(t *testing.T) {
|
||||||
|
ids := make([][]byte, 4)
|
||||||
|
for index := range ids {
|
||||||
|
digest := sha256.Sum256([]byte(fmt.Sprintf("turn-%d", index+1)))
|
||||||
|
ids[index] = digest[:]
|
||||||
|
}
|
||||||
|
trimmed := rewindImportedTurnPrefix(ids, runRewindDecision{TargetTurnSeq: 3})
|
||||||
|
if len(trimmed) != 2 || string(trimmed[0]) != string(ids[0]) || string(trimmed[1]) != string(ids[1]) {
|
||||||
|
t.Fatalf("rewindImportedTurnPrefix() = %x, want target-derived first two IDs", trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCheckpointBlobGraph(t *testing.T, projection *CheckpointProjection) {
|
||||||
|
t.Helper()
|
||||||
|
if projection == nil || projection.State == nil {
|
||||||
|
t.Fatal("checkpoint projection is nil")
|
||||||
|
}
|
||||||
|
blobByID := make(map[string][]byte, len(projection.Blobs))
|
||||||
|
for _, blob := range projection.Blobs {
|
||||||
|
if len(blob.ID) != sha256.Size {
|
||||||
|
t.Fatalf("blob id length = %d, want %d", len(blob.ID), sha256.Size)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(blob.Data)
|
||||||
|
if string(blob.ID) != string(digest[:]) {
|
||||||
|
t.Fatal("blob id does not match SHA-256(data)")
|
||||||
|
}
|
||||||
|
blobByID[string(blob.ID)] = blob.Data
|
||||||
|
}
|
||||||
|
for _, turnID := range projection.State.GetTurns() {
|
||||||
|
turnData, ok := blobByID[string(turnID)]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("turn references missing blob")
|
||||||
|
}
|
||||||
|
turn := &agentv1.ConversationTurnStructure{}
|
||||||
|
if err := proto.Unmarshal(turnData, turn); err != nil {
|
||||||
|
t.Fatalf("decode turn blob: %v", err)
|
||||||
|
}
|
||||||
|
agentTurn := turn.GetAgentConversationTurn()
|
||||||
|
if agentTurn == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if userID := agentTurn.GetUserMessage(); len(userID) > 0 {
|
||||||
|
userData, exists := blobByID[string(userID)]
|
||||||
|
if !exists {
|
||||||
|
t.Fatal("turn references missing user message blob")
|
||||||
|
}
|
||||||
|
if err := proto.Unmarshal(userData, &agentv1.UserMessage{}); err != nil {
|
||||||
|
t.Fatalf("decode user message blob: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, stepID := range agentTurn.GetSteps() {
|
||||||
|
stepData, exists := blobByID[string(stepID)]
|
||||||
|
if !exists {
|
||||||
|
t.Fatal("turn references missing step blob")
|
||||||
|
}
|
||||||
|
if err := proto.Unmarshal(stepData, &agentv1.ConversationStep{}); err != nil {
|
||||||
|
t.Fatalf("decode conversation step blob: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConversation(entries []HistoryEntry) *ConversationFile {
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
RootConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 1,
|
||||||
|
NextEntrySeq: 1,
|
||||||
|
Entries: make([]HistoryEntry, 0, len(entries)),
|
||||||
|
}
|
||||||
|
appendEntriesInPlace(conversation, entries)
|
||||||
|
return conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUserMessageEntry(t *testing.T, turnSeq int64, requestID string, text string) HistoryEntry {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := protojson.Marshal(&agentv1.UserMessage{Text: text, MessageId: fmt.Sprintf("message-%d", turnSeq)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal user message: %v", err)
|
||||||
|
}
|
||||||
|
return HistoryEntry{
|
||||||
|
TurnSeq: turnSeq,
|
||||||
|
RequestID: requestID,
|
||||||
|
Role: "user",
|
||||||
|
Kind: "user_message",
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testModelMessageEntry(t *testing.T, turnSeq int64, requestID string, message modeladapter.Message) HistoryEntry {
|
||||||
|
t.Helper()
|
||||||
|
entry, ok, err := newModelMessageEntry(turnSeq, requestID, message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newModelMessageEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("newModelMessageEntry() rejected test message")
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEditToolCall(t *testing.T, path string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := protojson.Marshal(&agentv1.ToolCall{
|
||||||
|
Tool: &agentv1.ToolCall_EditToolCall{
|
||||||
|
EditToolCall: &agentv1.EditToolCall{
|
||||||
|
Args: &agentv1.EditArgs{Path: path},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal edit tool call: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ type runRewindMatch struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) decideRunRewind(intent InboundIntent, conversation *ConversationFile) runRewindDecision {
|
func (service *Service) decideRunRewind(intent InboundIntent, conversation *ConversationFile) runRewindDecision {
|
||||||
decision := runRewindDecision{ClientTurnCount: -1}
|
decision := runRewindDecision{}
|
||||||
if !shouldEvaluateRunRewind(intent) {
|
if !shouldEvaluateRunRewind(intent) {
|
||||||
return decision
|
return decision
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ func selectRunRewindMatch(matches []runRewindMatch, clientTurnCount int, hasClie
|
|||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return runRewindMatch{}, "no_match"
|
return runRewindMatch{}, "no_match"
|
||||||
}
|
}
|
||||||
if hasClientTurnCount && clientTurnCount >= 0 {
|
if hasClientTurnCount {
|
||||||
targetTurnSeq := int64(clientTurnCount) + 1
|
targetTurnSeq := int64(clientTurnCount) + 1
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
if match.Entry.TurnSeq == targetTurnSeq {
|
if match.Entry.TurnSeq == targetTurnSeq {
|
||||||
@@ -224,6 +224,7 @@ func (service *Service) applyRunRewindToConversation(conversation *ConversationF
|
|||||||
conversation.Entries = nil
|
conversation.Entries = nil
|
||||||
conversation.NextEntrySeq = 1
|
conversation.NextEntrySeq = 1
|
||||||
conversation.NextTurnSeq = 1
|
conversation.NextTurnSeq = 1
|
||||||
|
conversation.ImportedTurnIDs = rewindImportedTurnPrefix(conversation.ImportedTurnIDs, decision)
|
||||||
appendEntriesInPlace(conversation, appendReplacementRunEntries(decision.PrefixEntries, entries))
|
appendEntriesInPlace(conversation, appendReplacementRunEntries(decision.PrefixEntries, entries))
|
||||||
applyRunRewindConversationState(conversation, intent, turnSeq)
|
applyRunRewindConversationState(conversation, intent, turnSeq)
|
||||||
deriveConversationLoopState(conversation)
|
deriveConversationLoopState(conversation)
|
||||||
@@ -269,10 +270,30 @@ func applyRunRewindMetadata(conversation *ConversationFile, source *Conversation
|
|||||||
if source.TokenDetailsMaxTokens > 0 {
|
if source.TokenDetailsMaxTokens > 0 {
|
||||||
conversation.TokenDetailsMaxTokens = source.TokenDetailsMaxTokens
|
conversation.TokenDetailsMaxTokens = source.TokenDetailsMaxTokens
|
||||||
}
|
}
|
||||||
|
decision := runRewindDecision{TargetTurnSeq: turnSeq}
|
||||||
|
if intent.ConversationState != nil {
|
||||||
|
decision.HasClientTurnCount = true
|
||||||
|
decision.ClientTurnCount = len(intent.ConversationState.GetTurns())
|
||||||
|
}
|
||||||
|
conversation.ImportedTurnIDs = rewindImportedTurnPrefix(source.ImportedTurnIDs, decision)
|
||||||
}
|
}
|
||||||
applyRunRewindConversationState(conversation, intent, turnSeq)
|
applyRunRewindConversationState(conversation, intent, turnSeq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rewindImportedTurnPrefix(importedTurnIDs [][]byte, decision runRewindDecision) [][]byte {
|
||||||
|
keep := decision.TargetTurnSeq - 1
|
||||||
|
if decision.HasClientTurnCount {
|
||||||
|
keep = int64(decision.ClientTurnCount)
|
||||||
|
}
|
||||||
|
if keep <= 0 || len(importedTurnIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if keep > int64(len(importedTurnIDs)) {
|
||||||
|
keep = int64(len(importedTurnIDs))
|
||||||
|
}
|
||||||
|
return cloneByteSlices(importedTurnIDs[:keep])
|
||||||
|
}
|
||||||
|
|
||||||
func (service *Service) logRunRewindDecision(requestID string, conversationID string, eventName string, decision runRewindDecision) {
|
func (service *Service) logRunRewindDecision(requestID string, conversationID string, eventName string, decision runRewindDecision) {
|
||||||
if service == nil || !decision.Evaluated {
|
if service == nil || !decision.Evaluated {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func (service *Service) bootstrapRuntimeConversation(intent InboundIntent) (*Con
|
|||||||
}
|
}
|
||||||
importedEntries := []HistoryEntry(nil)
|
importedEntries := []HistoryEntry(nil)
|
||||||
if len(conversation.Entries) == 0 && intent.ConversationState != nil {
|
if len(conversation.Entries) == 0 && intent.ConversationState != nil {
|
||||||
importedEntries, err = service.importConversationState(conversation, intent.ConversationState)
|
importedEntries, err = service.importConversationState(conversation, intent.ConversationState, intent.PreFetchedBlobs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, agentv1.AgentMode_AGENT_MODE_AGENT, 0, nil, err
|
return nil, agentv1.AgentMode_AGENT_MODE_AGENT, 0, nil, err
|
||||||
}
|
}
|
||||||
@@ -138,6 +138,7 @@ func (service *Service) syncConversationRecord(conversationID string, conversati
|
|||||||
item.AutoCompactionReserveTokens = conversation.AutoCompactionReserveTokens
|
item.AutoCompactionReserveTokens = conversation.AutoCompactionReserveTokens
|
||||||
item.AutoCompactionTriggeredAt = conversation.AutoCompactionTriggeredAt
|
item.AutoCompactionTriggeredAt = conversation.AutoCompactionTriggeredAt
|
||||||
item.AutoCompactionSourceModelCallID = conversation.AutoCompactionSourceModelCallID
|
item.AutoCompactionSourceModelCallID = conversation.AutoCompactionSourceModelCallID
|
||||||
|
item.ImportedTurnIDs = cloneByteSlices(conversation.ImportedTurnIDs)
|
||||||
item.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
|
item.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
|
||||||
item.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
|
item.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
|
||||||
item.CreatedAt = conversation.CreatedAt
|
item.CreatedAt = conversation.CreatedAt
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
@@ -261,6 +262,8 @@ type Service struct {
|
|||||||
execBridge execbridge.ExecBridge
|
execBridge execbridge.ExecBridge
|
||||||
interactionBridge interactionbridge.InteractionBridge
|
interactionBridge interactionbridge.InteractionBridge
|
||||||
appendSeq *appendSequenceTracker
|
appendSeq *appendSequenceTracker
|
||||||
|
checkpointBlobMu sync.Mutex
|
||||||
|
checkpointBlobs map[string]*checkpointBlobCacheEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
type agentModelMemory interface {
|
type agentModelMemory interface {
|
||||||
@@ -300,6 +303,7 @@ func NewService(historyRoot string, resolver modeladapter.ChannelResolver) *Serv
|
|||||||
execBridge: execbridge.NewBridge(),
|
execBridge: execbridge.NewBridge(),
|
||||||
interactionBridge: interactionbridge.NewBridge(),
|
interactionBridge: interactionbridge.NewBridge(),
|
||||||
appendSeq: newAppendSequenceTracker(),
|
appendSeq: newAppendSequenceTracker(),
|
||||||
|
checkpointBlobs: make(map[string]*checkpointBlobCacheEntry),
|
||||||
}
|
}
|
||||||
service.startHistoryMaintenance()
|
service.startHistoryMaintenance()
|
||||||
store.SyncAllCursorTranscriptsBestEffort()
|
store.SyncAllCursorTranscriptsBestEffort()
|
||||||
@@ -328,6 +332,7 @@ func newServiceWithDependencies(store *ConversationFileStore, projector *History
|
|||||||
execBridge: execbridge.NewBridge(),
|
execBridge: execbridge.NewBridge(),
|
||||||
interactionBridge: interactionbridge.NewBridge(),
|
interactionBridge: interactionbridge.NewBridge(),
|
||||||
appendSeq: newAppendSequenceTracker(),
|
appendSeq: newAppendSequenceTracker(),
|
||||||
|
checkpointBlobs: make(map[string]*checkpointBlobCacheEntry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,6 +562,7 @@ func (service *Service) decodeInboundIntent(requestID string, message *agentv1.A
|
|||||||
}
|
}
|
||||||
intent.ConversationID = conversationID
|
intent.ConversationID = conversationID
|
||||||
intent.ConversationState = runRequest.GetConversationState()
|
intent.ConversationState = runRequest.GetConversationState()
|
||||||
|
intent.PreFetchedBlobs = runRequest.GetPreFetchedBlobs()
|
||||||
intent.UserMessage = extractUserMessage(message)
|
intent.UserMessage = extractUserMessage(message)
|
||||||
intent.RequestContext = extractRequestContext(message)
|
intent.RequestContext = extractRequestContext(message)
|
||||||
if service.shouldIgnoreEmptyResumeRunRequest(requestID, runRequest, intent.UserMessage, intent.RequestContext) {
|
if service.shouldIgnoreEmptyResumeRunRequest(requestID, runRequest, intent.UserMessage, intent.RequestContext) {
|
||||||
@@ -604,6 +610,7 @@ func (service *Service) decodeInboundIntent(requestID string, message *agentv1.A
|
|||||||
intent.ConversationID = conversationID
|
intent.ConversationID = conversationID
|
||||||
intent.SubagentTypeName = strings.TrimSpace(prewarmRequest.GetSubagentTypeName())
|
intent.SubagentTypeName = strings.TrimSpace(prewarmRequest.GetSubagentTypeName())
|
||||||
intent.ConversationState = prewarmRequest.GetConversationState()
|
intent.ConversationState = prewarmRequest.GetConversationState()
|
||||||
|
intent.PreFetchedBlobs = prewarmRequest.GetPreFetchedBlobs()
|
||||||
intent.Mode, intent.ModeSource, intent.HasExplicitMode, err = extractPrewarmMode(prewarmRequest)
|
intent.Mode, intent.ModeSource, intent.HasExplicitMode, err = extractPrewarmMode(prewarmRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return InboundIntent{}, err
|
return InboundIntent{}, err
|
||||||
@@ -819,11 +826,14 @@ func (service *Service) snapshotVisibleTurns(conversation *ConversationFile) ([]
|
|||||||
if service == nil || service.projector == nil || conversation == nil {
|
if service == nil || service.projector == nil || conversation == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
state, err := service.projector.ProjectLegacyCheckpoint(conversation)
|
projection, err := service.projector.ProjectCheckpointProjection(conversation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return cloneByteSlices(state.GetTurns()), nil
|
if projection == nil || projection.State == nil {
|
||||||
|
return nil, fmt.Errorf("checkpoint projection is empty")
|
||||||
|
}
|
||||||
|
return cloneByteSlices(projection.State.GetTurns()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCancelIntent 处理取消请求,并向客户端发送执行桥 abort。
|
// handleCancelIntent 处理取消请求,并向客户端发送执行桥 abort。
|
||||||
@@ -833,42 +843,60 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
|||||||
return fmt.Errorf("request is not active: %s", intent.RequestID)
|
return fmt.Errorf("request is not active: %s", intent.RequestID)
|
||||||
}
|
}
|
||||||
hasCheckpoint := checkpointConversationInitialized(stream)
|
hasCheckpoint := checkpointConversationInitialized(stream)
|
||||||
if hasCheckpoint {
|
|
||||||
cancelReason := firstNonEmpty(intent.CancelReason, "user aborted")
|
|
||||||
_, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{
|
|
||||||
newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
|
|
||||||
"status": "canceled",
|
|
||||||
"reason": cancelReason,
|
|
||||||
"replay_policy": cancelReplayPolicyForReason(cancelReason),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stream.mu.Lock()
|
stream.mu.Lock()
|
||||||
pendingExecs := make([]runtimecore.PendingExec, 0, len(stream.PendingExecs))
|
pendingExecs := make([]runtimecore.PendingExec, 0, len(stream.PendingExecs))
|
||||||
for _, pending := range stream.PendingExecs {
|
for _, pending := range stream.PendingExecs {
|
||||||
pendingExecs = append(pendingExecs, pending)
|
pendingExecs = append(pendingExecs, pending)
|
||||||
}
|
}
|
||||||
|
if stream.ProviderCancel != nil {
|
||||||
|
stream.ProviderCancel()
|
||||||
|
stream.ProviderCancel = nil
|
||||||
|
}
|
||||||
|
stream.ProviderActive = false
|
||||||
|
stream.CurrentProviderToken++
|
||||||
|
stream.CurrentCompactionToken++
|
||||||
|
stream.PendingProviderAction = providerActionNone
|
||||||
|
stream.PendingCompaction = nil
|
||||||
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
stream.mu.Unlock()
|
stream.mu.Unlock()
|
||||||
|
if hasCheckpoint {
|
||||||
|
cancelReason := firstNonEmpty(intent.CancelReason, "user aborted")
|
||||||
|
cancelEntry := newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
|
||||||
|
"status": "canceled",
|
||||||
|
"reason": cancelReason,
|
||||||
|
"replay_policy": cancelReplayPolicyForReason(cancelReason),
|
||||||
|
})
|
||||||
|
if _, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{cancelEntry}); err != nil {
|
||||||
|
log.Printf("forwarder cancellation metadata persistence failed request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, err)
|
||||||
|
if memoryErr := service.appendCheckpointEntries(stream, []HistoryEntry{cancelEntry}); memoryErr != nil {
|
||||||
|
return memoryErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, pending := range pendingExecs {
|
for _, pending := range pendingExecs {
|
||||||
_ = service.broker.Publish(intent.RequestID, StreamEvent{
|
_ = service.broker.Publish(intent.RequestID, StreamEvent{
|
||||||
Message: buildExecAbortMessage(pending),
|
Message: buildExecAbortMessage(pending),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if hasCheckpoint {
|
|
||||||
if err := service.publishCheckpoint(stream.RequestID, stream.ConversationID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clearPendingProviderCompletion(stream)
|
clearPendingProviderCompletion(stream)
|
||||||
|
terminalMessage := firstNonEmpty(intent.CancelReason, "[canceled] User aborted request")
|
||||||
stream.mu.Lock()
|
stream.mu.Lock()
|
||||||
stream.PendingProviderAction = providerActionNone
|
stream.PendingExecs = make(map[string]runtimecore.PendingExec)
|
||||||
|
stream.PendingInteractions = make(map[string]runtimecore.PendingInteraction)
|
||||||
stream.UpdatedAt = time.Now().UTC()
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
stream.mu.Unlock()
|
stream.mu.Unlock()
|
||||||
service.setTurnPhase(stream, TurnPhaseCanceled)
|
service.discardPendingCheckpoint(stream, fmt.Errorf("checkpoint superseded by cancellation"))
|
||||||
return service.broker.Cancel(intent.RequestID, firstNonEmpty(intent.CancelReason, "[canceled] User aborted request"))
|
if hasCheckpoint {
|
||||||
|
if err := service.publishCheckpointWithTerminalAction(
|
||||||
|
stream.RequestID,
|
||||||
|
stream.ConversationID,
|
||||||
|
checkpointCancellationAction(terminalMessage),
|
||||||
|
); err != nil {
|
||||||
|
return service.failTerminalCheckpointSync(stream, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return service.finishCanceledTurnAfterCheckpoint(stream, terminalMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleExecResult 处理客户端返回的执行桥结果,并在终态时把 tool_result 写回 history。
|
// handleExecResult 处理客户端返回的执行桥结果,并在终态时把 tool_result 写回 history。
|
||||||
@@ -2122,9 +2150,18 @@ func (service *Service) completeSuccessfulTurn(stream *ActiveStream, completion
|
|||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err := service.publishCheckpoint(requestID, conversationID); err != nil {
|
if err := service.publishCheckpointWithCompletion(requestID, conversationID, &completion); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
if err := service.broker.Publish(requestID, StreamEvent{
|
||||||
Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens),
|
Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -2151,7 +2188,15 @@ func (service *Service) failStreamIfNonTerminal(stream *ActiveStream, terminalCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// publishCheckpoint 按当前内存会话镜像投影出 checkpoint,并广播给所有 RunSSE 订阅者。
|
// 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, conversationID string, completion *pendingTurnCompletion) error {
|
||||||
|
return service.publishCheckpointWithTerminalAction(requestID, conversationID, checkpointCompletionAction(completion))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) publishCheckpointWithTerminalAction(requestID string, conversationID string, terminalAction checkpointTerminalAction) error {
|
||||||
stream, ok := service.broker.Get(requestID)
|
stream, ok := service.broker.Get(requestID)
|
||||||
if !ok || stream == nil {
|
if !ok || stream == nil {
|
||||||
return fmt.Errorf("request is not active: %s", requestID)
|
return fmt.Errorf("request is not active: %s", requestID)
|
||||||
@@ -2160,15 +2205,16 @@ func (service *Service) publishCheckpoint(requestID string, _ string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state, err := service.projector.ProjectLegacyCheckpoint(conversation)
|
projection, err := service.projector.ProjectCheckpointProjection(conversation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
|
if projection == nil || projection.State == nil {
|
||||||
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, state)
|
return fmt.Errorf("checkpoint projection is empty")
|
||||||
return service.broker.Publish(requestID, StreamEvent{
|
}
|
||||||
Message: buildCheckpointMessage(state),
|
projection.State.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
|
||||||
})
|
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, projection.State)
|
||||||
|
return service.queueCheckpointProjection(stream, projection, terminalAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
|
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
|
||||||
|
|||||||
@@ -45,13 +45,25 @@ func (snapshot turnUsageSnapshot) requestTokensTotal() int64 {
|
|||||||
return snapshot.promptTokensTotal() + nonNegativeInt64(snapshot.OutputTokens)
|
return snapshot.promptTokensTotal() + nonNegativeInt64(snapshot.OutputTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) importConversationState(item *ConversationFile, state *agentv1.ConversationStateStructure) ([]HistoryEntry, error) {
|
func (service *Service) importConversationState(item *ConversationFile, state *agentv1.ConversationStateStructure, prefetchedBlobs []*agentv1.PreFetchedBlob) ([]HistoryEntry, error) {
|
||||||
if item == nil || state == nil {
|
if item == nil || state == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
blobs, err := newImportedBlobStore(prefetchedBlobs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
importedIDs, err := importedTurnIDs(state.GetTurns(), blobs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
item.TokenDetailsUsedTokens = state.GetTokenDetails().GetUsedTokens()
|
item.TokenDetailsUsedTokens = state.GetTokenDetails().GetUsedTokens()
|
||||||
|
item.ImportedTurnIDs = importedIDs
|
||||||
|
if minimumNextTurnSeq := int64(len(item.ImportedTurnIDs)) + 1; item.NextTurnSeq < minimumNextTurnSeq {
|
||||||
|
item.NextTurnSeq = minimumNextTurnSeq
|
||||||
|
}
|
||||||
entries := make([]HistoryEntry, 0, 2)
|
entries := make([]HistoryEntry, 0, 2)
|
||||||
if messages, err := importedConversationStateModelMessages(state); err != nil {
|
if messages, err := importedConversationStateModelMessages(state, blobs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else {
|
} else {
|
||||||
for _, message := range messages {
|
for _, message := range messages {
|
||||||
@@ -104,7 +116,7 @@ func (service *Service) importConversationState(item *ConversationFile, state *a
|
|||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func importedConversationStateModelMessages(state *agentv1.ConversationStateStructure) ([]modeladapter.Message, error) {
|
func importedConversationStateModelMessages(state *agentv1.ConversationStateStructure, blobs importedBlobStore) ([]modeladapter.Message, error) {
|
||||||
if state == nil {
|
if state == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -113,7 +125,7 @@ func importedConversationStateModelMessages(state *agentv1.ConversationStateStru
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decode imported replay messages: %w", err)
|
return nil, fmt.Errorf("decode imported replay messages: %w", err)
|
||||||
}
|
}
|
||||||
decoded = restoreImportedReplayUserMessages(decoded, state.GetTurns())
|
decoded = restoreImportedReplayUserMessages(decoded, state.GetTurns(), blobs)
|
||||||
decoded = filterLegacyPlainWriteReplay(decoded)
|
decoded = filterLegacyPlainWriteReplay(decoded)
|
||||||
decoded = filterInternalPromptContextReplay(decoded)
|
decoded = filterInternalPromptContextReplay(decoded)
|
||||||
messages := make([]modeladapter.Message, 0, len(decoded))
|
messages := make([]modeladapter.Message, 0, len(decoded))
|
||||||
@@ -133,35 +145,18 @@ func importedConversationStateModelMessages(state *agentv1.ConversationStateStru
|
|||||||
if len(rawTurn) == 0 {
|
if len(rawTurn) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
turn := &agentv1.ConversationTurnStructure{}
|
turn, turnID, err := decodeImportedTurn(rawTurn, blobs)
|
||||||
if err := proto.Unmarshal(rawTurn, turn); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decode imported turn: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
agentTurn := turn.GetAgentConversationTurn()
|
if turn == nil && len(turnID) > 0 {
|
||||||
if agentTurn == nil {
|
return nil, fmt.Errorf("missing prefetched turn blob %x", turnID)
|
||||||
continue
|
|
||||||
}
|
|
||||||
if rawUser := agentTurn.GetUserMessage(); len(rawUser) > 0 {
|
|
||||||
userMessage := &agentv1.UserMessage{}
|
|
||||||
if err := proto.Unmarshal(rawUser, userMessage); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode imported turn user_message: %w", err)
|
|
||||||
}
|
|
||||||
if replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage); ok {
|
|
||||||
messages = append(messages, toModelMessage(replay))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, rawStep := range agentTurn.GetSteps() {
|
|
||||||
if len(rawStep) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
step := &agentv1.ConversationStep{}
|
|
||||||
if err := proto.Unmarshal(rawStep, step); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode imported turn step: %w", err)
|
|
||||||
}
|
|
||||||
for _, replay := range promptengine.BuildLegacyMessagesFromConversationStep(step) {
|
|
||||||
messages = append(messages, toModelMessage(replay))
|
|
||||||
}
|
}
|
||||||
|
turnMessages, err := importedBlobTurnMessages(turn, blobs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
messages = append(messages, turnMessages...)
|
||||||
}
|
}
|
||||||
return normalizeReplayMessageSequence(messages), nil
|
return normalizeReplayMessageSequence(messages), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type ConversationFile struct {
|
|||||||
CurrentPlanText string `json:"current_plan_text,omitempty"`
|
CurrentPlanText string `json:"current_plan_text,omitempty"`
|
||||||
CurrentPlans map[string]*agentv1.PlanRegistryEntry `json:"current_plans,omitempty"`
|
CurrentPlans map[string]*agentv1.PlanRegistryEntry `json:"current_plans,omitempty"`
|
||||||
CurrentTodos []*agentv1.TodoItem `json:"current_todos,omitempty"`
|
CurrentTodos []*agentv1.TodoItem `json:"current_todos,omitempty"`
|
||||||
|
ImportedTurnIDs [][]byte `json:"imported_turn_ids,omitempty"`
|
||||||
LatestRequestPrefix *ConversationRequestPrefix `json:"latest_request_prefix,omitempty"`
|
LatestRequestPrefix *ConversationRequestPrefix `json:"latest_request_prefix,omitempty"`
|
||||||
LastProviderCall *ConversationProviderCall `json:"last_provider_call,omitempty"`
|
LastProviderCall *ConversationProviderCall `json:"last_provider_call,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -163,6 +164,11 @@ type ActiveStream struct {
|
|||||||
ProviderUsage turnUsageSnapshot
|
ProviderUsage turnUsageSnapshot
|
||||||
ProviderTerminalToolInvocation bool
|
ProviderTerminalToolInvocation bool
|
||||||
PendingCompaction *PendingCompaction
|
PendingCompaction *PendingCompaction
|
||||||
|
PendingCheckpointBlobWrites map[uint32]pendingCheckpointBlobWrite
|
||||||
|
PendingCheckpointBlobRequests map[string]uint32
|
||||||
|
NextCheckpointBlobRequestID uint32
|
||||||
|
NextCheckpointRevision uint64
|
||||||
|
PendingCheckpoint *pendingCheckpointPublish
|
||||||
|
|
||||||
Backlog []StreamEvent
|
Backlog []StreamEvent
|
||||||
Subscribers map[string]*StreamSubscriber
|
Subscribers map[string]*StreamSubscriber
|
||||||
@@ -219,6 +225,32 @@ type pendingTurnCompletion struct {
|
|||||||
Disposition pendingCompletionDisposition
|
Disposition pendingCompletionDisposition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pendingCheckpointBlobWrite struct {
|
||||||
|
Key string
|
||||||
|
Revision uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkpointTerminalActionKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
checkpointTerminalActionNone checkpointTerminalActionKind = iota
|
||||||
|
checkpointTerminalActionComplete
|
||||||
|
checkpointTerminalActionCancel
|
||||||
|
)
|
||||||
|
|
||||||
|
type checkpointTerminalAction struct {
|
||||||
|
kind checkpointTerminalActionKind
|
||||||
|
completion pendingTurnCompletion
|
||||||
|
cancelMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingCheckpointPublish struct {
|
||||||
|
Revision uint64
|
||||||
|
State *agentv1.ConversationStateStructure
|
||||||
|
Required map[string]struct{}
|
||||||
|
TerminalAction checkpointTerminalAction
|
||||||
|
}
|
||||||
|
|
||||||
type PendingCompaction struct {
|
type PendingCompaction struct {
|
||||||
Trigger string
|
Trigger string
|
||||||
ContextTokens int64
|
ContextTokens int64
|
||||||
@@ -418,6 +450,7 @@ type InboundIntent struct {
|
|||||||
SubagentTypeName string
|
SubagentTypeName string
|
||||||
SubagentModelOverrides map[string]runtimecore.SubagentModelOverrideSelection
|
SubagentModelOverrides map[string]runtimecore.SubagentModelOverrideSelection
|
||||||
ConversationState *agentv1.ConversationStateStructure
|
ConversationState *agentv1.ConversationStateStructure
|
||||||
|
PreFetchedBlobs []*agentv1.PreFetchedBlob
|
||||||
UserMessage *agentv1.UserMessage
|
UserMessage *agentv1.UserMessage
|
||||||
RequestContext *agentv1.RequestContext
|
RequestContext *agentv1.RequestContext
|
||||||
ClientMessage *agentv1.AgentClientMessage
|
ClientMessage *agentv1.AgentClientMessage
|
||||||
|
|||||||
@@ -319,6 +319,16 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
|||||||
MockBuilder: upstream.ServerConfigMockBuilder,
|
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.POST("/aiserver.v1.AiService/AvailableModels",
|
||||||
server.Name("available_models"),
|
server.Name("available_models"),
|
||||||
server.ConnectUnary(),
|
server.ConnectUnary(),
|
||||||
@@ -329,6 +339,36 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
|||||||
MockBuilder: upstream.AvailableModelsMockBuilder,
|
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.POST("/aiserver.v1.AiService/GetDefaultModelNudgeData",
|
||||||
server.Name("default_model_nudge"),
|
server.Name("default_model_nudge"),
|
||||||
server.ConnectUnary(),
|
server.ConnectUnary(),
|
||||||
@@ -359,6 +399,34 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
|||||||
MockBuilder: upstream.FirstWindowStatsigDecisionMockBuilder,
|
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.POST("/oauth/token",
|
||||||
server.Name("oauth_token"),
|
server.Name("oauth_token"),
|
||||||
server.HTTP(),
|
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.POST("/aiserver.v1.DashboardService/GetMe",
|
||||||
server.Name("dashboard_get_me"),
|
server.Name("dashboard_get_me"),
|
||||||
server.ConnectUnary(),
|
server.ConnectUnary(),
|
||||||
|
|||||||
@@ -193,6 +193,18 @@ func DefaultModelNudgeMockBuilder(reqCtx *RequestContext) (map[string]any, error
|
|||||||
return buildDefaultModelNudgeDataPayload(reqCtx)
|
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) {
|
func BootstrapStatsigMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
|
||||||
return buildBootstrapStatsigPayload(reqCtx)
|
return buildBootstrapStatsigPayload(reqCtx)
|
||||||
}
|
}
|
||||||
@@ -213,6 +225,18 @@ func DashboardManagedSkillsMockBuilder(reqCtx *RequestContext) (map[string]any,
|
|||||||
return buildDashboardManagedSkillsPayload(reqCtx)
|
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) {
|
func DashboardGetMeMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
|
||||||
return buildDashboardGetMePayload(reqCtx)
|
return buildDashboardGetMePayload(reqCtx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
"cursor/gen/aiserverv1"
|
"cursor/gen/aiserverv1"
|
||||||
"cursor/internal/logger"
|
"cursor/internal/logger"
|
||||||
"cursor/internal/netproxy"
|
"cursor/internal/netproxy"
|
||||||
@@ -426,6 +427,30 @@ func newProtoMessage(typeName string) (proto.Message, error) {
|
|||||||
return &aiserverv1.GetUsageLimitStatusAndActiveGrantsResponse{}, nil
|
return &aiserverv1.GetUsageLimitStatusAndActiveGrantsResponse{}, nil
|
||||||
case "aiserver.v1.IsOnNewPricingResponse":
|
case "aiserver.v1.IsOnNewPricingResponse":
|
||||||
return &aiserverv1.IsOnNewPricingResponse{}, nil
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported proto message type %q", typeName)
|
return nil, fmt.Errorf("unsupported proto message type %q", typeName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ const (
|
|||||||
|
|
||||||
modelRuntimeThinkingEffortParameterID = "thinking_effort"
|
modelRuntimeThinkingEffortParameterID = "thinking_effort"
|
||||||
|
|
||||||
|
// localPathEncryptionKey — стабильный ключ шифрования путей для индексации
|
||||||
|
// репозитория, который cursor-agent CLI запрашивает через GetServerConfig
|
||||||
|
// (indexingConfig.default{User,Team}PathEncryptionKey). Без него CLI
|
||||||
|
// не может инициализировать repo identity в git-воркспейсе и вызовы
|
||||||
|
// файловых инструментов падают с "[unimplemented] HTTP 404".
|
||||||
|
localPathEncryptionKey = "6f6e63652d6c6f63616c2d706174682d656e6372797074696f6e2d6b6579"
|
||||||
|
|
||||||
localUltraMembershipType = "ultra"
|
localUltraMembershipType = "ultra"
|
||||||
localUltraPaymentID = "local_ultra"
|
localUltraPaymentID = "local_ultra"
|
||||||
localUltraSubscriptionStatus = "active"
|
localUltraSubscriptionStatus = "active"
|
||||||
@@ -425,8 +432,12 @@ func buildServerTimePayload(*RequestContext) (map[string]any, error) {
|
|||||||
func buildServerConfigPayload(*RequestContext) (map[string]any, error) {
|
func buildServerConfigPayload(*RequestContext) (map[string]any, error) {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"configVersion": "local_cli_sandbox_defaults_disabled_v2",
|
"configVersion": "local_cli_sandbox_defaults_disabled_v2",
|
||||||
// "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED",
|
"http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED",
|
||||||
"cliSandboxDefaultEnabled": true,
|
"cliSandboxDefaultEnabled": true,
|
||||||
|
"indexingConfig": map[string]any{
|
||||||
|
"defaultUserPathEncryptionKey": localPathEncryptionKey,
|
||||||
|
"defaultTeamPathEncryptionKey": localPathEncryptionKey,
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +451,7 @@ func buildAvailableModelsPayload(reqCtx *RequestContext) (map[string]any, error)
|
|||||||
if len(modelRefs) > 0 {
|
if len(modelRefs) > 0 {
|
||||||
defaultModel = modelRefs[0]
|
defaultModel = modelRefs[0]
|
||||||
}
|
}
|
||||||
|
modelEntries := buildAvailableModelEntries(adapters)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"backgroundComposerModelConfig": map[string]any{
|
"backgroundComposerModelConfig": map[string]any{
|
||||||
"bestOfNDefaultModels": append([]string(nil), modelRefs...),
|
"bestOfNDefaultModels": append([]string(nil), modelRefs...),
|
||||||
@@ -459,7 +471,7 @@ func buildAvailableModelsPayload(reqCtx *RequestContext) (map[string]any, error)
|
|||||||
"defaultModel": defaultModel,
|
"defaultModel": defaultModel,
|
||||||
},
|
},
|
||||||
"disableUnusedModelsAfterNHours": availableModelsDisableUnusedHours,
|
"disableUnusedModelsAfterNHours": availableModelsDisableUnusedHours,
|
||||||
"models": buildAvailableModelEntries(adapters),
|
"models": modelEntries,
|
||||||
"planExecutionModelConfig": map[string]any{
|
"planExecutionModelConfig": map[string]any{
|
||||||
"defaultModel": defaultModel,
|
"defaultModel": defaultModel,
|
||||||
"fallbackModels": append([]string(nil), modelRefs...),
|
"fallbackModels": append([]string(nil), modelRefs...),
|
||||||
@@ -486,6 +498,35 @@ func buildDefaultModelNudgeDataPayload(reqCtx *RequestContext) (map[string]any,
|
|||||||
}, nil
|
}, 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) {
|
func buildBootstrapStatsigPayload(reqCtx *RequestContext) (map[string]any, error) {
|
||||||
generatedAtMs := uint64(time.Now().UnixMilli())
|
generatedAtMs := uint64(time.Now().UnixMilli())
|
||||||
authID := resolveBootstrapStatsigAuthID(reqCtx)
|
authID := resolveBootstrapStatsigAuthID(reqCtx)
|
||||||
@@ -679,6 +720,25 @@ func buildAvailableModelEntries(adapters []legacyruntime.ModelAdapterConfig) []m
|
|||||||
return output
|
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 {
|
func buildThinkingEffortParameterDefinitions(adapterType string) []map[string]any {
|
||||||
values := thinkingEffortValuesForAdapter(adapterType)
|
values := thinkingEffortValuesForAdapter(adapterType)
|
||||||
options := make([]map[string]any, 0, len(values))
|
options := make([]map[string]any, 0, len(values))
|
||||||
@@ -823,6 +883,16 @@ func collectModelAdapterRefs(adapters []legacyruntime.ModelAdapterConfig) []stri
|
|||||||
return output
|
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 {
|
func resolveBootstrapStatsigAuthID(reqCtx *RequestContext) string {
|
||||||
if reqCtx != nil {
|
if reqCtx != nil {
|
||||||
if authID := authIDFromBearer(reqCtx.Headers.Get("authorization")); authID != "" {
|
if authID := authIDFromBearer(reqCtx.Headers.Get("authorization")); authID != "" {
|
||||||
|
|||||||
@@ -2,9 +2,55 @@ package upstream
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"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) {
|
func TestBuildBootstrapStatsigConfigJSONDisablesAlwaysLocalDecompositionGate(t *testing.T) {
|
||||||
payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id")
|
payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+1
-7
@@ -8,11 +8,5 @@ QQ交流群:
|
|||||||
Tg群组:
|
Tg群组:
|
||||||
https://t.me/cursor_byok
|
https://t.me/cursor_byok
|
||||||
|
|
||||||
- 修复commands无法识别的问题 @DedSecer
|
- 支持cursor-cli
|
||||||
- 修复 summarize 指令(主动压缩) @DedSecer
|
|
||||||
- 修复 MiniMax 禁止 thinking 的问题 @Octopus
|
|
||||||
- 支持Fork message (需要新开对话) @DedSecer
|
|
||||||
- 支持 @关联对话 @DedSecer
|
|
||||||
- 支持插件市场(须登录你的任意账号) @aike1202
|
|
||||||
- 新增支持 cursor debugger 调试器, 用法请看.agents/skills/coding-guidance/SKILL.md
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user