mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b8d1c9e3d | ||
|
|
7a67e76617 | ||
|
|
08ce12aa55 | ||
|
|
8f8d28880d | ||
|
|
988ba63d40 | ||
|
|
edd59dc684 | ||
|
|
da5fa34a4d | ||
|
|
4bd2359282 | ||
|
|
7838ffc6a2 | ||
|
|
ee30775be1 | ||
|
|
80a5093aa9 | ||
|
|
b9742b1667 | ||
|
|
7d3e74be59 | ||
|
|
1285bf9d62 | ||
|
|
7a724595eb | ||
|
|
00aec40e50 | ||
|
|
c10a2d475d | ||
|
|
4864d3675b | ||
|
|
3f95318a49 | ||
|
|
9373e57ebf | ||
|
|
f1992b0cfe | ||
|
|
67a9c27931 | ||
|
|
3cf8bdbc3c | ||
|
|
684953a80b | ||
|
|
85a43115c7 | ||
|
|
b475166ba8 |
+107
@@ -0,0 +1,107 @@
|
||||
<div align="center">
|
||||
|
||||
# cursor-byok
|
||||
|
||||
cursor-byok 是 Cursor 后端的本地实现。
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
[使用教程](https://docs.leokun.cn) · [下载最新版](https://github.com/leookun/cursor-byok/releases/latest) · [问题反馈](https://github.com/leookun/cursor-byok/issues) · [English](./README.md)
|
||||
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
[](https://github.com/leookun/cursor-byok/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 项目介绍
|
||||
|
||||
cursor-byok 是一个开源的 Cursor 本地模型接入工具。它通过运行在本机的服务连接 Cursor 与你配置的模型 API,让模型请求使用自己的渠道处理,同时保留 Cursor Agent 的工具调用、Skills 和 MCP 等能力。
|
||||
|
||||
你可以接入 OpenAI、Anthropic 及其兼容服务,自由配置接口地址、模型、密钥和请求参数,不再局限于平台预设的模型渠道。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> cursor-byok 本身免费开源,但你接入的模型 API 可能由对应服务商收费。本项目不是 Cursor 官方产品,与 Cursor 或其开发公司无隶属关系。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- **自定义模型渠道**:配置自己的 API 地址、访问密钥和模型标识。
|
||||
- **多种接口协议**:支持 OpenAI、Anthropic 兼容接口及自定义端点。
|
||||
- **模型管理**:添加、复制、编辑、排序和批量测试多个模型配置。
|
||||
- **连接性能测试**:查看首字延迟、生成速度与模型服务的原始响应。
|
||||
- **Agent 工作流**:支持工具调用、Skills、MCP 和多轮会话。
|
||||
- **会话统计**:查看 Token 消耗、缓存命中率、对话轮次和价值估算。
|
||||
- **跨平台运行**:支持 macOS、Windows 和 Linux。
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载对应平台的最新版本。
|
||||
2. 启动 cursor-byok,打开“模型配置”,填写接口地址、API Key 和模型标识。
|
||||
3. 测试模型配置;测试通过后返回主界面启动服务。
|
||||
4. 打开 Cursor,选择已配置的模型并开始使用 Agent。
|
||||
|
||||
更完整的安装、系统配置和常见问题说明,请查看 [详细使用教程](https://docs.leokun.cn)。
|
||||
|
||||
## 模型管理
|
||||
|
||||
模型配置支持 OpenAI 与 Anthropic 两类接口协议。每个模型渠道可以独立设置上下文窗口、最大输出 Token、推理强度、自定义请求头和额外请求参数。
|
||||
|
||||

|
||||
|
||||
## 工作原理
|
||||
|
||||
```text
|
||||
Cursor 客户端
|
||||
│
|
||||
│ Agent 请求与工具结果
|
||||
▼
|
||||
cursor-byok 本地服务
|
||||
│
|
||||
│ OpenAI / Anthropic 兼容请求
|
||||
▼
|
||||
你配置的模型 API
|
||||
```
|
||||
|
||||
cursor-byok 在本机负责协议适配、模型请求转发、工具调用衔接与会话状态管理。模型 API Key 和应用配置保存在本机;实际请求仍会发送到你所配置的模型服务商。
|
||||
|
||||
## 为什么做这个项目
|
||||
|
||||
很多 Agent 产品会将工具能力、模型选择、订阅方案和计费方式绑定在一起,用户只能使用平台提供的模型渠道。
|
||||
|
||||
我希望将模型选择权交还给用户:开发者可以充分利用已有的模型 API 和额度,自由选择适合自己的模型与服务商,也可以在需要时自托管相关服务。
|
||||
|
||||
## 路线图
|
||||
|
||||
项目将继续改进模型兼容性、Agent 工具链、本地运行稳定性和自托管体验,并探索更多 IDE、Chat 与 Agent 场景。
|
||||
|
||||
详细计划与进展请查看 [正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)。
|
||||
|
||||
## 社区与支持
|
||||
|
||||
- [使用教程](https://docs.leokun.cn)
|
||||
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
|
||||
- [Telegram 交流群](https://t.me/cursor_byok)
|
||||
- QQ 交流群:`1095916242`、`1094411438`、`1095918002`、`1094419321`
|
||||
|
||||
|
||||
## 开发与贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request。开发环境、构建命令、项目结构及提交规范请阅读 [贡献指南](./CONTRIBUTING.md)。
|
||||
|
||||
|
||||
## 贡献者名单
|
||||
|
||||
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
|
||||
</a>
|
||||
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [MIT License](./LICENSE) 开源。
|
||||
@@ -1,95 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
# cursor-byok
|
||||
|
||||
[User Guide](https://docs.leokun.cn) · [Latest Release](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [简体中文](./README.md)
|
||||
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
[](https://github.com/leookun/cursor-byok/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## About
|
||||
|
||||
cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP.
|
||||
|
||||
You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
|
||||
|
||||
## Features
|
||||
|
||||
- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
|
||||
- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
|
||||
- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
|
||||
- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
|
||||
- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
|
||||
- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
|
||||
- **Cross-platform:** Run on macOS, Windows, and Linux.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
|
||||
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
|
||||
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
|
||||
4. Open Cursor, select the configured model, and start using Agent.
|
||||
|
||||
For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
|
||||
|
||||
## Model Management
|
||||
|
||||
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
|
||||
|
||||

|
||||
|
||||
## How It Works
|
||||
|
||||
```text
|
||||
Cursor client
|
||||
│
|
||||
│ Agent requests and tool results
|
||||
▼
|
||||
cursor-byok local service
|
||||
│
|
||||
│ OpenAI- / Anthropic-compatible requests
|
||||
▼
|
||||
Your model API
|
||||
```
|
||||
|
||||
cursor-byok handles protocol adaptation, model request forwarding, tool-call coordination, and conversation state on your machine. API keys and application settings are stored locally; requests are still sent to the model provider you configure.
|
||||
|
||||
## Why This Project
|
||||
|
||||
Many Agent products bundle their tool capabilities with a fixed set of models, subscriptions, and billing options, leaving users limited to the channels offered by the platform.
|
||||
|
||||
cursor-byok is built to return model choice to the user. Developers can make full use of the APIs and credits they already have, choose the models and providers that fit their needs, and self-host related services when required.
|
||||
|
||||
## Roadmap
|
||||
|
||||
The project will continue to improve model compatibility, Agent tooling, local runtime stability, and the self-hosting experience while exploring support for more IDE, chat, and Agent workflows.
|
||||
|
||||
See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32) for plans and progress.
|
||||
|
||||
## Community and Support
|
||||
|
||||
- [User Guide](https://docs.leokun.cn)
|
||||
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
|
||||
- [Telegram community](https://t.me/cursor_byok)
|
||||
- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
|
||||
|
||||
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
## Development and Contributing
|
||||
|
||||
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
|
||||
|
||||
## License
|
||||
|
||||
This project is open source under the [MIT License](./LICENSE).
|
||||
@@ -1,95 +1,110 @@
|
||||
<div align="center">
|
||||
|
||||
# cursor-byok
|
||||
cursor-byok is a local implementation of Cursor's backend.
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
[使用教程](https://docs.leokun.cn) · [下载最新版](https://github.com/leookun/cursor-byok/releases/latest) · [问题反馈](https://github.com/leookun/cursor-byok/issues) · [English](./README-EN.md)
|
||||
[User Guide](https://docs.leokun.cn) · [Download](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [中文版本说明](./README-CN.md)
|
||||
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
[](https://github.com/leookun/cursor-byok/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## 项目介绍
|
||||
## About
|
||||
|
||||
cursor-byok 是一个开源的 Cursor 本地模型接入工具。它通过运行在本机的服务连接 Cursor 与你配置的模型 API,让模型请求使用自己的渠道处理,同时保留 Cursor Agent 的工具调用、Skills 和 MCP 等能力。
|
||||
cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP.
|
||||
|
||||
你可以接入 OpenAI、Anthropic 及其兼容服务,自由配置接口地址、模型、密钥和请求参数,不再局限于平台预设的模型渠道。
|
||||
You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> cursor-byok 本身免费开源,但你接入的模型 API 可能由对应服务商收费。本项目不是 Cursor 官方产品,与 Cursor 或其开发公司无隶属关系。
|
||||
> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
|
||||
|
||||
## 核心能力
|
||||
## Features
|
||||
|
||||
- **自定义模型渠道**:配置自己的 API 地址、访问密钥和模型标识。
|
||||
- **多种接口协议**:支持 OpenAI、Anthropic 兼容接口及自定义端点。
|
||||
- **模型管理**:添加、复制、编辑、排序和批量测试多个模型配置。
|
||||
- **连接性能测试**:查看首字延迟、生成速度与模型服务的原始响应。
|
||||
- **Agent 工作流**:支持工具调用、Skills、MCP 和多轮会话。
|
||||
- **会话统计**:查看 Token 消耗、缓存命中率、对话轮次和价值估算。
|
||||
- **跨平台运行**:支持 macOS、Windows 和 Linux。
|
||||
- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
|
||||
- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
|
||||
- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
|
||||
- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
|
||||
- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
|
||||
- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
|
||||
- **Cross-platform:** Run on macOS, Windows, and Linux.
|
||||
|
||||
## 快速开始
|
||||
## Quick Start
|
||||
|
||||
1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载对应平台的最新版本。
|
||||
2. 启动 cursor-byok,打开“模型配置”,填写接口地址、API Key 和模型标识。
|
||||
3. 测试模型配置;测试通过后返回主界面启动服务。
|
||||
4. 打开 Cursor,选择已配置的模型并开始使用 Agent。
|
||||
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
|
||||
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
|
||||
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
|
||||
4. Open Cursor, select the configured model, and start using Agent.
|
||||
|
||||
更完整的安装、系统配置和常见问题说明,请查看 [详细使用教程](https://docs.leokun.cn)。
|
||||
For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
|
||||
|
||||
## 模型管理
|
||||
## Model Management
|
||||
|
||||
模型配置支持 OpenAI 与 Anthropic 两类接口协议。每个模型渠道可以独立设置上下文窗口、最大输出 Token、推理强度、自定义请求头和额外请求参数。
|
||||
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
|
||||
|
||||

|
||||

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