mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-19 12:37:55 +08:00
Merge pull request #269 from leookun/feat/idempotent-checkpoint-recovery
Feat/idempotent checkpoint recovery
This commit is contained in:
@@ -342,3 +342,15 @@ go run ./cmd/cursor-proxy-debugger
|
|||||||
- `PendingInteraction`
|
- `PendingInteraction`
|
||||||
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
|
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
|
||||||
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
|
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
|
||||||
|
|
||||||
|
### 5.1 checkpoint 投影必须幂等且只有一个事实源
|
||||||
|
|
||||||
|
- 把 checkpoint 当作 `state.json + context.json` 的纯投影,不要把它写成第二套语义历史。
|
||||||
|
- 不要创建或维护 `checkpoint.json`、checkpoint history、独立 checkpoint entry 序列等持久化事实源。
|
||||||
|
- 允许在当前 stream 内存中保留 latest checkpoint 供 retry/resume 使用;进程重启后必须能从唯一事实源重新投影。
|
||||||
|
- 对同一份 semantic history 重复投影时,要求 state、turn 顺序、blob ID 和 blob 内容在语义上完全一致;投影函数不得修改输入 history。
|
||||||
|
- 把重复发送视为同一快照的幂等覆盖,不要追加一条新的会话历史;内容寻址 blob 的重复写入必须可安全忽略。
|
||||||
|
- 将 `turns` 投影为 UI 可恢复的完整结构,保留所有需要展示的 `ThinkingMessage`、`ToolCall` 和工具结果;不要为了模型 prompt 过滤而删除 UI step。
|
||||||
|
- 将 `root_prompt_messages_json` 单独投影为模型 replay;只在这条投影上应用 provider/context 过滤,不能反向改变 `turns`。
|
||||||
|
- 将工具完成结果合并回同一 `ToolCall`,保留开始态的 `args`、调用 ID 和开始时间,再补齐 `result` 与完成时间;不要制造协议不存在的独立 `ToolResult` step。
|
||||||
|
- 用 TDD 覆盖至少这些性质:重复投影相等、投影不修改 history、开始态字段在结果合并后仍存在、UI turns 保留思考/工具内容而模型 replay 仍遵守独立过滤规则。
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ server-go/log/
|
|||||||
.cursor-local-assistant
|
.cursor-local-assistant
|
||||||
.cursor-local-assistant-v2
|
.cursor-local-assistant-v2
|
||||||
.cursor-app-formatted/
|
.cursor-app-formatted/
|
||||||
|
proto/extensions-cursor-app/
|
||||||
ads-server-linux-amd64.tar
|
ads-server-linux-amd64.tar
|
||||||
cmd/ads-server/*.db
|
cmd/ads-server/*.db
|
||||||
cmd/ads-server/*.db-*
|
cmd/ads-server/*.db-*
|
||||||
|
|||||||
+9
-2
@@ -13,8 +13,8 @@ tasks:
|
|||||||
preconditions:
|
preconditions:
|
||||||
- sh: 'test -z "{{.PROTO_INPUT}}" || test -f "{{.PROTO_INPUT}}"'
|
- sh: 'test -z "{{.PROTO_INPUT}}" || test -f "{{.PROTO_INPUT}}"'
|
||||||
msg: "PROTO_INPUT 指向的 Cursor 扩展 bundle 不存在。"
|
msg: "PROTO_INPUT 指向的 Cursor 扩展 bundle 不存在。"
|
||||||
- sh: 'test -n "{{.PROTO_INPUT}}" || test -f ./proto/extensions-cursor-app/cursor-always-local/dist/main.js || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
|
- sh: 'test -n "{{.PROTO_INPUT}}" || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
|
||||||
msg: "未找到 Cursor 扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
|
msg: "未找到已安装 Cursor 的扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
|
||||||
cmds:
|
cmds:
|
||||||
- chmod +x ./proto/extract_extensions_proto.sh
|
- chmod +x ./proto/extract_extensions_proto.sh
|
||||||
- '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}'
|
- '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}'
|
||||||
@@ -29,9 +29,16 @@ tasks:
|
|||||||
- cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto
|
- cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto
|
||||||
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto
|
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto
|
||||||
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto
|
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto
|
||||||
|
- ./proto/check_proto_sync.sh
|
||||||
- rm -rf ./gen/agentv1 ./gen/aiserverv1
|
- rm -rf ./gen/agentv1 ./gen/aiserverv1
|
||||||
- task: generate:proto
|
- task: generate:proto
|
||||||
|
|
||||||
|
check:proto:
|
||||||
|
summary: 检查根 proto 与扩展提取快照是否一致
|
||||||
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
cmds:
|
||||||
|
- ./proto/check_proto_sync.sh
|
||||||
|
|
||||||
generate:proto:
|
generate:proto:
|
||||||
summary: 生成 proto Go/Connect 代码
|
summary: 生成 proto Go/Connect 代码
|
||||||
dir: '{{.ROOT_DIR}}'
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[中文](README.md) | [English](README.en.md)
|
[中文](README.md) | [English](README.en.md)
|
||||||
|
|
||||||
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend` and `RunSSE` traffic. It does not modify Cursor, the system proxy, or the installed client.
|
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend`, `RunSSE`, and Fork Chat traffic. It does not modify Cursor, the system proxy, or the installed client.
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
@@ -51,6 +51,8 @@ go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
|||||||
- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
|
- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
|
||||||
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
|
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
|
||||||
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
|
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
|
||||||
|
- Fork Chat's `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs` traffic is decoded bidirectionally as protobuf JSON.
|
||||||
|
- Local Fork Chat is primarily client-side and only emits `NotifyConversationClone` and `UploadConversationBlobs` when clone blob synchronization is enabled and privacy settings allow it.
|
||||||
- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
|
- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
|
||||||
- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
|
- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
|
||||||
- Captured traffic is stored only in process memory and is discarded when the process exits.
|
- Captured traffic is stored only in process memory and is discarded when the process exits.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[中文](README.md) | [English](README.en.md)
|
[中文](README.md) | [English](README.en.md)
|
||||||
|
|
||||||
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend` 和 `RunSSE` 通信。它不会修改 Cursor、系统代理或已安装客户端。
|
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend`、`RunSSE` 和 Fork Chat 相关通信。它不会修改 Cursor、系统代理或已安装客户端。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@@ -51,6 +51,8 @@ go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
|||||||
- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
|
- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
|
||||||
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
|
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
|
||||||
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。
|
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。
|
||||||
|
- Fork Chat 相关的 `ForkBackgroundComposer`、`NotifyConversationClone` 和 `UploadConversationBlobs` 会双向解码为 protobuf JSON。
|
||||||
|
- 本地 Fork Chat 主要在客户端完成,只有启用克隆 blob 同步且隐私设置允许时才会产生 `NotifyConversationClone` 和 `UploadConversationBlobs` 流量。
|
||||||
- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
|
- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
|
||||||
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
|
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
|
||||||
- 抓包只保留在当前进程内存中;关闭进程后消失。
|
- 抓包只保留在当前进程内存中;关闭进程后消失。
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ import (
|
|||||||
|
|
||||||
const maxConnectFrameBytes = 64 << 20
|
const maxConnectFrameBytes = 64 << 20
|
||||||
|
|
||||||
|
const (
|
||||||
|
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
|
||||||
|
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
|
||||||
|
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
|
||||||
|
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
|
||||||
|
)
|
||||||
|
|
||||||
type connectFrameDecoder struct {
|
type connectFrameDecoder struct {
|
||||||
buffer []byte
|
buffer []byte
|
||||||
messageType string
|
messageType string
|
||||||
@@ -140,10 +147,9 @@ func decompressPayload(payload []byte, codec string) ([]byte, error) {
|
|||||||
return decoded, nil
|
return decoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
|
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
|
||||||
var message proto.Message
|
|
||||||
switch path {
|
switch path {
|
||||||
case "/aiserver.v1.BidiService/BidiAppend":
|
case bidiAppendPath:
|
||||||
request := &aiserverv1.BidiAppendRequest{}
|
request := &aiserverv1.BidiAppendRequest{}
|
||||||
if err := proto.Unmarshal(payload, request); err != nil {
|
if err := proto.Unmarshal(payload, request); err != nil {
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
@@ -165,13 +171,65 @@ func decodeUnary(path string, payload []byte) (decodedJSON string, kind string,
|
|||||||
}
|
}
|
||||||
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
|
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
|
||||||
return string(formatted), clientKind, requestID, marshalErr
|
return string(formatted), clientKind, requestID, marshalErr
|
||||||
default:
|
|
||||||
message = nil
|
|
||||||
}
|
}
|
||||||
|
message, kind := unaryRequestMessage(path)
|
||||||
if message == nil {
|
if message == nil {
|
||||||
return "", "", "", nil
|
return "", "", "", nil
|
||||||
}
|
}
|
||||||
return marshalProtoJSON(message), activeOneofName(message), "", nil
|
if err := proto.Unmarshal(payload, message); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
return marshalProtoJSON(message), kind, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) {
|
||||||
|
message, kind := unaryResponseMessage(path)
|
||||||
|
if message == nil {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
if err := proto.Unmarshal(payload, message); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return marshalProtoJSON(message), kind, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unaryRequestMessage(path string) (proto.Message, string) {
|
||||||
|
switch path {
|
||||||
|
case forkBackgroundComposerPath:
|
||||||
|
return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request"
|
||||||
|
case notifyConversationClonePath:
|
||||||
|
return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request"
|
||||||
|
case uploadConversationBlobsPath:
|
||||||
|
return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request"
|
||||||
|
default:
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unaryResponseMessage(path string) (proto.Message, string) {
|
||||||
|
switch path {
|
||||||
|
case forkBackgroundComposerPath:
|
||||||
|
return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response"
|
||||||
|
case notifyConversationClonePath:
|
||||||
|
return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response"
|
||||||
|
case uploadConversationBlobsPath:
|
||||||
|
return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response"
|
||||||
|
default:
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodesUnaryRequest(path string) bool {
|
||||||
|
if path == bidiAppendPath {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
message, _ := unaryRequestMessage(path)
|
||||||
|
return message != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodesUnaryResponse(path string) bool {
|
||||||
|
message, _ := unaryResponseMessage(path)
|
||||||
|
return message != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMessage(messageType string) proto.Message {
|
func newMessage(messageType string) proto.Message {
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package proxydebugger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
"cursor/gen/aiserverv1"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecodeForkTrafficRequests(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
message proto.Message
|
||||||
|
kind string
|
||||||
|
contains []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fork background composer",
|
||||||
|
path: forkBackgroundComposerPath,
|
||||||
|
message: &aiserverv1.ForkBackgroundComposerRequest{
|
||||||
|
SourceBcId: "bc-source",
|
||||||
|
Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION,
|
||||||
|
Name: proto.String("forked chat"),
|
||||||
|
TurnCount: proto.Uint32(4),
|
||||||
|
},
|
||||||
|
kind: "fork_background_composer_request",
|
||||||
|
contains: []string{`"source_bc_id":"bc-source"`, `"turn_count":4`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notify conversation clone",
|
||||||
|
path: notifyConversationClonePath,
|
||||||
|
message: &agentv1.NotifyConversationCloneRequest{
|
||||||
|
ConversationId: "new-conversation",
|
||||||
|
SourceConversationId: "source-conversation",
|
||||||
|
SourceRequestId: "source-request",
|
||||||
|
},
|
||||||
|
kind: "notify_conversation_clone_request",
|
||||||
|
contains: []string{`"conversation_id":"new-conversation"`, `"source_conversation_id":"source-conversation"`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upload conversation blobs",
|
||||||
|
path: uploadConversationBlobsPath,
|
||||||
|
message: &agentv1.UploadConversationBlobsRequest{
|
||||||
|
ConversationId: "new-conversation",
|
||||||
|
Blobs: []*agentv1.BlobEntry{{
|
||||||
|
Id: []byte{1, 2},
|
||||||
|
Value: []byte("blob-value"),
|
||||||
|
}},
|
||||||
|
ChunkIndex: 1,
|
||||||
|
TotalChunks: 2,
|
||||||
|
},
|
||||||
|
kind: "upload_conversation_blobs_request",
|
||||||
|
contains: []string{`"conversation_id":"new-conversation"`, `"total_chunks":2`, `"value":"YmxvYi12YWx1ZQ=="`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
test := test
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
payload, err := proto.Marshal(test.message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decoded, kind, requestID, err := decodeUnaryRequest(test.path, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
if kind != test.kind {
|
||||||
|
t.Fatalf("kind = %q, want %q", kind, test.kind)
|
||||||
|
}
|
||||||
|
if requestID != "" {
|
||||||
|
t.Fatalf("request ID = %q, want empty", requestID)
|
||||||
|
}
|
||||||
|
compact := compactJSON(t, decoded)
|
||||||
|
for _, expected := range test.contains {
|
||||||
|
if !strings.Contains(compact, expected) {
|
||||||
|
t.Errorf("decoded JSON does not contain %q:\n%s", expected, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeForkTrafficResponses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
message proto.Message
|
||||||
|
kind string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fork background composer",
|
||||||
|
path: forkBackgroundComposerPath,
|
||||||
|
message: &aiserverv1.ForkBackgroundComposerResponse{
|
||||||
|
BcId: "bc-fork",
|
||||||
|
SourceBcId: "bc-source",
|
||||||
|
Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION,
|
||||||
|
},
|
||||||
|
kind: "fork_background_composer_response",
|
||||||
|
contains: `"bc_id":"bc-fork"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notify conversation clone",
|
||||||
|
path: notifyConversationClonePath,
|
||||||
|
message: &agentv1.NotifyConversationCloneResponse{},
|
||||||
|
kind: "notify_conversation_clone_response",
|
||||||
|
contains: `{}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upload conversation blobs",
|
||||||
|
path: uploadConversationBlobsPath,
|
||||||
|
message: &agentv1.UploadConversationBlobsResponse{},
|
||||||
|
kind: "upload_conversation_blobs_response",
|
||||||
|
contains: `{}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
test := test
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
payload, err := proto.Marshal(test.message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decoded, kind, err := decodeUnaryResponse(test.path, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if kind != test.kind {
|
||||||
|
t.Fatalf("kind = %q, want %q", kind, test.kind)
|
||||||
|
}
|
||||||
|
if !strings.Contains(compactJSON(t, decoded), test.contains) {
|
||||||
|
t.Errorf("decoded JSON does not contain %q:\n%s", test.contains, decoded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishResponseBodyDecodesCompressedForkResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
payload, err := proto.Marshal(&aiserverv1.ForkBackgroundComposerResponse{
|
||||||
|
BcId: "bc-fork",
|
||||||
|
SourceBcId: "bc-source",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var compressed bytes.Buffer
|
||||||
|
writer := gzip.NewWriter(&compressed)
|
||||||
|
if _, err := writer.Write(payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &Server{store: newExchangeStore(1)}
|
||||||
|
server.store.create(&Exchange{
|
||||||
|
ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()},
|
||||||
|
})
|
||||||
|
server.finishResponseBody("1", forkBackgroundComposerPath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil)
|
||||||
|
|
||||||
|
exchange, ok := server.store.get("1")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("exchange was not stored")
|
||||||
|
}
|
||||||
|
if exchange.ResponseKind != "fork_background_composer_response" {
|
||||||
|
t.Fatalf("response kind = %q", exchange.ResponseKind)
|
||||||
|
}
|
||||||
|
if !strings.Contains(compactJSON(t, exchange.Response.DecodedJSON), `"bc_id":"bc-fork"`) {
|
||||||
|
t.Fatalf("unexpected decoded response:\n%s", exchange.Response.DecodedJSON)
|
||||||
|
}
|
||||||
|
if exchange.Response.DecodeError != "" {
|
||||||
|
t.Fatalf("decode error = %q", exchange.Response.DecodeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishRequestBodyDecodesCompressedCloneRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
payload, err := proto.Marshal(&agentv1.NotifyConversationCloneRequest{
|
||||||
|
ConversationId: "new-conversation",
|
||||||
|
SourceConversationId: "source-conversation",
|
||||||
|
SourceRequestId: "source-request",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var compressed bytes.Buffer
|
||||||
|
writer := gzip.NewWriter(&compressed)
|
||||||
|
if _, err := writer.Write(payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &Server{store: newExchangeStore(1)}
|
||||||
|
server.store.create(&Exchange{
|
||||||
|
ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()},
|
||||||
|
})
|
||||||
|
server.finishRequestBody("1", notifyConversationClonePath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil)
|
||||||
|
|
||||||
|
exchange, ok := server.store.get("1")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("exchange was not stored")
|
||||||
|
}
|
||||||
|
if exchange.RequestKind != "notify_conversation_clone_request" {
|
||||||
|
t.Fatalf("request kind = %q", exchange.RequestKind)
|
||||||
|
}
|
||||||
|
if !strings.Contains(compactJSON(t, exchange.Request.DecodedJSON), `"source_conversation_id":"source-conversation"`) {
|
||||||
|
t.Fatalf("unexpected decoded request:\n%s", exchange.Request.DecodedJSON)
|
||||||
|
}
|
||||||
|
if exchange.Request.DecodeError != "" {
|
||||||
|
t.Fatalf("decode error = %q", exchange.Request.DecodeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactJSON(t *testing.T, value string) string {
|
||||||
|
t.Helper()
|
||||||
|
var compact bytes.Buffer
|
||||||
|
if err := json.Compact(&compact, []byte(value)); err != nil {
|
||||||
|
t.Fatalf("compact JSON: %v\n%s", err, value)
|
||||||
|
}
|
||||||
|
return compact.String()
|
||||||
|
}
|
||||||
@@ -226,28 +226,29 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy.
|
|||||||
if id == "" || response == nil {
|
if id == "" || response == nil {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
path := ""
|
||||||
|
if response.Request != nil && response.Request.URL != nil {
|
||||||
|
path = response.Request.URL.Path
|
||||||
|
}
|
||||||
|
responseCodec := responseContentCodec(path, response.Header)
|
||||||
server.store.update(id, func(exchange *Exchange) {
|
server.store.update(id, func(exchange *Exchange) {
|
||||||
exchange.Status = response.StatusCode
|
exchange.Status = response.StatusCode
|
||||||
exchange.State = "streaming"
|
exchange.State = "streaming"
|
||||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||||
exchange.Response.Headers = sortedHeaders(response.Header)
|
exchange.Response.Headers = sortedHeaders(response.Header)
|
||||||
exchange.Response.ContentType = response.Header.Get("Content-Type")
|
exchange.Response.ContentType = response.Header.Get("Content-Type")
|
||||||
exchange.Response.ContentCodec = responseContentCodec(response.Header)
|
exchange.Response.ContentCodec = responseCodec
|
||||||
})
|
})
|
||||||
if response.Body == nil {
|
if response.Body == nil {
|
||||||
server.finishResponseBody(id, nil, 0, false, nil)
|
server.finishResponseBody(id, path, responseCodec, nil, 0, false, nil)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
path := ""
|
|
||||||
if response.Request != nil && response.Request.URL != nil {
|
|
||||||
path = response.Request.URL.Path
|
|
||||||
}
|
|
||||||
var frameDecoder *connectFrameDecoder
|
var frameDecoder *connectFrameDecoder
|
||||||
if path == "/agent.v1.AgentService/RunSSE" {
|
if path == "/agent.v1.AgentService/RunSSE" {
|
||||||
frameDecoder = newConnectFrameDecoder(
|
frameDecoder = newConnectFrameDecoder(
|
||||||
"agent.v1.AgentServerMessage",
|
"agent.v1.AgentServerMessage",
|
||||||
response.Header.Get("Connect-Content-Encoding"),
|
responseCodec,
|
||||||
server.config.MaxFrames,
|
server.config.MaxFrames,
|
||||||
func(frame FrameView) { server.appendResponseFrame(id, frame) },
|
func(frame FrameView) { server.appendResponseFrame(id, frame) },
|
||||||
)
|
)
|
||||||
@@ -264,7 +265,7 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy.
|
|||||||
if frameDecoder != nil {
|
if frameDecoder != nil {
|
||||||
frameDecoder.Close()
|
frameDecoder.Close()
|
||||||
}
|
}
|
||||||
server.finishResponseBody(id, captured, size, truncated, readErr)
|
server.finishResponseBody(id, path, responseCodec, captured, size, truncated, readErr)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
@@ -273,14 +274,14 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy.
|
|||||||
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||||
decodePayload := captured
|
decodePayload := captured
|
||||||
var contentDecodeErr error
|
var contentDecodeErr error
|
||||||
if path == "/aiserver.v1.BidiService/BidiAppend" && truncated {
|
if decodesUnaryRequest(path) && truncated {
|
||||||
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
|
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
|
||||||
} else if path == "/aiserver.v1.BidiService/BidiAppend" && codec != "" && !strings.EqualFold(codec, "identity") {
|
} else if decodesUnaryRequest(path) && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||||
}
|
}
|
||||||
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
|
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
|
||||||
if decodeErr == nil {
|
if decodeErr == nil {
|
||||||
decodedJSON, kind, requestID, decodeErr = decodeUnary(path, decodePayload)
|
decodedJSON, kind, requestID, decodeErr = decodeUnaryRequest(path, decodePayload)
|
||||||
}
|
}
|
||||||
server.store.update(id, func(exchange *Exchange) {
|
server.store.update(id, func(exchange *Exchange) {
|
||||||
exchange.RequestBytes = size
|
exchange.RequestBytes = size
|
||||||
@@ -312,19 +313,44 @@ func requestContentCodec(path string, headers http.Header) string {
|
|||||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func responseContentCodec(headers http.Header) string {
|
func responseContentCodec(path string, headers http.Header) string {
|
||||||
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
|
if path == "/agent.v1.AgentService/RunSSE" {
|
||||||
return codec
|
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||||
|
}
|
||||||
|
if !decodesUnaryResponse(path) {
|
||||||
|
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
|
||||||
|
return codec
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (server *Server) finishResponseBody(id string, captured []byte, size int64, truncated bool, readErr error) {
|
func (server *Server) finishResponseBody(id, path, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||||
|
decodePayload := captured
|
||||||
|
var contentDecodeErr error
|
||||||
|
if decodesUnaryResponse(path) && truncated {
|
||||||
|
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
|
||||||
|
} else if decodesUnaryResponse(path) && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||||
|
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||||
|
}
|
||||||
|
decodedJSON, kind, decodeErr := "", "", contentDecodeErr
|
||||||
|
if decodeErr == nil {
|
||||||
|
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
|
||||||
|
}
|
||||||
server.store.update(id, func(exchange *Exchange) {
|
server.store.update(id, func(exchange *Exchange) {
|
||||||
exchange.ResponseBytes = size
|
exchange.ResponseBytes = size
|
||||||
exchange.Response.Size = size
|
exchange.Response.Size = size
|
||||||
exchange.Response.RawHex = rawHex(captured)
|
exchange.Response.RawHex = rawHex(captured)
|
||||||
exchange.Response.RawTruncated = truncated
|
exchange.Response.RawTruncated = truncated
|
||||||
|
if decodedJSON != "" {
|
||||||
|
exchange.Response.DecodedJSON = decodedJSON
|
||||||
|
}
|
||||||
|
if kind != "" {
|
||||||
|
exchange.ResponseKind = kind
|
||||||
|
}
|
||||||
|
if decodeErr != nil {
|
||||||
|
exchange.Response.DecodeError = decodeErr.Error()
|
||||||
|
}
|
||||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||||
exchange.State = "completed"
|
exchange.State = "completed"
|
||||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ function filteredExchanges() {
|
|||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
|
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
|
||||||
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
|
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
|
||||||
|
if (state.endpoint === "fork" && !isForkTrafficPath(item.path)) return false;
|
||||||
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
|
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
|
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
|
||||||
@@ -151,6 +152,13 @@ function filteredExchanges() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isForkTrafficPath(path) {
|
||||||
|
const normalized = String(path || "").toLowerCase();
|
||||||
|
return ["forkbackgroundcomposer", "notifyconversationclone", "uploadconversationblobs"].some((endpoint) =>
|
||||||
|
normalized.includes(endpoint),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const exchanges = filteredExchanges();
|
const exchanges = filteredExchanges();
|
||||||
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
|
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const messages = {
|
|||||||
"filters.requestIdPlaceholder": "按 Request ID 过滤",
|
"filters.requestIdPlaceholder": "按 Request ID 过滤",
|
||||||
"filters.endpoint": "接口过滤",
|
"filters.endpoint": "接口过滤",
|
||||||
"filters.all": "全部",
|
"filters.all": "全部",
|
||||||
|
"filters.fork": "Fork",
|
||||||
"filters.sort": "排序方向",
|
"filters.sort": "排序方向",
|
||||||
"filters.ascending": "正序",
|
"filters.ascending": "正序",
|
||||||
"filters.descending": "倒序",
|
"filters.descending": "倒序",
|
||||||
@@ -81,6 +82,7 @@ const messages = {
|
|||||||
"filters.requestIdPlaceholder": "Filter by Request ID",
|
"filters.requestIdPlaceholder": "Filter by Request ID",
|
||||||
"filters.endpoint": "Endpoint filter",
|
"filters.endpoint": "Endpoint filter",
|
||||||
"filters.all": "All",
|
"filters.all": "All",
|
||||||
|
"filters.fork": "Fork",
|
||||||
"filters.sort": "Sort order",
|
"filters.sort": "Sort order",
|
||||||
"filters.ascending": "Oldest first",
|
"filters.ascending": "Oldest first",
|
||||||
"filters.descending": "Newest first",
|
"filters.descending": "Newest first",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
|
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
|
||||||
<button type="button" data-value="runsse">RunSSE</button>
|
<button type="button" data-value="runsse">RunSSE</button>
|
||||||
<button type="button" data-value="bidiappend">BidiAppend</button>
|
<button type="button" data-value="bidiappend">BidiAppend</button>
|
||||||
|
<button type="button" data-value="fork" data-i18n="filters.fork">Fork</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
|
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
|
||||||
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
|
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
|
||||||
|
|||||||
@@ -690,9 +690,21 @@ func appendEntriesInPlace(conversation *ConversationFile, entries []HistoryEntry
|
|||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
assigned := make([]HistoryEntry, 0, len(entries))
|
assigned := make([]HistoryEntry, 0, len(entries))
|
||||||
|
existingIdempotencyKeys := make(map[string]struct{})
|
||||||
|
for _, existing := range conversation.Entries {
|
||||||
|
if key := strings.TrimSpace(existing.IdempotencyKey); key != "" {
|
||||||
|
existingIdempotencyKeys[key] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
maxTurnSeq := conversation.NextTurnSeq - 1
|
maxTurnSeq := conversation.NextTurnSeq - 1
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
next := entry
|
next := entry
|
||||||
|
if key := strings.TrimSpace(next.IdempotencyKey); key != "" {
|
||||||
|
if _, exists := existingIdempotencyKeys[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingIdempotencyKeys[key] = struct{}{}
|
||||||
|
}
|
||||||
if next.CreatedAt.IsZero() {
|
if next.CreatedAt.IsZero() {
|
||||||
next.CreatedAt = now
|
next.CreatedAt = now
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppendEntriesDeduplicatesIdempotencyKey(t *testing.T) {
|
||||||
|
store := NewConversationFileStore(t.TempDir())
|
||||||
|
entry := HistoryEntry{
|
||||||
|
TurnSeq: 1,
|
||||||
|
RequestID: "request-1",
|
||||||
|
IdempotencyKey: "provider-interrupted-output:test",
|
||||||
|
Role: "assistant",
|
||||||
|
Kind: "assistant_text",
|
||||||
|
Payload: json.RawMessage(`{"text":"partial"}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, assigned, err := store.AppendEntries("conversation-1", []HistoryEntry{entry}); err != nil {
|
||||||
|
t.Fatalf("first AppendEntries() error = %v", err)
|
||||||
|
} else if len(assigned) != 1 {
|
||||||
|
t.Fatalf("first AppendEntries() assigned = %d, want 1", len(assigned))
|
||||||
|
}
|
||||||
|
if _, assigned, err := store.AppendEntries("conversation-1", []HistoryEntry{entry}); err != nil {
|
||||||
|
t.Fatalf("duplicate AppendEntries() error = %v", err)
|
||||||
|
} else if len(assigned) != 0 {
|
||||||
|
t.Fatalf("duplicate AppendEntries() assigned = %d, want 0", len(assigned))
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation, err := store.LoadConversation("conversation-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(conversation.Entries) != 1 {
|
||||||
|
t.Fatalf("persisted entries = %d, want 1", len(conversation.Entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelPersistsInterruptedProviderOutputIdempotently(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.ProviderAccumulatedText = "partial answer"
|
||||||
|
stream.ProviderAccumulatedReasoning = "partial reasoning"
|
||||||
|
stream.mu.Unlock()
|
||||||
|
|
||||||
|
cancel := InboundIntent{
|
||||||
|
Kind: "cancel",
|
||||||
|
RequestID: stream.RequestID,
|
||||||
|
CancelReason: "[canceled] Superseded by newer request",
|
||||||
|
}
|
||||||
|
if err := service.handleCancelIntent(cancel); err != nil {
|
||||||
|
t.Fatalf("first handleCancelIntent() error = %v", err)
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
stream.ProviderAccumulatedText = "late duplicate fragment"
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if err := service.handleCancelIntent(cancel); err != nil {
|
||||||
|
t.Fatalf("duplicate handleCancelIntent() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted, err := service.store.LoadConversation(stream.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
assistantEntries := 0
|
||||||
|
cancelEntries := 0
|
||||||
|
for _, entry := range persisted.Entries {
|
||||||
|
if entry.Kind == "metadata" {
|
||||||
|
var payload metadataPayload
|
||||||
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||||
|
t.Fatalf("decode metadata entry: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Type == "control" && readStringValue(payload.Value["status"]) == "canceled" {
|
||||||
|
cancelEntries++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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" {
|
||||||
|
assistantEntries++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if assistantEntries != 1 {
|
||||||
|
t.Fatalf("persisted interrupted assistant entries = %d, want 1", assistantEntries)
|
||||||
|
}
|
||||||
|
if cancelEntries != 1 {
|
||||||
|
t.Fatalf("persisted cancel metadata entries = %d, want 1", cancelEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
replay, err := service.projector.ProjectPromptReplay(persisted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, message := range replay {
|
||||||
|
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "partial answer" && strings.TrimSpace(message.ReasoningContent) == "partial reasoning" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("replay = %#v, want interrupted assistant output", replay)
|
||||||
|
}
|
||||||
|
checkpoint, err := service.projector.ProjectCheckpointProjection(persisted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if checkpoint == nil || checkpoint.State == nil || len(checkpoint.State.GetTurns()) != 1 {
|
||||||
|
t.Fatalf("checkpoint state = %#v, want interrupted turn", checkpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelPreservesPersistedTurnActivityWithoutLiveAccumulator(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.handleCancelIntent(InboundIntent{
|
||||||
|
Kind: "cancel",
|
||||||
|
RequestID: stream.RequestID,
|
||||||
|
CancelReason: "new_message_submitted",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleCancelIntent() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted, err := service.store.LoadConversation(stream.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
replay, err := service.projector.ProjectPromptReplay(persisted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, message := range replay {
|
||||||
|
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "hi" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("replay = %#v, want persisted assistant activity", replay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectPromptReplayPreservesLegacyCanceledTurnActivity(t *testing.T) {
|
||||||
|
cancelEntry := newMetadataEntry(1, "request-1", "control", map[string]any{
|
||||||
|
"status": "canceled",
|
||||||
|
"reason": "new_message_submitted",
|
||||||
|
"replay_policy": cancelReplayPolicyKeepStableInput,
|
||||||
|
})
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
newAssistantTextEntry(1, "request-1", "persisted activity", "", ""),
|
||||||
|
cancelEntry,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
replay, err := NewHistoryProjector().ProjectPromptReplay(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, message := range replay {
|
||||||
|
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "persisted activity" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("replay = %#v, want legacy canceled activity", replay)
|
||||||
|
}
|
||||||
@@ -355,6 +355,7 @@ const (
|
|||||||
cancelReplayPolicyDropTurn = "drop_turn"
|
cancelReplayPolicyDropTurn = "drop_turn"
|
||||||
cancelReplayPolicyDropUnstarted = "drop_unstarted_turn"
|
cancelReplayPolicyDropUnstarted = "drop_unstarted_turn"
|
||||||
cancelReplayPolicyKeepStableInput = "keep_stable_input"
|
cancelReplayPolicyKeepStableInput = "keep_stable_input"
|
||||||
|
cancelReplayPolicyKeepInterrupted = "keep_interrupted_output"
|
||||||
)
|
)
|
||||||
|
|
||||||
func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
|
func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
|
||||||
@@ -370,13 +371,19 @@ func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.TurnSeq > 0 {
|
if entry.TurnSeq > 0 {
|
||||||
if policy, canceled := canceledTurns[entry.TurnSeq]; canceled {
|
if policy, canceled := canceledTurns[entry.TurnSeq]; canceled {
|
||||||
if policy == cancelReplayPolicyDropUnstarted {
|
if policy == cancelReplayPolicyKeepInterrupted {
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if policy != cancelReplayPolicyDropTurn {
|
||||||
if _, active := activeCanceledTurns[entry.TurnSeq]; active {
|
if _, active := activeCanceledTurns[entry.TurnSeq]; active {
|
||||||
policy = cancelReplayPolicyKeepStableInput
|
filtered = append(filtered, entry)
|
||||||
} else {
|
continue
|
||||||
policy = cancelReplayPolicyDropTurn
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if policy == cancelReplayPolicyDropUnstarted {
|
||||||
|
policy = cancelReplayPolicyDropTurn
|
||||||
|
}
|
||||||
if policy == cancelReplayPolicyDropTurn || !isStableCanceledTurnInputEntry(entry) {
|
if policy == cancelReplayPolicyDropTurn || !isStableCanceledTurnInputEntry(entry) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -450,6 +457,8 @@ func normalizeCancelReplayPolicy(policy string, reason string) string {
|
|||||||
return cancelReplayPolicyDropUnstarted
|
return cancelReplayPolicyDropUnstarted
|
||||||
case cancelReplayPolicyKeepStableInput:
|
case cancelReplayPolicyKeepStableInput:
|
||||||
return cancelReplayPolicyKeepStableInput
|
return cancelReplayPolicyKeepStableInput
|
||||||
|
case cancelReplayPolicyKeepInterrupted:
|
||||||
|
return cancelReplayPolicyKeepInterrupted
|
||||||
default:
|
default:
|
||||||
return cancelReplayPolicyForReason(reason)
|
return cancelReplayPolicyForReason(reason)
|
||||||
}
|
}
|
||||||
@@ -616,9 +625,13 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
|
|||||||
turnIDs := make([][]byte, 0, len(order))
|
turnIDs := make([][]byte, 0, len(order))
|
||||||
for _, turnSeq := range order {
|
for _, turnSeq := range order {
|
||||||
entries := grouped[turnSeq]
|
entries := grouped[turnSeq]
|
||||||
|
completedToolCalls, err := collectCheckpointCompletedToolCalls(entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
var userMessageID []byte
|
var userMessageID []byte
|
||||||
var turnRequestID string
|
var turnRequestID string
|
||||||
stepIDs := make([][]byte, 0, len(entries))
|
steps := make([]*agentv1.ConversationStep, 0, len(entries))
|
||||||
seenToolCalls := make(map[string]struct{})
|
seenToolCalls := make(map[string]struct{})
|
||||||
openToolCalls := make(map[string]struct{})
|
openToolCalls := make(map[string]struct{})
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -645,59 +658,48 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||||
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
stepIDs = append(stepIDs, stepID)
|
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(payload.Text) == "" {
|
if strings.TrimSpace(payload.Text) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
Message: &agentv1.ConversationStep_AssistantMessage{
|
Message: &agentv1.ConversationStep_AssistantMessage{
|
||||||
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
|
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
stepIDs = append(stepIDs, stepID)
|
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
var payload toolCallEntryPayload
|
var payload toolCallEntryPayload
|
||||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||||
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
stepIDs = append(stepIDs, stepID)
|
|
||||||
}
|
}
|
||||||
toolCall := &agentv1.ToolCall{}
|
toolCall := &agentv1.ToolCall{}
|
||||||
|
toolCallID := strings.TrimSpace(payload.ToolCallID)
|
||||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
|
if completedPayload := completedToolCalls[toolCallID]; len(completedPayload) > 0 {
|
||||||
continue
|
completedToolCall := &agentv1.ToolCall{}
|
||||||
|
if err := protojson.Unmarshal(completedPayload, completedToolCall); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
proto.Merge(toolCall, completedToolCall)
|
||||||
}
|
}
|
||||||
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
|
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if toolCallID != "" {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
stepIDs = append(stepIDs, stepID)
|
|
||||||
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
|
|
||||||
seenToolCalls[toolCallID] = struct{}{}
|
seenToolCalls[toolCallID] = struct{}{}
|
||||||
openToolCalls[toolCallID] = struct{}{}
|
openToolCalls[toolCallID] = struct{}{}
|
||||||
}
|
}
|
||||||
@@ -706,22 +708,19 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
|
|||||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
|
toolCallID := strings.TrimSpace(payload.ToolCallID)
|
||||||
if _, ok := seenToolCalls[toolCallID]; ok {
|
if toolCallID != "" {
|
||||||
delete(openToolCalls, toolCallID)
|
delete(openToolCalls, toolCallID)
|
||||||
continue
|
}
|
||||||
}
|
if _, ok := seenToolCalls[toolCallID]; ok {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||||
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
stepIDs = append(stepIDs, stepID)
|
|
||||||
}
|
}
|
||||||
if len(payload.ToolCall) == 0 {
|
if len(payload.ToolCall) == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -730,21 +729,22 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
|
|||||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
continue
|
|
||||||
}
|
|
||||||
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
|
|
||||||
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
|
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
stepIDs = append(stepIDs, stepID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(userMessageID) == 0 && len(stepIDs) == 0 {
|
if len(userMessageID) == 0 && len(steps) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
stepIDs := make([][]byte, 0, len(steps))
|
||||||
|
for _, step := range steps {
|
||||||
|
stepID, err := addCheckpointStepBlob(blobs, step)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stepIDs = append(stepIDs, stepID)
|
||||||
|
}
|
||||||
agentTurn := &agentv1.AgentConversationTurnStructure{
|
agentTurn := &agentv1.AgentConversationTurnStructure{
|
||||||
UserMessage: userMessageID,
|
UserMessage: userMessageID,
|
||||||
Steps: stepIDs,
|
Steps: stepIDs,
|
||||||
@@ -765,6 +765,23 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
|
|||||||
return turnIDs, nil
|
return turnIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectCheckpointCompletedToolCalls(entries []HistoryEntry) (map[string]json.RawMessage, error) {
|
||||||
|
completed := make(map[string]json.RawMessage)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.TrimSpace(entry.Kind) != "tool_result" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var payload toolResultEntryPayload
|
||||||
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" && len(payload.ToolCall) > 0 {
|
||||||
|
completed[toolCallID] = payload.ToolCall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completed, nil
|
||||||
|
}
|
||||||
|
|
||||||
func addCheckpointStepBlob(blobs *checkpointBlobGraph, step *agentv1.ConversationStep) ([]byte, error) {
|
func addCheckpointStepBlob(blobs *checkpointBlobGraph, step *agentv1.ConversationStep) ([]byte, error) {
|
||||||
payload, err := proto.Marshal(step)
|
payload, err := proto.Marshal(step)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1209,7 +1226,7 @@ func trimReplayDanglingAssistantToolCalls(messages []modeladapter.Message) []mod
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldPersistToolResultName(toolName string) bool {
|
func shouldPersistCheckpointReplayToolResultName(toolName string) bool {
|
||||||
switch strings.TrimSpace(toolName) {
|
switch strings.TrimSpace(toolName) {
|
||||||
case "PatchEdit", "PatchEditLines", "PatchEditSpan", "Edit", "Write", "GenerateImage":
|
case "PatchEdit", "PatchEditLines", "PatchEditSpan", "Edit", "Write", "GenerateImage":
|
||||||
return true
|
return true
|
||||||
@@ -1218,60 +1235,6 @@ func shouldPersistToolResultName(toolName string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterCheckpointTurns(rawTurns [][]byte) [][]byte {
|
|
||||||
if len(rawTurns) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
filtered := make([][]byte, 0, len(rawTurns))
|
|
||||||
for _, rawTurn := range rawTurns {
|
|
||||||
if len(rawTurn) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
turn := &agentv1.ConversationTurnStructure{}
|
|
||||||
if err := proto.Unmarshal(rawTurn, turn); err != nil {
|
|
||||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
agentTurn := turn.GetAgentConversationTurn()
|
|
||||||
if agentTurn == nil {
|
|
||||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
nextSteps := make([][]byte, 0, len(agentTurn.GetSteps()))
|
|
||||||
for _, rawStep := range agentTurn.GetSteps() {
|
|
||||||
if len(rawStep) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
step := &agentv1.ConversationStep{}
|
|
||||||
if err := proto.Unmarshal(rawStep, step); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if toolCall := step.GetToolCall(); toolCall != nil && !shouldPersistToolResultName(inferToolName(toolCall)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nextSteps = append(nextSteps, append([]byte(nil), rawStep...))
|
|
||||||
}
|
|
||||||
if len(agentTurn.GetUserMessage()) == 0 && len(nextSteps) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
encoded, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
|
||||||
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
|
||||||
AgentConversationTurn: &agentv1.AgentConversationTurnStructure{
|
|
||||||
UserMessage: append([]byte(nil), agentTurn.GetUserMessage()...),
|
|
||||||
Steps: nextSteps,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, encoded)
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []promptengine.Message {
|
func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []promptengine.Message {
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -1282,7 +1245,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
|
|||||||
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
|
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
|
||||||
nextToolCalls := make([]promptengine.ToolCallDescriptor, 0, len(message.ToolCalls))
|
nextToolCalls := make([]promptengine.ToolCallDescriptor, 0, len(message.ToolCalls))
|
||||||
for _, toolCall := range message.ToolCalls {
|
for _, toolCall := range message.ToolCalls {
|
||||||
if !shouldPersistToolResultName(toolCall.Function.Name) {
|
if !shouldPersistCheckpointReplayToolResultName(toolCall.Function.Name) {
|
||||||
skippedToolCallIDs[strings.TrimSpace(toolCall.ID)] = struct{}{}
|
skippedToolCallIDs[strings.TrimSpace(toolCall.ID)] = struct{}{}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1299,7 +1262,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
|
|||||||
if _, ok := skippedToolCallIDs[strings.TrimSpace(message.ToolCallID)]; ok {
|
if _, ok := skippedToolCallIDs[strings.TrimSpace(message.ToolCallID)]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !shouldPersistToolResultName(message.Name) {
|
if !shouldPersistCheckpointReplayToolResultName(message.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package forwarder
|
package forwarder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ import (
|
|||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
"cursor/gen/agentv1"
|
"cursor/gen/agentv1"
|
||||||
|
promptengine "cursor/internal/backend/agent/prompt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProjectCheckpointProjectionBuildsResolvableForkState(t *testing.T) {
|
func TestProjectCheckpointProjectionBuildsResolvableForkState(t *testing.T) {
|
||||||
@@ -138,3 +141,240 @@ func TestProjectCheckpointProjectionKeepsForkPointIsolatedFromLaterHistory(t *te
|
|||||||
t.Fatalf("fork snapshots are not isolated: midpoint=%#v latest=%#v", midpointMessages, latestMessages)
|
t.Fatalf("fork snapshots are not isolated: midpoint=%#v latest=%#v", midpointMessages, latestMessages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionMergesToolCallWithCompletedResult(t *testing.T) {
|
||||||
|
userPayload, err := protojson.Marshal(&agentv1.UserMessage{Text: "inspect file", MessageId: "message-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal user message: %v", err)
|
||||||
|
}
|
||||||
|
startedAt := uint64(100)
|
||||||
|
toolCallID := "call-1"
|
||||||
|
startedToolCall := checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||||
|
ToolCallId: &toolCallID,
|
||||||
|
StartedAtMs: &startedAt,
|
||||||
|
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||||
|
ReadToolCall: &agentv1.ReadToolCall{
|
||||||
|
Args: &agentv1.ReadToolArgs{Path: "/tmp/example.txt"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
completedAt := uint64(200)
|
||||||
|
completedToolCall := checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||||
|
CompletedAtMs: &completedAt,
|
||||||
|
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||||
|
ReadToolCall: &agentv1.ReadToolCall{
|
||||||
|
Result: &agentv1.ReadToolResult{
|
||||||
|
Result: &agentv1.ReadToolResult_Success{
|
||||||
|
Success: &agentv1.ReadToolSuccess{
|
||||||
|
Path: "/tmp/example.txt",
|
||||||
|
TotalLines: 1,
|
||||||
|
Output: &agentv1.ReadToolSuccess_Content{Content: "file contents"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||||
|
newAssistantTextEntry(1, "request-1", "before", "", ""),
|
||||||
|
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall),
|
||||||
|
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "file contents", "", completedToolCall),
|
||||||
|
newAssistantTextEntry(1, "request-1", "after", "", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(projection.Blobs) != 5 {
|
||||||
|
t.Fatalf("checkpoint blobs = %d, want user, three final steps, and turn", len(projection.Blobs))
|
||||||
|
}
|
||||||
|
steps := checkpointProjectionSteps(t, projection)
|
||||||
|
if len(steps) != 3 {
|
||||||
|
t.Fatalf("checkpoint steps = %d, want assistant, completed Read, assistant", len(steps))
|
||||||
|
}
|
||||||
|
if steps[0].GetAssistantMessage().GetText() != "before" || steps[2].GetAssistantMessage().GetText() != "after" {
|
||||||
|
t.Fatalf("checkpoint step ordering changed: %#v", steps)
|
||||||
|
}
|
||||||
|
mergedToolCall := steps[1].GetToolCall()
|
||||||
|
readCall := mergedToolCall.GetReadToolCall()
|
||||||
|
if readCall == nil || readCall.GetResult().GetSuccess().GetContent() != "file contents" {
|
||||||
|
t.Fatalf("checkpoint Read step does not contain completed result: %#v", steps[1].GetToolCall())
|
||||||
|
}
|
||||||
|
if readCall.GetArgs().GetPath() != "/tmp/example.txt" || mergedToolCall.GetToolCallId() != toolCallID || mergedToolCall.GetStartedAtMs() != startedAt || mergedToolCall.GetCompletedAtMs() != completedAt {
|
||||||
|
t.Fatalf("checkpoint Read step lost started-call fields: %#v", mergedToolCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
replay, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode root prompt replay: %v", err)
|
||||||
|
}
|
||||||
|
for _, message := range replay {
|
||||||
|
if message.Name == "Read" || len(message.ToolCalls) > 0 {
|
||||||
|
t.Fatalf("UI-only Read result leaked into root prompt replay: %#v", replay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionIsIdempotentAndDoesNotMutateHistory(t *testing.T) {
|
||||||
|
userPayload, err := protojson.Marshal(&agentv1.UserMessage{Text: "inspect file", MessageId: "message-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal user message: %v", err)
|
||||||
|
}
|
||||||
|
completedToolCall := checkpointTestReadToolCall(t, &agentv1.ReadToolResult{
|
||||||
|
Result: &agentv1.ReadToolResult_Success{
|
||||||
|
Success: &agentv1.ReadToolSuccess{
|
||||||
|
Path: "/tmp/example.txt",
|
||||||
|
TotalLines: 1,
|
||||||
|
Output: &agentv1.ReadToolSuccess_Content{Content: "file contents"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||||
|
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", checkpointTestReadToolCall(t, nil)),
|
||||||
|
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "file contents", "", completedToolCall),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
before, err := json.Marshal(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal conversation before projection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
projector := NewHistoryProjector()
|
||||||
|
first, err := projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first projection: %v", err)
|
||||||
|
}
|
||||||
|
second, err := projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second projection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !proto.Equal(first.State, second.State) {
|
||||||
|
t.Fatalf("repeated projection changed checkpoint state: first=%#v second=%#v", first.State, second.State)
|
||||||
|
}
|
||||||
|
if len(first.Blobs) != len(second.Blobs) {
|
||||||
|
t.Fatalf("repeated projection changed blob count: first=%d second=%d", len(first.Blobs), len(second.Blobs))
|
||||||
|
}
|
||||||
|
for index := range first.Blobs {
|
||||||
|
if !bytes.Equal(first.Blobs[index].ID, second.Blobs[index].ID) || !bytes.Equal(first.Blobs[index].Data, second.Blobs[index].Data) {
|
||||||
|
t.Fatalf("repeated projection changed blob %d", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
after, err := json.Marshal(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal conversation after projection: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(before, after) {
|
||||||
|
t.Fatalf("checkpoint projection mutated semantic history:\nbefore=%s\nafter=%s", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionKeepsStartedToolCallWhenResultPayloadIsMissing(t *testing.T) {
|
||||||
|
startedToolCall := checkpointTestReadToolCall(t, nil)
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall),
|
||||||
|
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "read failed", "", nil),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
steps := checkpointProjectionSteps(t, projection)
|
||||||
|
if len(steps) != 1 {
|
||||||
|
t.Fatalf("checkpoint steps = %d, want the original Read step", len(steps))
|
||||||
|
}
|
||||||
|
readCall := steps[0].GetToolCall().GetReadToolCall()
|
||||||
|
if readCall == nil || readCall.GetArgs().GetPath() != "/tmp/example.txt" || readCall.GetResult() != nil {
|
||||||
|
t.Fatalf("checkpoint did not preserve the original Read call: %#v", steps[0].GetToolCall())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionAppendsLegacyResultWithoutToolCallEntry(t *testing.T) {
|
||||||
|
completedToolCall := checkpointTestReadToolCall(t, &agentv1.ReadToolResult{
|
||||||
|
Result: &agentv1.ReadToolResult_Error{Error: &agentv1.ReadToolError{ErrorMessage: "not readable"}},
|
||||||
|
})
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "not readable", "", completedToolCall),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
steps := checkpointProjectionSteps(t, projection)
|
||||||
|
if len(steps) != 1 || steps[0].GetToolCall().GetReadToolCall().GetResult().GetError().GetErrorMessage() != "not readable" {
|
||||||
|
t.Fatalf("legacy result-only Read step was not preserved: %#v", steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointTestReadToolCall(t *testing.T, result *agentv1.ReadToolResult) []byte {
|
||||||
|
t.Helper()
|
||||||
|
return checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||||
|
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||||
|
ReadToolCall: &agentv1.ReadToolCall{
|
||||||
|
Args: &agentv1.ReadToolArgs{Path: "/tmp/example.txt"},
|
||||||
|
Result: result,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointTestToolCallPayload(t *testing.T, toolCall *agentv1.ToolCall) []byte {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := protojson.Marshal(toolCall)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal Read tool call: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointProjectionSteps(t *testing.T, projection *CheckpointProjection) []*agentv1.ConversationStep {
|
||||||
|
t.Helper()
|
||||||
|
if projection == nil || projection.State == nil || len(projection.State.GetTurns()) != 1 {
|
||||||
|
t.Fatalf("checkpoint turns = %#v, want exactly one turn", projection)
|
||||||
|
}
|
||||||
|
blobs := make(map[string][]byte, len(projection.Blobs))
|
||||||
|
for _, blob := range projection.Blobs {
|
||||||
|
blobs[string(blob.ID)] = blob.Data
|
||||||
|
}
|
||||||
|
turn := &agentv1.ConversationTurnStructure{}
|
||||||
|
if err := proto.Unmarshal(blobs[string(projection.State.GetTurns()[0])], turn); err != nil {
|
||||||
|
t.Fatalf("decode checkpoint turn: %v", err)
|
||||||
|
}
|
||||||
|
agentTurn := turn.GetAgentConversationTurn()
|
||||||
|
if agentTurn == nil {
|
||||||
|
t.Fatal("checkpoint turn does not contain an agent turn")
|
||||||
|
}
|
||||||
|
steps := make([]*agentv1.ConversationStep, 0, len(agentTurn.GetSteps()))
|
||||||
|
for _, stepID := range agentTurn.GetSteps() {
|
||||||
|
step := &agentv1.ConversationStep{}
|
||||||
|
if err := proto.Unmarshal(blobs[string(stepID)], step); err != nil {
|
||||||
|
t.Fatalf("decode checkpoint step: %v", err)
|
||||||
|
}
|
||||||
|
steps = append(steps, step)
|
||||||
|
}
|
||||||
|
return steps
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package forwarder
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -834,14 +836,22 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
|||||||
}
|
}
|
||||||
hasCheckpoint := checkpointConversationInitialized(stream)
|
hasCheckpoint := checkpointConversationInitialized(stream)
|
||||||
if hasCheckpoint {
|
if hasCheckpoint {
|
||||||
|
preservedInterruptedOutput, err := service.persistInterruptedProviderOutput(stream)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
cancelReason := firstNonEmpty(intent.CancelReason, "user aborted")
|
cancelReason := firstNonEmpty(intent.CancelReason, "user aborted")
|
||||||
_, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{
|
replayPolicy := cancelReplayPolicyForReason(cancelReason)
|
||||||
newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
|
if preservedInterruptedOutput || checkpointTurnHasReplayActivity(stream) {
|
||||||
"status": "canceled",
|
replayPolicy = cancelReplayPolicyKeepInterrupted
|
||||||
"reason": cancelReason,
|
}
|
||||||
"replay_policy": cancelReplayPolicyForReason(cancelReason),
|
cancelEntry := newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
|
||||||
}),
|
"status": "canceled",
|
||||||
|
"reason": cancelReason,
|
||||||
|
"replay_policy": replayPolicy,
|
||||||
})
|
})
|
||||||
|
cancelEntry.IdempotencyKey = cancelMetadataIdempotencyKey(stream.TurnSeq, intent.RequestID)
|
||||||
|
_, err = service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{cancelEntry})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -869,6 +879,89 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
|||||||
return service.broker.Cancel(intent.RequestID, firstNonEmpty(intent.CancelReason, "[canceled] User aborted request"))
|
return service.broker.Cancel(intent.RequestID, firstNonEmpty(intent.CancelReason, "[canceled] User aborted request"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkpointTurnHasReplayActivity(stream *ActiveStream) bool {
|
||||||
|
if stream == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
defer stream.mu.Unlock()
|
||||||
|
if stream.CheckpointConversation == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, entry := range stream.CheckpointConversation.Entries {
|
||||||
|
if entry.TurnSeq == stream.TurnSeq && isCanceledTurnActivityEntry(entry) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistInterruptedProviderOutput commits the current provider pass before cancellation.
|
||||||
|
// The entry key is stable for this provider pass, so repeated cancellation handling is a no-op.
|
||||||
|
func (service *Service) persistInterruptedProviderOutput(stream *ActiveStream) (bool, error) {
|
||||||
|
if stream == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
turnSeq := stream.TurnSeq
|
||||||
|
requestID := strings.TrimSpace(stream.RequestID)
|
||||||
|
modelCallID := strings.TrimSpace(stream.CurrentModelCallID)
|
||||||
|
providerPass := stream.ProviderPassCount
|
||||||
|
text := stream.ProviderAccumulatedText
|
||||||
|
reasoning := stream.ProviderAccumulatedReasoning
|
||||||
|
reasoningSignature := stream.ProviderAccumulatedReasoningSignature
|
||||||
|
reasoningSignatureSource := stream.ProviderAccumulatedReasoningSignatureSource
|
||||||
|
reasoningItemID := stream.ProviderAccumulatedReasoningItemID
|
||||||
|
reasoningStatus := stream.ProviderAccumulatedReasoningStatus
|
||||||
|
reasoningSummary := append([]byte(nil), stream.ProviderAccumulatedReasoningSummary...)
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if strings.TrimSpace(text) == "" && !hasReplayableReasoningPayload(reasoning, reasoningSignature, reasoningSignatureSource) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
key := interruptedProviderOutputIdempotencyKey(turnSeq, requestID, modelCallID, providerPass)
|
||||||
|
_, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{
|
||||||
|
{
|
||||||
|
TurnSeq: turnSeq,
|
||||||
|
RequestID: requestID,
|
||||||
|
IdempotencyKey: key,
|
||||||
|
Role: "assistant",
|
||||||
|
Kind: "assistant_text",
|
||||||
|
Payload: newAssistantTextPayload(
|
||||||
|
text,
|
||||||
|
reasoning,
|
||||||
|
reasoningSignature,
|
||||||
|
reasoningSignatureSource,
|
||||||
|
reasoningItemID,
|
||||||
|
reasoningStatus,
|
||||||
|
reasoningSummary,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func interruptedProviderOutputIdempotencyKey(turnSeq int64, requestID string, modelCallID string, providerPass int) string {
|
||||||
|
payload := strings.Join([]string{
|
||||||
|
"provider_interrupted_output",
|
||||||
|
fmt.Sprintf("%d", turnSeq),
|
||||||
|
strings.TrimSpace(requestID),
|
||||||
|
strings.TrimSpace(modelCallID),
|
||||||
|
fmt.Sprintf("%d", providerPass),
|
||||||
|
}, "\x00")
|
||||||
|
digest := sha256.Sum256([]byte(payload))
|
||||||
|
return "provider-interrupted-output:" + hex.EncodeToString(digest[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelMetadataIdempotencyKey(turnSeq int64, requestID string) string {
|
||||||
|
payload := strings.Join([]string{
|
||||||
|
"cancel",
|
||||||
|
fmt.Sprintf("%d", turnSeq),
|
||||||
|
strings.TrimSpace(requestID),
|
||||||
|
}, "\x00")
|
||||||
|
digest := sha256.Sum256([]byte(payload))
|
||||||
|
return "cancel:" + hex.EncodeToString(digest[:])
|
||||||
|
}
|
||||||
|
|
||||||
// handleExecResult 处理客户端返回的执行桥结果,并在终态时把 tool_result 写回 history。
|
// handleExecResult 处理客户端返回的执行桥结果,并在终态时把 tool_result 写回 history。
|
||||||
func (service *Service) handleExecResult(intent InboundIntent) error {
|
func (service *Service) handleExecResult(intent InboundIntent) error {
|
||||||
stream, ok := service.broker.Get(intent.RequestID)
|
stream, ok := service.broker.Get(intent.RequestID)
|
||||||
@@ -2431,6 +2524,16 @@ func newAssistantTextEntry(turnSeq int64, requestID string, text string, reasoni
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newAssistantTextEntryWithProviderMetadata(turnSeq int64, requestID string, text string, reasoningContent string, reasoningSignature string, reasoningSignatureSource string, reasoningItemID string, reasoningStatus string, reasoningSummary json.RawMessage) HistoryEntry {
|
func newAssistantTextEntryWithProviderMetadata(turnSeq int64, requestID string, text string, reasoningContent string, reasoningSignature string, reasoningSignatureSource string, reasoningItemID string, reasoningStatus string, reasoningSummary json.RawMessage) HistoryEntry {
|
||||||
|
return HistoryEntry{
|
||||||
|
TurnSeq: turnSeq,
|
||||||
|
RequestID: strings.TrimSpace(requestID),
|
||||||
|
Role: "assistant",
|
||||||
|
Kind: "assistant_text",
|
||||||
|
Payload: newAssistantTextPayload(text, reasoningContent, reasoningSignature, reasoningSignatureSource, reasoningItemID, reasoningStatus, reasoningSummary),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAssistantTextPayload(text string, reasoningContent string, reasoningSignature string, reasoningSignatureSource string, reasoningItemID string, reasoningStatus string, reasoningSummary json.RawMessage) json.RawMessage {
|
||||||
payload, _ := json.Marshal(assistantTextPayload{
|
payload, _ := json.Marshal(assistantTextPayload{
|
||||||
Text: text,
|
Text: text,
|
||||||
ReasoningContent: reasoningContent,
|
ReasoningContent: reasoningContent,
|
||||||
@@ -2440,13 +2543,7 @@ func newAssistantTextEntryWithProviderMetadata(turnSeq int64, requestID string,
|
|||||||
ReasoningStatus: strings.TrimSpace(reasoningStatus),
|
ReasoningStatus: strings.TrimSpace(reasoningStatus),
|
||||||
ReasoningSummary: append(json.RawMessage(nil), reasoningSummary...),
|
ReasoningSummary: append(json.RawMessage(nil), reasoningSummary...),
|
||||||
})
|
})
|
||||||
return HistoryEntry{
|
return payload
|
||||||
TurnSeq: turnSeq,
|
|
||||||
RequestID: strings.TrimSpace(requestID),
|
|
||||||
Role: "assistant",
|
|
||||||
Kind: "assistant_text",
|
|
||||||
Payload: payload,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newToolCallEntry 构造 tool_call entry。
|
// newToolCallEntry 构造 tool_call entry。
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ type HistoryEntry struct {
|
|||||||
Seq int64 `json:"seq"`
|
Seq int64 `json:"seq"`
|
||||||
TurnSeq int64 `json:"turn_seq"`
|
TurnSeq int64 `json:"turn_seq"`
|
||||||
RequestID string `json:"request_id,omitempty"`
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
|||||||
+2500
-739
File diff suppressed because it is too large
Load Diff
+10930
-4348
File diff suppressed because it is too large
Load Diff
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cursor-proto-sync.XXXXXX")"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TEMP_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
for PROTO_NAME in agent_v1.proto aiserver_v1.proto; do
|
||||||
|
ROOT_PROTO="$SCRIPT_DIR/$PROTO_NAME"
|
||||||
|
EXTRACTED_PROTO="$SCRIPT_DIR/from_extensions/$PROTO_NAME"
|
||||||
|
if [[ ! -f "$ROOT_PROTO" || ! -f "$EXTRACTED_PROTO" ]]; then
|
||||||
|
echo "Missing proto pair for $PROTO_NAME" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sed -E 's|^option go_package = ".*";$|option go_package = "__NORMALIZED__";|' "$ROOT_PROTO" > "$TEMP_DIR/root-$PROTO_NAME"
|
||||||
|
sed -E 's|^option go_package = ".*";$|option go_package = "__NORMALIZED__";|' "$EXTRACTED_PROTO" > "$TEMP_DIR/extracted-$PROTO_NAME"
|
||||||
|
if ! cmp -s "$TEMP_DIR/root-$PROTO_NAME" "$TEMP_DIR/extracted-$PROTO_NAME"; then
|
||||||
|
echo "Proto snapshot is out of sync: $PROTO_NAME" >&2
|
||||||
|
diff -u "$TEMP_DIR/root-$PROTO_NAME" "$TEMP_DIR/extracted-$PROTO_NAME" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
+226
-60
@@ -101,24 +101,28 @@ func SetStrictMode(enabled bool) {
|
|||||||
var activeDiagnostics *extractionDiagnostics
|
var activeDiagnostics *extractionDiagnostics
|
||||||
|
|
||||||
var (
|
var (
|
||||||
noRe = regexp.MustCompile(`(?:^|[,{]\s*)no:\s*(\d+)`)
|
noRe = regexp.MustCompile(`(?:^|[,{]\s*)no:\s*(\d+)`)
|
||||||
nameRe = regexp.MustCompile(`(?:^|[,{]\s*)name:\s*["']([^"']+)["']`)
|
nameRe = regexp.MustCompile(`(?:^|[,{]\s*)name:\s*["']([^"']+)["']`)
|
||||||
kindRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["']([^"']+)["']`)
|
kindRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["']([^"']+)["']`)
|
||||||
enumTypeRe = regexp.MustCompile(`[,\s]T:\s*[\w$.]+\.getEnumType\s*\(\s*([\w$.]+)\s*\)`)
|
enumTypeRe = regexp.MustCompile(`[,\s]T:\s*[\w$.]+\.getEnumType\s*\(\s*([\w$.]+)\s*\)`)
|
||||||
tRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
|
tRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
|
||||||
oneofRe = regexp.MustCompile(`oneof:\s*["']([^"']+)["']`)
|
oneofRe = regexp.MustCompile(`oneof:\s*["']([^"']+)["']`)
|
||||||
repeatedRe = regexp.MustCompile(`repeated:\s*(!0|true)`)
|
repeatedRe = regexp.MustCompile(`repeated:\s*(!0|true)`)
|
||||||
optRe = regexp.MustCompile(`opt:\s*(!0|true)`)
|
optRe = regexp.MustCompile(`opt:\s*(!0|true)`)
|
||||||
keyRe = regexp.MustCompile(`[,\s]K:\s*(\d+)`)
|
keyRe = regexp.MustCompile(`[,\s]K:\s*(\d+)`)
|
||||||
mapValueRe = regexp.MustCompile(`V:\s*\{([^}]*)\}`)
|
mapValueRe = regexp.MustCompile(`V:\s*\{([^}]*)\}`)
|
||||||
mapValueKRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["'](\w+)["']`)
|
mapValueKRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["'](\w+)["']`)
|
||||||
mapValueTRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
|
mapValueTRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
|
||||||
oneofNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
shorthandTRe = regexp.MustCompile(`(?:^|[,\{])\s*T\s*(?:[,\}])`)
|
||||||
fieldNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
oneofNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
placeholderRe = regexp.MustCompile(`^\s*(optional\s+|repeated\s+)?[A-Za-z_][A-Za-z0-9_.<>]*\s+(field_\d+|unknown(?:_[A-Za-z0-9_]+)?)\s*=\s*\d+\s*;`)
|
fieldNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
varAliasRe = regexp.MustCompile(`\b(?:let|const|var)\s+([\w$]+)\s*=\s*([\w$]+)\s*(?:[,;])`)
|
placeholderRe = regexp.MustCompile(`^\s*(optional\s+|repeated\s+)?[A-Za-z_][A-Za-z0-9_.<>]*\s+(field_\d+|unknown(?:_[A-Za-z0-9_]+)?)\s*=\s*\d+\s*;`)
|
||||||
streamCloseRe = regexp.MustCompile(`(?s)message\s+ExecClientControlMessage\s*\{.*?ExecClientStreamClose\s+stream_close\s*=\s*1\s*;`)
|
varAliasRe = regexp.MustCompile(`\b(?:let|const|var)\s+([\w$]+)\s*=\s*([\w$]+)\s*(?:[,;])`)
|
||||||
shellStdoutRe = regexp.MustCompile(`(?s)message\s+ShellStream\s*\{.*?ShellStreamStdout\s+stdout\s*=\s*1\s*;`)
|
webpackExportBlockRe = regexp.MustCompile(`[\w$]+\.d\(\s*[\w$]+\s*,\s*\{`)
|
||||||
|
webpackExportEntryRe = regexp.MustCompile(`(?:^|[,\{])\s*([\w$]+)\s*:\s*\(\s*\)\s*=>\s*([\w$]+)`)
|
||||||
|
moduleImportRe = regexp.MustCompile(`(?:\b(?:var|let|const)\s+|,)\s*([\w$]+)\s*=\s*[\w$]+\(\s*(\d+)\s*\)`)
|
||||||
|
streamCloseRe = regexp.MustCompile(`(?s)message\s+ExecClientControlMessage\s*\{.*?ExecClientStreamClose\s+stream_close\s*=\s*1\s*;`)
|
||||||
|
shellStdoutRe = regexp.MustCompile(`(?s)message\s+ShellStream\s*\{.*?ShellStreamStdout\s+stdout\s*=\s*1\s*;`)
|
||||||
)
|
)
|
||||||
|
|
||||||
type Field struct {
|
type Field struct {
|
||||||
@@ -185,13 +189,18 @@ type symbolDef struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TypeResolver struct {
|
type TypeResolver struct {
|
||||||
bySymbol map[string][]symbolDef
|
bySymbol map[string][]symbolDef
|
||||||
byShort map[string][]symbolDef
|
byAlias map[string][]symbolDef
|
||||||
|
byShort map[string][]symbolDef
|
||||||
|
moduleImports map[int]map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTypeResolver(messages []Message, enums []Enum, aliases map[string][]string) *TypeResolver {
|
type aliasIndex map[int]map[string][]string
|
||||||
|
|
||||||
|
func newTypeResolver(messages []Message, enums []Enum, aliases aliasIndex, exportAliases aliasIndex) *TypeResolver {
|
||||||
resolver := &TypeResolver{
|
resolver := &TypeResolver{
|
||||||
bySymbol: make(map[string][]symbolDef),
|
bySymbol: make(map[string][]symbolDef),
|
||||||
|
byAlias: make(map[string][]symbolDef),
|
||||||
byShort: make(map[string][]symbolDef),
|
byShort: make(map[string][]symbolDef),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,43 +227,64 @@ func newTypeResolver(messages []Message, enums []Enum, aliases map[string][]stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
addAlias := func(symbol, typeName string, pos int, moduleStart int, kind string) {
|
||||||
|
symbol = strings.TrimSpace(symbol)
|
||||||
|
typeName = strings.TrimSpace(typeName)
|
||||||
|
if symbol == "" || typeName == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolver.byAlias[symbol] = append(resolver.byAlias[symbol], symbolDef{
|
||||||
|
TypeName: typeName, Pos: pos, ModuleStart: moduleStart, Kind: kind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
add(msg.VarName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
add(msg.VarName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
||||||
if msg.InternalName != "" && msg.InternalName != msg.VarName {
|
if msg.InternalName != "" && msg.InternalName != msg.VarName {
|
||||||
add(msg.InternalName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
add(msg.InternalName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
||||||
}
|
}
|
||||||
for _, alias := range aliasesForSymbols(aliases, msg.VarName, msg.InternalName) {
|
for _, alias := range aliasesForSymbols(aliases[msg.ModuleStart], msg.VarName, msg.InternalName) {
|
||||||
add(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
addAlias(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, enum := range enums {
|
for _, enum := range enums {
|
||||||
add(enum.VarName, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
|
add(enum.VarName, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
|
||||||
for _, alias := range aliasesForSymbols(aliases, enum.VarName) {
|
for _, alias := range aliasesForSymbols(aliases[enum.ModuleStart], enum.VarName) {
|
||||||
add(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
|
addAlias(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
for _, alias := range aliasesForSymbols(exportAliases[msg.ModuleStart], msg.VarName, msg.InternalName) {
|
||||||
|
addAlias(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, enum := range enums {
|
||||||
|
for _, alias := range aliasesForSymbols(exportAliases[enum.ModuleStart], enum.VarName) {
|
||||||
|
addAlias(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolver
|
return resolver
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAliasIndex(text string) map[string][]string {
|
func buildAliasIndex(text string, moduleStarts []int) aliasIndex {
|
||||||
matches := varAliasRe.FindAllStringSubmatch(text, -1)
|
matches := varAliasRe.FindAllStringSubmatchIndex(text, -1)
|
||||||
if len(matches) == 0 {
|
directByModule := make(map[int]map[string]string)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
direct := make(map[string]string, len(matches))
|
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
alias := strings.TrimSpace(match[1])
|
alias := strings.TrimSpace(text[match[2]:match[3]])
|
||||||
target := strings.TrimSpace(match[2])
|
target := strings.TrimSpace(text[match[4]:match[5]])
|
||||||
if alias == "" || target == "" || alias == target {
|
if alias == "" || target == "" || alias == target {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
direct[alias] = target
|
moduleStart := moduleStartForPos(moduleStarts, match[0])
|
||||||
|
if directByModule[moduleStart] == nil {
|
||||||
|
directByModule[moduleStart] = make(map[string]string)
|
||||||
|
}
|
||||||
|
directByModule[moduleStart][alias] = target
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveRoot := func(symbol string) string {
|
resolveRoot := func(direct map[string]string, symbol string) string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
current := symbol
|
current := symbol
|
||||||
for {
|
for {
|
||||||
@@ -270,16 +300,90 @@ func buildAliasIndex(text string) map[string][]string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
aliases := make(map[string][]string)
|
aliasSets := make(map[int]map[string]map[string]bool)
|
||||||
for alias := range direct {
|
addAlias := func(moduleStart int, root string, alias string) {
|
||||||
root := resolveRoot(alias)
|
root = strings.TrimSpace(root)
|
||||||
if root == alias {
|
alias = strings.TrimSpace(alias)
|
||||||
|
if root == "" || alias == "" || root == alias {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if aliasSets[moduleStart] == nil {
|
||||||
|
aliasSets[moduleStart] = make(map[string]map[string]bool)
|
||||||
|
}
|
||||||
|
if aliasSets[moduleStart][root] == nil {
|
||||||
|
aliasSets[moduleStart][root] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
aliasSets[moduleStart][root][alias] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for moduleStart, direct := range directByModule {
|
||||||
|
for alias := range direct {
|
||||||
|
root := resolveRoot(direct, alias)
|
||||||
|
addAlias(moduleStart, root, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(aliasSets) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
aliases := make(aliasIndex, len(aliasSets))
|
||||||
|
for moduleStart, roots := range aliasSets {
|
||||||
|
aliases[moduleStart] = make(map[string][]string, len(roots))
|
||||||
|
for root, set := range roots {
|
||||||
|
for alias := range set {
|
||||||
|
aliases[moduleStart][root] = append(aliases[moduleStart][root], alias)
|
||||||
|
}
|
||||||
|
sort.Strings(aliases[moduleStart][root])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWebpackExportAliasIndex(text string, moduleStarts []int) aliasIndex {
|
||||||
|
aliasSets := make(map[int]map[string]map[string]bool)
|
||||||
|
addAlias := func(moduleStart int, root string, alias string) {
|
||||||
|
root = strings.TrimSpace(root)
|
||||||
|
alias = strings.TrimSpace(alias)
|
||||||
|
if root == "" || alias == "" || root == alias {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if aliasSets[moduleStart] == nil {
|
||||||
|
aliasSets[moduleStart] = make(map[string]map[string]bool)
|
||||||
|
}
|
||||||
|
if aliasSets[moduleStart][root] == nil {
|
||||||
|
aliasSets[moduleStart][root] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
aliasSets[moduleStart][root][alias] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webpack exposes module members through tables such as
|
||||||
|
// n.d(t, { KS: () => T }). Service descriptors refer to the exported
|
||||||
|
// name (r.KS), while message definitions use the local symbol (T).
|
||||||
|
for _, blockMatch := range webpackExportBlockRe.FindAllStringIndex(text, -1) {
|
||||||
|
moduleStart := moduleStartForPos(moduleStarts, blockMatch[0])
|
||||||
|
blockStart := blockMatch[1] - 1
|
||||||
|
blockEnd := findMatchingBrace(text, blockStart)
|
||||||
|
if blockEnd == -1 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
aliases[root] = append(aliases[root], alias)
|
block := text[blockStart:blockEnd]
|
||||||
|
for _, entry := range webpackExportEntryRe.FindAllStringSubmatch(block, -1) {
|
||||||
|
addAlias(moduleStart, entry[2], entry[1])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for root := range aliases {
|
|
||||||
sort.Strings(aliases[root])
|
if len(aliasSets) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
aliases := make(aliasIndex, len(aliasSets))
|
||||||
|
for moduleStart, roots := range aliasSets {
|
||||||
|
aliases[moduleStart] = make(map[string][]string, len(roots))
|
||||||
|
for root, set := range roots {
|
||||||
|
for alias := range set {
|
||||||
|
aliases[moduleStart][root] = append(aliases[moduleStart][root], alias)
|
||||||
|
}
|
||||||
|
sort.Strings(aliases[moduleStart][root])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return aliases
|
return aliases
|
||||||
}
|
}
|
||||||
@@ -318,11 +422,10 @@ func pickBestDefinition(candidates []symbolDef, contextPos int, contextModuleSta
|
|||||||
}
|
}
|
||||||
|
|
||||||
filtered := candidates
|
filtered := candidates
|
||||||
if strings.TrimSpace(preferredPkg) != "" {
|
if strings.TrimSpace(expectedKind) != "" {
|
||||||
tmp := make([]symbolDef, 0, len(candidates))
|
tmp := make([]symbolDef, 0, len(candidates))
|
||||||
for _, item := range candidates {
|
for _, item := range candidates {
|
||||||
pkg, _ := parseTypeName(item.TypeName)
|
if item.Kind == expectedKind {
|
||||||
if pkg == preferredPkg {
|
|
||||||
tmp = append(tmp, item)
|
tmp = append(tmp, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,10 +434,11 @@ func pickBestDefinition(candidates []symbolDef, contextPos int, contextModuleSta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(expectedKind) != "" {
|
if strings.TrimSpace(preferredPkg) != "" {
|
||||||
tmp := make([]symbolDef, 0, len(filtered))
|
tmp := make([]symbolDef, 0, len(filtered))
|
||||||
for _, item := range filtered {
|
for _, item := range filtered {
|
||||||
if item.Kind == expectedKind {
|
pkg, _ := parseTypeName(item.TypeName)
|
||||||
|
if pkg == preferredPkg {
|
||||||
tmp = append(tmp, item)
|
tmp = append(tmp, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,6 +520,17 @@ func (resolver *TypeResolver) ResolveTypeName(ref string, contextPos int, contex
|
|||||||
}
|
}
|
||||||
return best.TypeName, true
|
return best.TypeName, true
|
||||||
}
|
}
|
||||||
|
resolveByAlias := func(symbol string, targetModuleStart int) (string, bool) {
|
||||||
|
candidates := resolver.byAlias[symbol]
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
best, ok := pickBestDefinition(candidates, contextPos, targetModuleStart, preferredPkg, expectedKind)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return best.TypeName, true
|
||||||
|
}
|
||||||
resolveByShort := func(symbol string, preferSameModule bool) (string, bool) {
|
resolveByShort := func(symbol string, preferSameModule bool) (string, bool) {
|
||||||
candidates := resolver.byShort[symbol]
|
candidates := resolver.byShort[symbol]
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
@@ -435,17 +550,30 @@ func (resolver *TypeResolver) ResolveTypeName(ref string, contextPos int, contex
|
|||||||
if typeName, ok := resolveBySymbol(trimmed, !strings.Contains(trimmed, ".")); ok {
|
if typeName, ok := resolveBySymbol(trimmed, !strings.Contains(trimmed, ".")); ok {
|
||||||
return typeName, true
|
return typeName, true
|
||||||
}
|
}
|
||||||
|
if typeName, ok := resolveByAlias(trimmed, 0); ok {
|
||||||
|
return typeName, true
|
||||||
|
}
|
||||||
if typeName, ok := resolveByShort(trimmed, !strings.Contains(trimmed, ".")); ok {
|
if typeName, ok := resolveByShort(trimmed, !strings.Contains(trimmed, ".")); ok {
|
||||||
return typeName, true
|
return typeName, true
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(trimmed, ".") {
|
if strings.Contains(trimmed, ".") {
|
||||||
parts := strings.Split(trimmed, ".")
|
parts := strings.Split(trimmed, ".")
|
||||||
|
first := parts[0]
|
||||||
last := parts[len(parts)-1]
|
last := parts[len(parts)-1]
|
||||||
|
targetModuleStart := 0
|
||||||
|
if imports := resolver.moduleImports[contextModuleStart]; imports != nil {
|
||||||
|
targetModuleStart = imports[first]
|
||||||
|
}
|
||||||
|
if typeName, ok := resolveByAlias(last, targetModuleStart); ok {
|
||||||
|
return typeName, true
|
||||||
|
}
|
||||||
|
if typeName, ok := resolveBySymbol(last, false); ok {
|
||||||
|
return typeName, true
|
||||||
|
}
|
||||||
if typeName, ok := resolveByShort(last, false); ok {
|
if typeName, ok := resolveByShort(last, false); ok {
|
||||||
return typeName, true
|
return typeName, true
|
||||||
}
|
}
|
||||||
first := parts[0]
|
|
||||||
if typeName, ok := resolveBySymbol(first, false); ok {
|
if typeName, ok := resolveBySymbol(first, false); ok {
|
||||||
return typeName, true
|
return typeName, true
|
||||||
}
|
}
|
||||||
@@ -473,7 +601,7 @@ func absInt(value int) int {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
var moduleStartRe = regexp.MustCompile(`(?:^|,)(\d+):(?:function\([\w$,]*\)|\([\w$,]*\)=>)\{`)
|
var moduleStartRe = regexp.MustCompile(`(?:^|,)\s*(\d+)\s*:\s*(?:function\s*\(\s*[\w$,\s]*\s*\)|\(\s*[\w$,\s]*\s*\)\s*=>)\s*\{`)
|
||||||
|
|
||||||
func buildModuleStarts(text string) []int {
|
func buildModuleStarts(text string) []int {
|
||||||
matches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
|
matches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
|
||||||
@@ -497,6 +625,38 @@ func moduleStartForPos(moduleStarts []int, pos int) int {
|
|||||||
return moduleStarts[index]
|
return moduleStarts[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildModuleImportIndex(text string, moduleStarts []int) map[int]map[string]int {
|
||||||
|
if len(moduleStarts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleMatches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
|
||||||
|
moduleStartByID := make(map[string]int, len(moduleMatches))
|
||||||
|
for _, match := range moduleMatches {
|
||||||
|
moduleStartByID[text[match[2]:match[3]]] = match[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
importsByModule := make(map[int]map[string]int)
|
||||||
|
for index, moduleStart := range moduleStarts {
|
||||||
|
moduleEnd := len(text)
|
||||||
|
if index+1 < len(moduleStarts) {
|
||||||
|
moduleEnd = moduleStarts[index+1]
|
||||||
|
}
|
||||||
|
body := text[moduleStart:moduleEnd]
|
||||||
|
for _, match := range moduleImportRe.FindAllStringSubmatch(body, -1) {
|
||||||
|
targetModuleStart, ok := moduleStartByID[match[2]]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if importsByModule[moduleStart] == nil {
|
||||||
|
importsByModule[moduleStart] = make(map[string]int)
|
||||||
|
}
|
||||||
|
importsByModule[moduleStart][match[1]] = targetModuleStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return importsByModule
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractProtos extracts proto definitions from formatted JS file
|
// ExtractProtos extracts proto definitions from formatted JS file
|
||||||
func ExtractProtos(inputFile, outputDir string) {
|
func ExtractProtos(inputFile, outputDir string) {
|
||||||
activeDiagnostics = newExtractionDiagnostics()
|
activeDiagnostics = newExtractionDiagnostics()
|
||||||
@@ -512,7 +672,8 @@ func ExtractProtos(inputFile, outputDir string) {
|
|||||||
|
|
||||||
text := string(content)
|
text := string(content)
|
||||||
moduleStarts := buildModuleStarts(text)
|
moduleStarts := buildModuleStarts(text)
|
||||||
aliases := buildAliasIndex(text)
|
aliases := buildAliasIndex(text, moduleStarts)
|
||||||
|
exportAliases := buildWebpackExportAliasIndex(text, moduleStarts)
|
||||||
|
|
||||||
// Extract messages, enums, and services
|
// Extract messages, enums, and services
|
||||||
messages := extractMessages(text, moduleStarts)
|
messages := extractMessages(text, moduleStarts)
|
||||||
@@ -524,7 +685,8 @@ func ExtractProtos(inputFile, outputDir string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolver := newTypeResolver(messages, enums, aliases)
|
resolver := newTypeResolver(messages, enums, aliases, exportAliases)
|
||||||
|
resolver.moduleImports = buildModuleImportIndex(text, moduleStarts)
|
||||||
|
|
||||||
// Generate proto files
|
// Generate proto files
|
||||||
generateProtos(messages, enums, services, resolver, outputDir)
|
generateProtos(messages, enums, services, resolver, outputDir)
|
||||||
@@ -900,6 +1062,8 @@ func parseFieldObject(obj string) (*Field, error) {
|
|||||||
} else {
|
} else {
|
||||||
field.T = tMatch[1]
|
field.T = tMatch[1]
|
||||||
}
|
}
|
||||||
|
} else if shorthandTRe.MatchString(obj) {
|
||||||
|
field.T = "T"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,8 +1359,9 @@ func copyAllExternalTypes(pkgName string, pkg struct {
|
|||||||
neededTypes := make(map[string]bool)
|
neededTypes := make(map[string]bool)
|
||||||
|
|
||||||
for _, msg := range result.messages {
|
for _, msg := range result.messages {
|
||||||
|
preferredPkg, _ := parseTypeName(msg.TypeName)
|
||||||
for _, f := range msg.Fields {
|
for _, f := range msg.Fields {
|
||||||
collectFieldRefsSimple(f, pkgName, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes)
|
collectFieldRefsSimple(f, pkgName, preferredPkg, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, svc := range result.services {
|
for _, svc := range result.services {
|
||||||
@@ -1268,7 +1433,7 @@ func copyAllExternalTypes(pkgName string, pkg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// collectFieldRefsSimple collects external type references from a field (non-recursive, just this field)
|
// collectFieldRefsSimple collects external type references from a field (non-recursive, just this field)
|
||||||
func collectFieldRefsSimple(f Field, currentPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
|
func collectFieldRefsSimple(f Field, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
|
||||||
neededTypes map[string]bool, localTypes map[string]bool) {
|
neededTypes map[string]bool, localTypes map[string]bool) {
|
||||||
|
|
||||||
type refWithKind struct {
|
type refWithKind struct {
|
||||||
@@ -1289,7 +1454,7 @@ func collectFieldRefsSimple(f Field, currentPkg string, contextPos int, contextM
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range refs {
|
for _, item := range refs {
|
||||||
typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, currentPkg, item.kind)
|
typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, preferredPkg, item.kind)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1637,6 +1802,7 @@ func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolve
|
|||||||
// Get the current message's path prefix for relative type resolution
|
// Get the current message's path prefix for relative type resolution
|
||||||
msgPath := msg.ShortName
|
msgPath := msg.ShortName
|
||||||
currentPkg := msg.Package
|
currentPkg := msg.Package
|
||||||
|
preferredPkg, _ := parseTypeName(msg.TypeName)
|
||||||
|
|
||||||
// Group fields by oneof
|
// Group fields by oneof
|
||||||
oneofGroups := make(map[string][]Field)
|
oneofGroups := make(map[string][]Field)
|
||||||
@@ -1652,7 +1818,7 @@ func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolve
|
|||||||
|
|
||||||
// Write regular fields
|
// Write regular fields
|
||||||
for _, f := range regularFields {
|
for _, f := range regularFields {
|
||||||
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, msg.Pos, msg.ModuleStart)
|
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
|
||||||
prefix := ""
|
prefix := ""
|
||||||
if f.Repeated {
|
if f.Repeated {
|
||||||
prefix = "repeated "
|
prefix = "repeated "
|
||||||
@@ -1673,7 +1839,7 @@ func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolve
|
|||||||
fields := oneofGroups[oneofName]
|
fields := oneofGroups[oneofName]
|
||||||
sb.WriteString(fmt.Sprintf("%soneof %s {\n", indentStr, oneofName))
|
sb.WriteString(fmt.Sprintf("%soneof %s {\n", indentStr, oneofName))
|
||||||
for _, f := range fields {
|
for _, f := range fields {
|
||||||
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, msg.Pos, msg.ModuleStart)
|
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
|
||||||
sb.WriteString(fmt.Sprintf("%s %s %s = %d;\n", indentStr, fieldType, f.Name, f.No))
|
sb.WriteString(fmt.Sprintf("%s %s %s = %d;\n", indentStr, fieldType, f.Name, f.No))
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("%s}\n", indentStr))
|
sb.WriteString(fmt.Sprintf("%s}\n", indentStr))
|
||||||
@@ -1721,15 +1887,15 @@ func getNestedPath(shortName string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveFieldType(f Field, resolver *TypeResolver, contextPos int, contextModuleStart int) string {
|
func resolveFieldType(f Field, resolver *TypeResolver, contextPos int, contextModuleStart int) string {
|
||||||
return resolveFieldTypeWithPkg(f, resolver, "", "", contextPos, contextModuleStart)
|
return resolveFieldTypeWithPkg(f, resolver, "", "", "", contextPos, contextModuleStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveFieldTypeWithPkg resolves field type with package awareness
|
// resolveFieldTypeWithPkg resolves field type with package awareness
|
||||||
// parentPath is like "ConversationMessage" or "ConversationMessage.ToolResult"
|
// parentPath is like "ConversationMessage" or "ConversationMessage.ToolResult"
|
||||||
// currentPkg is the package of the current message being written (e.g., "agent.v1")
|
// currentPkg is the package of the current message being written (e.g., "agent.v1")
|
||||||
func resolveFieldTypeWithPkg(f Field, resolver *TypeResolver, parentPath string, currentPkg string, contextPos int, contextModuleStart int) string {
|
func resolveFieldTypeWithPkg(f Field, resolver *TypeResolver, parentPath string, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int) string {
|
||||||
resolveNamedType := func(ref string, expectedKind string) string {
|
resolveNamedType := func(ref string, expectedKind string) string {
|
||||||
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, expectedKind)
|
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, preferredPkg, expectedKind)
|
||||||
if !ok {
|
if !ok {
|
||||||
activeDiagnostics.addUnresolvedType(expectedKind + ":" + ref)
|
activeDiagnostics.addUnresolvedType(expectedKind + ":" + ref)
|
||||||
return fallbackTypeToken(ref)
|
return fallbackTypeToken(ref)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseFieldObjectSupportsShorthandType(t *testing.T) {
|
||||||
|
field, err := parseFieldObject(`{no:4,name:"file_not_found",kind:"message",T,oneof:"result"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse shorthand T: %v", err)
|
||||||
|
}
|
||||||
|
if field.T != "T" {
|
||||||
|
t.Fatalf("parsed shorthand T as %#v, want T", field.T)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebpackExportAliasResolvesServiceMessageType(t *testing.T) {
|
||||||
|
const bundle = `
|
||||||
|
1:(e,t,n)=>{
|
||||||
|
n.d(t,{KS:()=>T,_B:()=>r});
|
||||||
|
var r;
|
||||||
|
class T {}
|
||||||
|
T.typeName="agent.v1.AgentClientMessage";
|
||||||
|
n.proto3.util.setEnumType(r,"agent.v1.DiagnosticSeverity",[]);
|
||||||
|
},
|
||||||
|
2:(e,t,n)=>{
|
||||||
|
var r=n(1);
|
||||||
|
const service={typeName:"agent.v1.AgentService",methods:{run:{name:"Run",I:r.KS,O:r.KS,kind:n.MethodKind.BiDiStreaming}}};
|
||||||
|
}`
|
||||||
|
|
||||||
|
moduleStarts := buildModuleStarts(bundle)
|
||||||
|
messages := []Message{{
|
||||||
|
TypeName: "agent.v1.AgentClientMessage",
|
||||||
|
VarName: "T",
|
||||||
|
InternalName: "T",
|
||||||
|
Package: "agent.v1",
|
||||||
|
Pos: 35,
|
||||||
|
ModuleStart: moduleStartForPos(moduleStarts, 35),
|
||||||
|
}}
|
||||||
|
enums := []Enum{{
|
||||||
|
TypeName: "agent.v1.DiagnosticSeverity",
|
||||||
|
VarName: "r",
|
||||||
|
Package: "agent.v1",
|
||||||
|
Pos: 100,
|
||||||
|
ModuleStart: moduleStartForPos(moduleStarts, 100),
|
||||||
|
}}
|
||||||
|
|
||||||
|
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
|
||||||
|
resolver.moduleImports = buildModuleImportIndex(bundle, moduleStarts)
|
||||||
|
typeName, ok := resolver.ResolveTypeName("r.KS", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "agent.v1", "message")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected webpack export alias to resolve")
|
||||||
|
}
|
||||||
|
if typeName != "agent.v1.AgentClientMessage" {
|
||||||
|
t.Fatalf("resolved r.KS to %q, want agent.v1.AgentClientMessage", typeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolverPrefersExpectedKindOverCurrentPackage(t *testing.T) {
|
||||||
|
resolver := &TypeResolver{bySymbol: map[string][]symbolDef{
|
||||||
|
"nt": {
|
||||||
|
{TypeName: "git_forge.v1.GetTagResponse", Kind: "message", Pos: 10, ModuleStart: 1},
|
||||||
|
{TypeName: "origin.v1.TeamGroupKind", Kind: "enum", Pos: 20, ModuleStart: 1},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
typeName, ok := resolver.ResolveTypeName("nt", 30, 1, "origin.v1", "message")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected cross-package message type to resolve")
|
||||||
|
}
|
||||||
|
if typeName != "git_forge.v1.GetTagResponse" {
|
||||||
|
t.Fatalf("resolved nt to %q, want git_forge.v1.GetTagResponse", typeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
src/proto/**
|
|
||||||
media/index.js
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"name":"cursor-always-local","description":"Implements experimentation features for Cursor","author":"Anysphere, Inc.","publisher":"anysphere","version":"0.0.1","private":true,"repository":{"type":"git","url":"https://github.com/anysphere/vscode"},"extensionKind":["ui"],"engines":{"vscode":"^1.43.0","yarn":"please-use-npm"},"activationEvents":["onStartupFinished","onResolveRemoteAuthority:background-composer"],"enabledApiProposals":["cursor","control","externalUriOpener","contribSourceControlInputBoxMenu"],"main":"./dist/main","contributes":{"commands":[],"keybindings":[],"menus":{"scm/inputBox":[{"command":"cursor.generateGitCommitMessage","when":"scmProvider == git"}]},"jsonValidation":[{"fileMatch":".cursor/environment.json","url":"./schemas/environment.schema.json"}],"configuration":{"type":"object","title":"Cursor Always Local"}},"optionalDependencies":{"@vscode/windows-ca-certs":"^0.3.3"}}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"displayName":"Cursor Always Local","description":"Experimentation @ cursor.sh"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"$schema":"https://json-schema.org/draft/2019-09/schema","description":"Defines a dev environment","allowComments":true,"allowTrailingCommas":false,"definitions":{"common":{"type":"object","properties":{"name":{"type":"string","description":"The name of the environment."},"user":{"type":"string","description":"The user to run the environment as."},"install":{"type":"string","description":"The update command to run on VM startup (after pulling latest changes) to refresh dependencies."},"start":{"type":"string","description":"The start command to run when the environment is started."},"repositoryDependencies":{"type":"array","description":"Repositories that are required for the environment to work, and need to be included in the GitHub access token that is generated for the environment.","items":{"type":"string","description":"The URL of the dependent repository, e.g. `github.com/org/repo`."}},"ports":{"type":"array","description":"Ports to expose from the container. Similar to devcontainers port forwarding.","items":{"type":"object","required":["port"],"properties":{"name":{"type":"string","description":"A descriptive name for the port (e.g., 'web server', 'api')."},"port":{"type":"integer","minimum":1,"maximum":65535,"description":"The port number inside the container to expose."}}}},"terminals":{"type":"array","description":"The terminals to run when the environment is started.","items":{"oneOf":[{"type":"array","items":{"type":"object","required":["command"],"properties":{"name":{"type":"string","description":"The name of the terminal."},"command":{"type":"string","description":"The command to run in the terminal."},"description":{"type":"string","description":"A description of what the terminal does. This is displayed to the agent."}}}},{"type":"object","required":["command"],"properties":{"name":{"type":"string","description":"The name of the terminal."},"command":{"type":"string","description":"The command to run in the terminal."},"description":{"type":"string","description":"A description of what the terminal does. This is displayed to the agent."}}}]}}}},"container":{"type":"object","properties":{"build":{"type":"object","description":"Docker build-related options.","properties":{"dockerfile":{"type":"string","description":"The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `environment.json` file."},"context":{"type":"string","description":"The location of the context folder for building the Docker image. The path is relative to the folder containing the `environment.json` file."}},"required":["dockerfile"],"unevaluatedProperties":false},"snapshot":{"type":"string","description":"A snapshot ID for the base environment."},"agentCanUpdateSnapshot":{"type":"boolean","description":"Whether the agent can update the snapshot."}},"required":[]}},"allOf":[{"$ref":"#/definitions/container"},{"$ref":"#/definitions/common"}],"unevaluatedProperties":false}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# Run vitest with any additional arguments passed after --
|
|
||||||
npx vitest run --config vitest.config.ts "$@"
|
|
||||||
|
|
||||||
# Capture the exit code
|
|
||||||
exit_code=$?
|
|
||||||
|
|
||||||
# If tests failed, echo the colored error message
|
|
||||||
if [ $exit_code -ne 0 ]; then
|
|
||||||
echo -e "\033[1;31m❌ Test failed -- you can set CURSOR_EXT_TEST_LOG_LEVEL=info to see more logs\033[0m"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Exit with the same code as vitest
|
|
||||||
exit $exit_code
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
include_files=()
|
|
||||||
for f in "$@"; do
|
|
||||||
include_files+=("${f#$PWD/}")
|
|
||||||
done
|
|
||||||
node_modules/.bin/tsc --noEmit -p . | (
|
|
||||||
status=0
|
|
||||||
show_continuation=false
|
|
||||||
while IFS='' read -r line; do
|
|
||||||
case "$line" in
|
|
||||||
(' '*)
|
|
||||||
if $show_continuation; then
|
|
||||||
echo "$line" >&2
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
(*)
|
|
||||||
file="${line%%(*}"
|
|
||||||
if [[ " ${include_files[@]} " =~ " ${file} " ]]; then
|
|
||||||
show_continuation=true
|
|
||||||
echo "$line" >&2
|
|
||||||
status=1
|
|
||||||
else
|
|
||||||
show_continuation=false
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
exit $status
|
|
||||||
)
|
|
||||||
@@ -2,27 +2,30 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SNAPSHOT_DEFAULT="$SCRIPT_DIR/extensions-cursor-app/cursor-always-local"
|
|
||||||
INSTALLED_CURSOR_DEFAULT="/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
|
INSTALLED_CURSOR_DEFAULT="/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
|
||||||
LATEST_EXT_DIR="$(
|
INPUT_DEFAULT="$INSTALLED_CURSOR_DEFAULT"
|
||||||
find "$SCRIPT_DIR" -maxdepth 1 -type d -name 'extensions-*' 2>/dev/null \
|
|
||||||
| sort -V \
|
|
||||||
| tail -n 1
|
|
||||||
)"
|
|
||||||
if [[ -f "$SNAPSHOT_DEFAULT/dist/main.js" ]]; then
|
|
||||||
INPUT_DEFAULT="$SNAPSHOT_DEFAULT"
|
|
||||||
elif [[ -f "$INSTALLED_CURSOR_DEFAULT" ]]; then
|
|
||||||
INPUT_DEFAULT="$INSTALLED_CURSOR_DEFAULT"
|
|
||||||
elif [[ -n "$LATEST_EXT_DIR" ]]; then
|
|
||||||
INPUT_DEFAULT="$LATEST_EXT_DIR"
|
|
||||||
else
|
|
||||||
INPUT_DEFAULT="$SCRIPT_DIR/extensions-2.6.19"
|
|
||||||
fi
|
|
||||||
OUTPUT_DEFAULT="$SCRIPT_DIR/from_extensions"
|
OUTPUT_DEFAULT="$SCRIPT_DIR/from_extensions"
|
||||||
|
|
||||||
INPUT_PATH="${1:-$INPUT_DEFAULT}"
|
INPUT_PATH="${1:-$INPUT_DEFAULT}"
|
||||||
OUTPUT_DIR="${2:-$OUTPUT_DEFAULT}"
|
OUTPUT_DIR="${2:-$OUTPUT_DEFAULT}"
|
||||||
|
|
||||||
|
canonicalize_path() {
|
||||||
|
local path="$1"
|
||||||
|
local parent
|
||||||
|
local base
|
||||||
|
if [[ -d "$path" ]]; then
|
||||||
|
(cd "$path" && pwd -P)
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
parent="$(dirname "$path")"
|
||||||
|
base="$(basename "$path")"
|
||||||
|
if [[ ! -d "$parent" ]]; then
|
||||||
|
echo "Parent directory does not exist: $parent" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf '%s/%s\n' "$(cd "$parent" && pwd -P)" "$base"
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve input: accept either a single JS file or an extensions root directory.
|
# Resolve input: accept either a single JS file or an extensions root directory.
|
||||||
if [[ -d "$INPUT_PATH" ]]; then
|
if [[ -d "$INPUT_PATH" ]]; then
|
||||||
CANDIDATES=(
|
CANDIDATES=(
|
||||||
@@ -40,7 +43,10 @@ if [[ -d "$INPUT_PATH" ]]; then
|
|||||||
if [[ -n "$FOUND_CANDIDATE" ]]; then
|
if [[ -n "$FOUND_CANDIDATE" ]]; then
|
||||||
INPUT_PATH="$FOUND_CANDIDATE"
|
INPUT_PATH="$FOUND_CANDIDATE"
|
||||||
else
|
else
|
||||||
mapfile -t JS_FILES < <(find "$INPUT_PATH" -type f -path "*/dist/main.js" | sort)
|
JS_FILES=()
|
||||||
|
while IFS= read -r JS_FILE; do
|
||||||
|
JS_FILES+=("$JS_FILE")
|
||||||
|
done < <(find "$INPUT_PATH" -type f -path "*/dist/main.js" | sort)
|
||||||
if [[ ${#JS_FILES[@]} -eq 1 ]]; then
|
if [[ ${#JS_FILES[@]} -eq 1 ]]; then
|
||||||
INPUT_PATH="${JS_FILES[0]}"
|
INPUT_PATH="${JS_FILES[0]}"
|
||||||
elif [[ ${#JS_FILES[@]} -eq 0 ]]; then
|
elif [[ ${#JS_FILES[@]} -eq 0 ]]; then
|
||||||
@@ -57,15 +63,62 @@ fi
|
|||||||
|
|
||||||
if [[ ! -f "$INPUT_PATH" ]]; then
|
if [[ ! -f "$INPUT_PATH" ]]; then
|
||||||
echo "Input JS not found: $INPUT_PATH" >&2
|
echo "Input JS not found: $INPUT_PATH" >&2
|
||||||
echo "Usage: $0 [input-js-file-or-extensions-dir] [output-dir]" >&2
|
echo "Install/update Cursor, or pass an explicit input bundle:" >&2
|
||||||
|
echo " $0 /path/to/cursor-always-local/dist/main.js [output-dir]" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -rf "$OUTPUT_DIR"
|
INPUT_PATH="$(canonicalize_path "$INPUT_PATH")"
|
||||||
mkdir -p "$OUTPUT_DIR"
|
OUTPUT_DIR="$(canonicalize_path "$OUTPUT_DIR")"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
CURRENT_DIR="$(pwd -P)"
|
||||||
|
|
||||||
|
case "$OUTPUT_DIR" in
|
||||||
|
"/"|"$HOME"|"$REPO_ROOT"|"$SCRIPT_DIR"|"$CURRENT_DIR")
|
||||||
|
echo "Refusing unsafe output directory: $OUTPUT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case "$INPUT_PATH" in
|
||||||
|
"$OUTPUT_DIR"|"$OUTPUT_DIR"/*)
|
||||||
|
echo "Refusing output directory that contains the input bundle: $OUTPUT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
OUTPUT_PARENT="$(dirname "$OUTPUT_DIR")"
|
||||||
|
OUTPUT_BASENAME="$(basename "$OUTPUT_DIR")"
|
||||||
|
TEMP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.tmp.XXXXXX")"
|
||||||
|
BACKUP_DIR=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
|
||||||
|
rm -rf "$TEMP_DIR"
|
||||||
|
fi
|
||||||
|
if [[ -n "$BACKUP_DIR" && -e "$BACKUP_DIR" ]]; then
|
||||||
|
if [[ ! -e "$OUTPUT_DIR" ]]; then
|
||||||
|
mv "$BACKUP_DIR" "$OUTPUT_DIR"
|
||||||
|
else
|
||||||
|
rm -rf "$BACKUP_DIR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
go run "$SCRIPT_DIR/ext_tool" \
|
go run "$SCRIPT_DIR/ext_tool" \
|
||||||
-input "$INPUT_PATH" \
|
-input "$INPUT_PATH" \
|
||||||
-output "$OUTPUT_DIR" \
|
-output "$TEMP_DIR" \
|
||||||
-skip-format \
|
-skip-format \
|
||||||
-strict
|
-strict
|
||||||
|
|
||||||
|
if [[ -e "$OUTPUT_DIR" ]]; then
|
||||||
|
BACKUP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.backup.XXXXXX")"
|
||||||
|
rmdir "$BACKUP_DIR"
|
||||||
|
mv "$OUTPUT_DIR" "$BACKUP_DIR"
|
||||||
|
fi
|
||||||
|
mv "$TEMP_DIR" "$OUTPUT_DIR"
|
||||||
|
TEMP_DIR=""
|
||||||
|
if [[ -n "$BACKUP_DIR" ]]; then
|
||||||
|
rm -rf "$BACKUP_DIR"
|
||||||
|
BACKUP_DIR=""
|
||||||
|
fi
|
||||||
|
|||||||
+2500
-739
File diff suppressed because it is too large
Load Diff
+10930
-4348
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,809 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package git_forge.v1;
|
||||||
|
|
||||||
|
option go_package = "react-admin/cursor-server/gen/git_forge/v1;git_forgev1";
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.BatchGetRepoContentRequest (var: Qt)
|
||||||
|
message BatchGetRepoContentRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string revision = 2;
|
||||||
|
repeated string paths = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.BatchGetRepoContentResponse (var: Ht)
|
||||||
|
message BatchGetRepoContentResponse {
|
||||||
|
repeated BatchRepoContentResult results = 1;
|
||||||
|
string resolved_commit_sha = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.BatchRepoContentResult (var: Gt)
|
||||||
|
message BatchRepoContentResult {
|
||||||
|
string path = 1;
|
||||||
|
bool found = 2;
|
||||||
|
oneof content {
|
||||||
|
FileContent file_content = 3;
|
||||||
|
DirectoryContent directory_content = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.BlameChunk (var: sn)
|
||||||
|
message BlameChunk {
|
||||||
|
repeated ShortCommit commits = 1;
|
||||||
|
repeated BlameLineRange line_ranges = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.BlameLineRange (var: rn)
|
||||||
|
message BlameLineRange {
|
||||||
|
bytes commit_sha = 1;
|
||||||
|
uint32 start_in_blamed_file = 2;
|
||||||
|
uint32 len = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CanMergeRequest (var: ft)
|
||||||
|
message CanMergeRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string ours = 2;
|
||||||
|
string theirs = 3;
|
||||||
|
uint64 change_number = 4;
|
||||||
|
MergeMode mode = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CanMergeResponse (var: gt)
|
||||||
|
message CanMergeResponse {
|
||||||
|
bool can_merge_without_conflicts = 1;
|
||||||
|
optional bytes merged_tree_sha = 2;
|
||||||
|
repeated string conflicted_paths = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ChangeKind (var: _)
|
||||||
|
enum ChangeKind {
|
||||||
|
CHANGE_KIND_UNSPECIFIED = 0;
|
||||||
|
CHANGE_KIND_ADDED = 1;
|
||||||
|
CHANGE_KIND_DELETED = 2;
|
||||||
|
CHANGE_KIND_MODIFIED = 3;
|
||||||
|
CHANGE_KIND_RENAMED = 4;
|
||||||
|
CHANGE_KIND_COPIED = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ChangedFile (var: Nn)
|
||||||
|
message ChangedFile {
|
||||||
|
string path = 1;
|
||||||
|
optional string old_path = 2;
|
||||||
|
ChangeKind change_kind = 3;
|
||||||
|
optional FileMode old_mode = 4;
|
||||||
|
optional FileMode new_mode = 5;
|
||||||
|
optional string old_sha = 6;
|
||||||
|
optional string new_sha = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ChangedFileWithStats (var: Pn)
|
||||||
|
message ChangedFileWithStats {
|
||||||
|
string path = 1;
|
||||||
|
optional string old_path = 2;
|
||||||
|
ChangeKind change_kind = 3;
|
||||||
|
bool is_binary = 4;
|
||||||
|
int32 additions = 5;
|
||||||
|
int32 deletions = 6;
|
||||||
|
optional FileMode old_mode = 7;
|
||||||
|
optional FileMode new_mode = 8;
|
||||||
|
optional string old_sha = 9;
|
||||||
|
optional string new_sha = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.Commit (var: bt)
|
||||||
|
message Commit {
|
||||||
|
string sha = 1;
|
||||||
|
string message = 2;
|
||||||
|
Signature author = 3;
|
||||||
|
Signature committer = 4;
|
||||||
|
repeated string parent_shas = 5;
|
||||||
|
string tree_sha = 7;
|
||||||
|
optional string change_id = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CommitDiffChunk (var: Jn)
|
||||||
|
message CommitDiffChunk {
|
||||||
|
optional CommitDiffHeader header = 1;
|
||||||
|
repeated FileDiff file_diffs = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CommitDiffHeader (var: Bn)
|
||||||
|
message CommitDiffHeader {
|
||||||
|
Commit commit = 1;
|
||||||
|
optional string base_commit_sha = 2;
|
||||||
|
CommitDiffStats stats = 3;
|
||||||
|
repeated ChangedFileWithStats changed_files = 4;
|
||||||
|
bool has_more = 5;
|
||||||
|
optional string next_page_cursor = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CommitDiffStats (var: Rn)
|
||||||
|
message CommitDiffStats {
|
||||||
|
uint32 files_changed = 1;
|
||||||
|
int32 additions = 2;
|
||||||
|
int32 deletions = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CommitFileDelete (var: Ct)
|
||||||
|
message CommitFileDelete {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CommitFileMode (var: g)
|
||||||
|
enum CommitFileMode {
|
||||||
|
COMMIT_FILE_MODE_UNSPECIFIED = 0;
|
||||||
|
COMMIT_FILE_MODE_REGULAR = 1;
|
||||||
|
COMMIT_FILE_MODE_EXECUTABLE = 2;
|
||||||
|
COMMIT_FILE_MODE_SYMLINK = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CommitFileOperation (var: Nt)
|
||||||
|
message CommitFileOperation {
|
||||||
|
string path = 1;
|
||||||
|
oneof operation {
|
||||||
|
CommitFileUpsert upsert = 2;
|
||||||
|
CommitFileDelete delete = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CommitFileUpsert (var: Jt)
|
||||||
|
message CommitFileUpsert {
|
||||||
|
bytes content = 1;
|
||||||
|
CommitFileMode mode = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CompareCommitsRequest (var: dt)
|
||||||
|
message CompareCommitsRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string base_revision = 2;
|
||||||
|
string head_revision = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CompareCommitsResponse (var: pt)
|
||||||
|
message CompareCommitsResponse {
|
||||||
|
CompareCommitsStatus status = 1;
|
||||||
|
int32 ahead_by = 2;
|
||||||
|
int32 behind_by = 3;
|
||||||
|
string base_commit_sha = 4;
|
||||||
|
string head_commit_sha = 5;
|
||||||
|
string merge_base_commit_sha = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CompareCommitsStatus (var: p)
|
||||||
|
enum CompareCommitsStatus {
|
||||||
|
COMPARE_COMMITS_STATUS_UNSPECIFIED = 0;
|
||||||
|
COMPARE_COMMITS_STATUS_IDENTICAL = 1;
|
||||||
|
COMPARE_COMMITS_STATUS_AHEAD = 2;
|
||||||
|
COMPARE_COMMITS_STATUS_BEHIND = 3;
|
||||||
|
COMPARE_COMMITS_STATUS_DIVERGED = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ComputeMergeCommitRequest (var: _t)
|
||||||
|
message ComputeMergeCommitRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string ours_sha = 2;
|
||||||
|
string theirs_sha = 3;
|
||||||
|
string message = 4;
|
||||||
|
Signature author = 5;
|
||||||
|
Signature committer = 6;
|
||||||
|
MergeMode mode = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ComputeMergeCommitResponse (var: Tt)
|
||||||
|
message ComputeMergeCommitResponse {
|
||||||
|
string merge_commit_sha = 1;
|
||||||
|
bytes packfile = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CreateCommitFromFilesRequest (var: Rt)
|
||||||
|
message CreateCommitFromFilesRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string target_ref = 2;
|
||||||
|
optional string expected_head_sha = 3;
|
||||||
|
string message = 4;
|
||||||
|
Signature author = 5;
|
||||||
|
optional Signature committer = 6;
|
||||||
|
repeated CommitFileOperation files = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CreateCommitFromFilesResponse (var: Pt)
|
||||||
|
message CreateCommitFromFilesResponse {
|
||||||
|
string commit_sha = 1;
|
||||||
|
string tree_sha = 2;
|
||||||
|
string old_head_sha = 3;
|
||||||
|
string wal_entry_key = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CreateMergeCommitRequest (var: ht)
|
||||||
|
message CreateMergeCommitRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
optional string ours_sha = 2;
|
||||||
|
optional string theirs_sha = 3;
|
||||||
|
string ours_ref = 4;
|
||||||
|
string theirs_ref = 5;
|
||||||
|
string message = 6;
|
||||||
|
Signature author = 7;
|
||||||
|
Signature committer = 8;
|
||||||
|
uint64 change_number = 9;
|
||||||
|
MergeMode mode = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CreateMergeCommitResponse (var: At)
|
||||||
|
message CreateMergeCommitResponse {
|
||||||
|
string merge_commit_sha = 1;
|
||||||
|
string wal_entry_key = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CreateRepoRequest (var: wn)
|
||||||
|
message CreateRepoRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.CreateRepoResponse (var: En)
|
||||||
|
message CreateRepoResponse {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.DiffHeader (var: Cn)
|
||||||
|
message DiffHeader {
|
||||||
|
string merge_base_commit_sha = 1;
|
||||||
|
repeated ChangedFile files = 2;
|
||||||
|
repeated ChangedFileWithStats files_with_stats = 3;
|
||||||
|
bool has_more = 4;
|
||||||
|
optional string next_page_cursor = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.DirectoryContent (var: qt)
|
||||||
|
message DirectoryContent {
|
||||||
|
repeated RepoContentEntry entries = 1;
|
||||||
|
string sha = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.FastForwardRefRequest (var: qn)
|
||||||
|
message FastForwardRefRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string target_ref = 2;
|
||||||
|
string expected_head_sha = 3;
|
||||||
|
string new_head_sha = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.FastForwardRefResponse (var: Dn)
|
||||||
|
message FastForwardRefResponse {
|
||||||
|
string old_head_sha = 1;
|
||||||
|
string new_head_sha = 2;
|
||||||
|
string wal_entry_key = 3;
|
||||||
|
bool unchanged = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.FileContent (var: Ft)
|
||||||
|
message FileContent {
|
||||||
|
string size = 1;
|
||||||
|
string encoding = 2;
|
||||||
|
string content = 3;
|
||||||
|
string sha = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.FileDiff (var: bn)
|
||||||
|
message FileDiff {
|
||||||
|
string path = 1;
|
||||||
|
optional string old_path = 2;
|
||||||
|
bool is_binary = 3;
|
||||||
|
string patch = 4;
|
||||||
|
int32 additions = 5;
|
||||||
|
int32 deletions = 6;
|
||||||
|
optional FileMode old_mode = 7;
|
||||||
|
optional FileMode new_mode = 8;
|
||||||
|
optional string old_sha = 9;
|
||||||
|
optional string new_sha = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.FileHistoryCommitEntry (var: Zt)
|
||||||
|
message FileHistoryCommitEntry {
|
||||||
|
ShortCommit commit = 1;
|
||||||
|
optional string diff_base_commit_sha = 2;
|
||||||
|
optional FileHistoryPathStats path_stats = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.FileHistoryPathStats (var: Xt)
|
||||||
|
message FileHistoryPathStats {
|
||||||
|
int32 additions = 1;
|
||||||
|
int32 deletions = 2;
|
||||||
|
bool is_binary = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.FileHistoryWithDiffStatsChunk (var: en)
|
||||||
|
message FileHistoryWithDiffStatsChunk {
|
||||||
|
repeated FileHistoryCommitEntry entries = 1;
|
||||||
|
bool exhausted = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.FileMode (var: A)
|
||||||
|
enum FileMode {
|
||||||
|
FILE_MODE_UNSPECIFIED = 0;
|
||||||
|
FILE_MODE_REGULAR = 1;
|
||||||
|
FILE_MODE_EXECUTABLE = 2;
|
||||||
|
FILE_MODE_SYMLINK = 3;
|
||||||
|
FILE_MODE_GITLINK = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetBlameRequest (var: nn)
|
||||||
|
message GetBlameRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string start_commit_sha = 2;
|
||||||
|
string path = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetBlobRequest (var: $e)
|
||||||
|
message GetBlobRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string blob_sha = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetBlobResponse (var: et)
|
||||||
|
message GetBlobResponse {
|
||||||
|
FileContent blob = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetCommitDiffRequest (var: vn)
|
||||||
|
message GetCommitDiffRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string commit_sha = 2;
|
||||||
|
optional string base_commit_sha = 3;
|
||||||
|
bool include_patches = 4;
|
||||||
|
repeated string paths = 5;
|
||||||
|
optional uint32 page_size = 6;
|
||||||
|
optional string page_cursor = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetCommitRequest (var: Xe)
|
||||||
|
message GetCommitRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string commit_sha = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetCommitResponse (var: Ze)
|
||||||
|
message GetCommitResponse {
|
||||||
|
Commit commit = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetFileHistoryPageWithDiffStatsResponse (var: tn)
|
||||||
|
message GetFileHistoryPageWithDiffStatsResponse {
|
||||||
|
repeated FileHistoryCommitEntry entries = 1;
|
||||||
|
bool has_more = 2;
|
||||||
|
optional string next_cursor = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetFileHistoryRequest (var: Wt)
|
||||||
|
message GetFileHistoryRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string start_commit_sha = 2;
|
||||||
|
optional string path = 3;
|
||||||
|
uint32 max_commits = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetFileHistoryResponse (var: zt)
|
||||||
|
message GetFileHistoryResponse {
|
||||||
|
repeated ShortCommit commits = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetFileHistoryWithDiffStatsRequest (var: jt)
|
||||||
|
message GetFileHistoryWithDiffStatsRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string start_commit_sha = 2;
|
||||||
|
optional string path = 3;
|
||||||
|
uint32 max_commits = 4;
|
||||||
|
optional string next_cursor = 5;
|
||||||
|
bool include_diff_stats = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetFileHistoryWithDiffStatsResponse (var: $t)
|
||||||
|
message GetFileHistoryWithDiffStatsResponse {
|
||||||
|
repeated FileHistoryCommitEntry entries = 1;
|
||||||
|
bool has_more = 2;
|
||||||
|
optional string next_cursor = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetFuzzyPathsRequest (var: un)
|
||||||
|
message GetFuzzyPathsRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string commit_sha = 2;
|
||||||
|
string query = 3;
|
||||||
|
uint32 limit = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetFuzzyPathsResponse (var: mn)
|
||||||
|
message GetFuzzyPathsResponse {
|
||||||
|
repeated string paths = 1;
|
||||||
|
bool has_more = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetLocalDevInfoRequest (var: ze)
|
||||||
|
message GetLocalDevInfoRequest {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetLocalDevInfoResponse (var: je)
|
||||||
|
message GetLocalDevInfoResponse {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string git_forge_root_dir = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetPullRequestDiffRequest (var: Sn)
|
||||||
|
message GetPullRequestDiffRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string head_commit_sha = 2;
|
||||||
|
string base_commit_sha = 3;
|
||||||
|
optional bool include_patches = 4;
|
||||||
|
optional uint32 page_size = 5;
|
||||||
|
optional string page_cursor = 6;
|
||||||
|
optional bool include_file_stats = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetRepoContentDetailsRequest (var: Yt)
|
||||||
|
message GetRepoContentDetailsRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
PathIdentifier path_identifier = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetRepoContentDetailsResponse (var: Kt)
|
||||||
|
message GetRepoContentDetailsResponse {
|
||||||
|
optional RepoContentDetails details = 1;
|
||||||
|
PathIdentifier path_identifier = 2;
|
||||||
|
string resolved_commit_sha = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetRepoContentRequest (var: Ut)
|
||||||
|
message GetRepoContentRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
oneof id {
|
||||||
|
PathIdentifier path_identifier = 2;
|
||||||
|
string ref_and_path = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetRepoContentResponse (var: xt)
|
||||||
|
message GetRepoContentResponse {
|
||||||
|
PathIdentifier path_identifier = 3;
|
||||||
|
string resolved_commit_sha = 4;
|
||||||
|
oneof content {
|
||||||
|
FileContent file_content = 1;
|
||||||
|
DirectoryContent directory_content = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetTagRequest (var: tt)
|
||||||
|
message GetTagRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string tag_sha = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetTagResponse (var: nt)
|
||||||
|
message GetTagResponse {
|
||||||
|
Tag tag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetTreeBlameRequest (var: on)
|
||||||
|
message GetTreeBlameRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string start_commit_sha = 2;
|
||||||
|
string path = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetTreeBlameResponse (var: ln)
|
||||||
|
message GetTreeBlameResponse {
|
||||||
|
repeated TreeEntryBlame entries = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetTreeRequest (var: rt)
|
||||||
|
message GetTreeRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string tree_sha = 2;
|
||||||
|
bool recursive = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GetTreeResponse (var: st)
|
||||||
|
message GetTreeResponse {
|
||||||
|
Tree tree = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GrepLineKind (var: h)
|
||||||
|
enum GrepLineKind {
|
||||||
|
GREP_LINE_KIND_UNSPECIFIED = 0;
|
||||||
|
GREP_LINE_KIND_MATCH = 1;
|
||||||
|
GREP_LINE_KIND_CONTEXT = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GrepMatch (var: hn)
|
||||||
|
message GrepMatch {
|
||||||
|
string path = 1;
|
||||||
|
string lines = 2;
|
||||||
|
uint32 line_number = 3;
|
||||||
|
uint64 absolute_offset = 4;
|
||||||
|
repeated GrepSubmatch submatches = 5;
|
||||||
|
GrepLineKind kind = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GrepRepoChunk (var: An)
|
||||||
|
message GrepRepoChunk {
|
||||||
|
repeated GrepMatch matches = 1;
|
||||||
|
bool limit_hit = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GrepRepoRequest (var: fn)
|
||||||
|
message GrepRepoRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string revision = 2;
|
||||||
|
string query = 3;
|
||||||
|
GrepSearchOptions options = 4;
|
||||||
|
uint32 max_results = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GrepSearchOptions (var: pn)
|
||||||
|
message GrepSearchOptions {
|
||||||
|
bool literal = 1;
|
||||||
|
bool case_insensitive = 2;
|
||||||
|
bool whole_word = 3;
|
||||||
|
uint32 context_before = 4;
|
||||||
|
uint32 context_after = 5;
|
||||||
|
optional uint64 max_lines = 6;
|
||||||
|
optional string filter_path = 7;
|
||||||
|
repeated string includes = 8;
|
||||||
|
repeated string excludes = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.GrepSubmatch (var: gn)
|
||||||
|
message GrepSubmatch {
|
||||||
|
uint32 start = 1;
|
||||||
|
uint32 end = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ListCommitsInRangeRequest (var: mt)
|
||||||
|
message ListCommitsInRangeRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string base_revision = 2;
|
||||||
|
string head_revision = 3;
|
||||||
|
optional int32 max_commits = 4;
|
||||||
|
bool oldest_first = 5;
|
||||||
|
ListCommitsSort sort = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ListCommitsInRangeResponse (var: ct)
|
||||||
|
message ListCommitsInRangeResponse {
|
||||||
|
repeated Commit commits = 1;
|
||||||
|
string base_commit_sha = 2;
|
||||||
|
string head_commit_sha = 3;
|
||||||
|
string merge_base_commit_sha = 4;
|
||||||
|
bool truncated = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ListCommitsRequest (var: lt)
|
||||||
|
message ListCommitsRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string revision = 2;
|
||||||
|
int32 page = 3;
|
||||||
|
int32 per_page = 4;
|
||||||
|
ListCommitsSort sort = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ListCommitsResponse (var: ut)
|
||||||
|
message ListCommitsResponse {
|
||||||
|
repeated Commit commits = 1;
|
||||||
|
optional int32 next_page = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ListCommitsSort (var: y)
|
||||||
|
enum ListCommitsSort {
|
||||||
|
LIST_COMMITS_SORT_UNSPECIFIED = 0;
|
||||||
|
LIST_COMMITS_SORT_COMMIT_TIME = 1;
|
||||||
|
LIST_COMMITS_SORT_TOPOLOGICAL = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ListRefsFilter (var: T)
|
||||||
|
enum ListRefsFilter {
|
||||||
|
LIST_REFS_FILTER_UNSPECIFIED = 0;
|
||||||
|
LIST_REFS_FILTER_ALL = 1;
|
||||||
|
LIST_REFS_FILTER_BRANCHES = 2;
|
||||||
|
LIST_REFS_FILTER_TAGS = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ListRefsRequest (var: Mn)
|
||||||
|
message ListRefsRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
ListRefsFilter filter = 2;
|
||||||
|
bool names_only = 3;
|
||||||
|
string prefix = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ListRefsResponse (var: Fn)
|
||||||
|
message ListRefsResponse {
|
||||||
|
repeated string refs = 1;
|
||||||
|
repeated RefInfo ref_infos = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ListTreePathsRequest (var: cn)
|
||||||
|
message ListTreePathsRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string revision = 2;
|
||||||
|
repeated string includes = 3;
|
||||||
|
repeated string excludes = 4;
|
||||||
|
uint32 limit = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ListTreePathsResponse (var: dn)
|
||||||
|
message ListTreePathsResponse {
|
||||||
|
repeated string paths = 1;
|
||||||
|
bool has_more = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.MergeMode (var: f)
|
||||||
|
enum MergeMode {
|
||||||
|
MERGE_MODE_UNSPECIFIED = 0;
|
||||||
|
MERGE_MODE_MERGE_COMMIT = 1;
|
||||||
|
MERGE_MODE_SQUASH = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.NotifyRepoPushedRequest (var: _n)
|
||||||
|
message NotifyRepoPushedRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.NotifyRepoPushedResponse (var: Tn)
|
||||||
|
message NotifyRepoPushedResponse {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.PathIdentifier (var: Lt)
|
||||||
|
message PathIdentifier {
|
||||||
|
string revision = 1;
|
||||||
|
string path = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.PrepareChangeMergeRequest (var: yt)
|
||||||
|
message PrepareChangeMergeRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string base_ref = 2;
|
||||||
|
string head_ref = 3;
|
||||||
|
uint64 change_number = 4;
|
||||||
|
optional string expected_base_sha = 5;
|
||||||
|
optional string expected_head_sha = 6;
|
||||||
|
MergeMode mode = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.PrepareChangeMergeResponse (var: kt)
|
||||||
|
message PrepareChangeMergeResponse {
|
||||||
|
bool mergeable = 1;
|
||||||
|
optional string merged_tree_sha = 2;
|
||||||
|
optional string change_merge_ref = 3;
|
||||||
|
optional string dummy_commit_sha = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.PullRequestDiffChunk (var: In)
|
||||||
|
message PullRequestDiffChunk {
|
||||||
|
optional DiffHeader header = 1;
|
||||||
|
repeated FileDiff file_diffs = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.RebaseStackBranch (var: Et)
|
||||||
|
message RebaseStackBranch {
|
||||||
|
string head_ref = 1;
|
||||||
|
string expected_old_oid = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.RebaseStackBranchUpdate (var: vt)
|
||||||
|
message RebaseStackBranchUpdate {
|
||||||
|
string head_ref = 1;
|
||||||
|
string old_oid = 2;
|
||||||
|
string new_oid = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.RebaseStackConflict (var: Bt)
|
||||||
|
message RebaseStackConflict {
|
||||||
|
string conflicted_head_ref = 1;
|
||||||
|
repeated string conflicted_paths = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.RebaseStackRequest (var: wt)
|
||||||
|
message RebaseStackRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string onto_ref = 2;
|
||||||
|
optional string expected_onto_oid = 3;
|
||||||
|
repeated RebaseStackBranch branches = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.RebaseStackResponse (var: St)
|
||||||
|
message RebaseStackResponse {
|
||||||
|
oneof result {
|
||||||
|
RebaseStackSuccess success = 1;
|
||||||
|
RebaseStackConflict conflict = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.RebaseStackSuccess (var: It)
|
||||||
|
message RebaseStackSuccess {
|
||||||
|
string wal_entry_key = 1;
|
||||||
|
repeated RebaseStackBranchUpdate updates = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.RefInfo (var: Ln)
|
||||||
|
message RefInfo {
|
||||||
|
string name = 1;
|
||||||
|
string target_sha = 2;
|
||||||
|
string object_sha = 3;
|
||||||
|
string object_type = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.RepoContentDetails (var: Vt)
|
||||||
|
message RepoContentDetails {
|
||||||
|
string type = 1;
|
||||||
|
optional uint64 size = 2;
|
||||||
|
bool is_binary = 3;
|
||||||
|
bool too_large_to_introspect = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.RepoContentEntry (var: kn)
|
||||||
|
message RepoContentEntry {
|
||||||
|
string type = 1;
|
||||||
|
string name = 2;
|
||||||
|
string path = 3;
|
||||||
|
string sha = 4;
|
||||||
|
optional uint64 size = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ResolveRefPathRequest (var: Dt)
|
||||||
|
message ResolveRefPathRequest {
|
||||||
|
string repo_uuid = 1;
|
||||||
|
string ref_path = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ResolveRefPathResponse (var: Ot)
|
||||||
|
message ResolveRefPathResponse {
|
||||||
|
PathIdentifier path_identifier = 1;
|
||||||
|
string resolved_commit_sha = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.ShortCommit (var: yn)
|
||||||
|
message ShortCommit {
|
||||||
|
bytes sha = 1;
|
||||||
|
string summary = 2;
|
||||||
|
string author_name = 3;
|
||||||
|
string author_email = 4;
|
||||||
|
int64 timestamp = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.Signature (var: Mt)
|
||||||
|
message Signature {
|
||||||
|
string name = 1;
|
||||||
|
string email = 2;
|
||||||
|
int64 timestamp = 3;
|
||||||
|
int32 timezone_offset = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.Tag (var: at)
|
||||||
|
message Tag {
|
||||||
|
string sha = 1;
|
||||||
|
string name = 2;
|
||||||
|
string message = 3;
|
||||||
|
Signature tagger = 4;
|
||||||
|
string object_sha = 5;
|
||||||
|
string object_type = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.Tree (var: ot)
|
||||||
|
message Tree {
|
||||||
|
string sha = 1;
|
||||||
|
repeated TreeEntry tree = 2;
|
||||||
|
bool truncated = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.TreeEntry (var: it)
|
||||||
|
message TreeEntry {
|
||||||
|
string path = 1;
|
||||||
|
string mode = 2;
|
||||||
|
string type = 3;
|
||||||
|
string sha = 4;
|
||||||
|
optional uint64 size = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from: local:git_forge.v1.TreeEntryBlame (var: an)
|
||||||
|
message TreeEntryBlame {
|
||||||
|
string name = 1;
|
||||||
|
ShortCommit last_commit = 2;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ package internapi.v1;
|
|||||||
|
|
||||||
option go_package = "react-admin/cursor-server/gen/internapi/v1;internapiv1";
|
option go_package = "react-admin/cursor-server/gen/internapi/v1;internapiv1";
|
||||||
|
|
||||||
// Copied from: local:internapi.v1.BlobData (var: hxe)
|
// Copied from: local:internapi.v1.BlobData (var: Mn)
|
||||||
message BlobData {
|
message BlobData {
|
||||||
BlobType blob_type = 1;
|
BlobType blob_type = 1;
|
||||||
bytes blob_id = 2;
|
bytes blob_id = 2;
|
||||||
@@ -14,12 +14,12 @@ message BlobData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copied from: local:internapi.v1.BlobDataPerMessage (var: Txe)
|
// Copied from: local:internapi.v1.BlobDataPerMessage (var: Ln)
|
||||||
message BlobDataPerMessage {
|
message BlobDataPerMessage {
|
||||||
repeated BlobData blob_data = 1;
|
repeated BlobData blob_data = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copied from: local:internapi.v1.BlobType (var: Axe)
|
// Copied from: local:internapi.v1.BlobType (var: Nn)
|
||||||
enum BlobType {
|
enum BlobType {
|
||||||
BLOB_TYPE_UNSPECIFIED = 0;
|
BLOB_TYPE_UNSPECIFIED = 0;
|
||||||
BLOB_TYPE_IMAGE = 1;
|
BLOB_TYPE_IMAGE = 1;
|
||||||
@@ -34,7 +34,7 @@ enum BlobType {
|
|||||||
BLOB_TYPE_VIDEO = 10;
|
BLOB_TYPE_VIDEO = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copied from: local:internapi.v1.ImageBlobData (var: pxe)
|
// Copied from: local:internapi.v1.ImageBlobData (var: bn)
|
||||||
message ImageBlobData {
|
message ImageBlobData {
|
||||||
string mime_type = 1;
|
string mime_type = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user