Compare commits

...
Author SHA1 Message Date
leookun 2f47f02497 chore(cursor-proxy-debugger): enhance traffic capture for Fork Chat and update README
- Added support for capturing and decoding Fork Chat traffic, including `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs`.
- Updated the README to reflect new features and usage instructions for Fork Chat traffic.
- Modified `.gitignore` to include `proto/extensions-cursor-app/`.
- Refactored `Taskfile.yml` to improve error messages related to Cursor extensions.
- Introduced new tests for decoding functionality in `cursor-proxy-debugger`.
2026-08-05 01:17:01 +08:00
leookun 058aaa532e remote: cursor source code from git 2026-08-05 00:34:30 +08:00
leookun 622a57fed7 Revert "Merge pull request #239 from DedSecer/fix/cursor-fork-context"
This reverts commit 697fa99245, reversing
changes made to 5742073be5.
2026-08-04 23:49:49 +08:00
leokunandGitHub 639c452a00 Merge pull request #252 from leookun/release/0.0.44
chore(release): update version to 0.0.44 and enhance release notes
2026-08-03 23:18:11 +08:00
41 changed files with 30208 additions and 11671 deletions
+1
View File
@@ -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-*
+2 -2
View File
@@ -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}}'
+3 -1
View File
@@ -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.
+3 -1
View File
@@ -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` 过滤。
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。 - 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
- 抓包只保留在当前进程内存中;关闭进程后消失。 - 抓包只保留在当前进程内存中;关闭进程后消失。
+64 -6
View File
@@ -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 {
+242
View File
@@ -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()
}
+39 -13
View File
@@ -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 path == "/agent.v1.AgentService/RunSSE" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
if !decodesUnaryResponse(path) {
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" { if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
return 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) {
+8
View File
@@ -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 });
+2
View File
@@ -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",
+1
View File
@@ -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>
-7
View File
@@ -23,7 +23,6 @@ const (
TurnPhaseWaitingExternal TurnPhase = "waiting_external" TurnPhaseWaitingExternal TurnPhase = "waiting_external"
TurnPhaseAwaitingUser TurnPhase = "awaiting_user" TurnPhaseAwaitingUser TurnPhase = "awaiting_user"
TurnPhaseCompacting TurnPhase = "compacting" TurnPhaseCompacting TurnPhase = "compacting"
TurnPhaseCheckpointing TurnPhase = "checkpointing"
TurnPhaseCompleted TurnPhase = "completed" TurnPhaseCompleted TurnPhase = "completed"
TurnPhaseFailed TurnPhase = "failed" TurnPhaseFailed TurnPhase = "failed"
TurnPhaseCanceled TurnPhase = "canceled" TurnPhaseCanceled TurnPhase = "canceled"
@@ -67,7 +66,6 @@ const (
streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery" streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery"
streamTimerShellForeground streamTimerKind = "shell_foreground" streamTimerShellForeground streamTimerKind = "shell_foreground"
streamTimerShellTransportClose streamTimerKind = "shell_transport_close" streamTimerShellTransportClose streamTimerKind = "shell_transport_close"
streamTimerCheckpointBlobs streamTimerKind = "checkpoint_blobs"
streamTimerOrphanCancel streamTimerKind = "orphan_cancel" streamTimerOrphanCancel streamTimerKind = "orphan_cancel"
) )
@@ -320,9 +318,6 @@ func (service *Service) handleStreamCommand(stream *ActiveStream, command stream
case streamCommandCancel: case streamCommandCancel:
return service.handleCancelIntent(command.Intent) return service.handleCancelIntent(command.Intent)
case streamCommandMetadata: case streamCommandMetadata:
if strings.TrimSpace(command.Intent.Kind) == "kv_result" {
return service.handleCheckpointBlobResult(stream, command.Intent.KVClientMessage)
}
return service.handleMetadataIntent(command.Intent) return service.handleMetadataIntent(command.Intent)
case streamCommandExecResult: case streamCommandExecResult:
return service.handleExecResult(command.Intent) return service.handleExecResult(command.Intent)
@@ -1008,8 +1003,6 @@ func (service *Service) handleTimerEvent(stream *ActiveStream, payload *streamTi
return nil return nil
} }
return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed) return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed)
case streamTimerCheckpointBlobs:
return service.handleCheckpointBlobTimeout(stream)
case streamTimerOrphanCancel: case streamTimerOrphanCancel:
stream.mu.Lock() stream.mu.Lock()
subscriberCount := len(stream.Subscribers) subscriberCount := len(stream.Subscribers)
-383
View File
@@ -1,383 +0,0 @@
package forwarder
import (
"encoding/hex"
"fmt"
"log"
"strings"
"time"
"google.golang.org/protobuf/proto"
"cursor/gen/agentv1"
)
const (
checkpointBlobWriteTimeout = 10 * time.Second
checkpointBlobCacheIdleTTL = 6 * time.Hour
checkpointBlobCacheMaxConversations = 256
)
type checkpointBlobCacheEntry struct {
Confirmed map[string]struct{}
LastAccess time.Time
}
func checkpointBlobKey(id []byte) string {
return string(id)
}
func checkpointBlobHex(key string) string {
return hex.EncodeToString([]byte(key))
}
func (service *Service) confirmedCheckpointBlob(conversationID string, key string) bool {
if service == nil || key == "" {
return false
}
service.checkpointBlobMu.Lock()
defer service.checkpointBlobMu.Unlock()
conversationID = strings.TrimSpace(conversationID)
entry := service.checkpointBlobs[conversationID]
if entry == nil {
return false
}
entry.LastAccess = time.Now().UTC()
_, ok := entry.Confirmed[key]
return ok
}
func (service *Service) confirmCheckpointBlob(conversationID string, key string) {
if service == nil || key == "" {
return
}
service.checkpointBlobMu.Lock()
defer service.checkpointBlobMu.Unlock()
if service.checkpointBlobs == nil {
service.checkpointBlobs = make(map[string]*checkpointBlobCacheEntry)
}
conversationID = strings.TrimSpace(conversationID)
now := time.Now().UTC()
entry := service.checkpointBlobs[conversationID]
if entry == nil {
entry = &checkpointBlobCacheEntry{Confirmed: make(map[string]struct{})}
service.checkpointBlobs[conversationID] = entry
}
entry.Confirmed[key] = struct{}{}
entry.LastAccess = now
service.pruneCheckpointBlobCacheLocked(now)
}
func (service *Service) pruneCheckpointBlobCacheLocked(now time.Time) {
if service == nil || len(service.checkpointBlobs) == 0 {
return
}
cutoff := now.Add(-checkpointBlobCacheIdleTTL)
for conversationID, entry := range service.checkpointBlobs {
if entry == nil || entry.LastAccess.Before(cutoff) {
delete(service.checkpointBlobs, conversationID)
}
}
for len(service.checkpointBlobs) > checkpointBlobCacheMaxConversations {
oldestConversationID := ""
oldestAccess := now
for conversationID, entry := range service.checkpointBlobs {
if entry == nil || oldestConversationID == "" || entry.LastAccess.Before(oldestAccess) {
oldestConversationID = conversationID
if entry != nil {
oldestAccess = entry.LastAccess
}
}
}
if oldestConversationID == "" {
return
}
delete(service.checkpointBlobs, oldestConversationID)
}
}
func checkpointCompletionAction(completion *pendingTurnCompletion) checkpointTerminalAction {
if completion == nil {
return checkpointTerminalAction{kind: checkpointTerminalActionNone}
}
return checkpointTerminalAction{
kind: checkpointTerminalActionComplete,
completion: *clonePendingTurnCompletion(completion),
}
}
func checkpointCancellationAction(message string) checkpointTerminalAction {
return checkpointTerminalAction{
kind: checkpointTerminalActionCancel,
cancelMessage: firstNonEmpty(strings.TrimSpace(message), "[canceled] User aborted request"),
}
}
func mergeCheckpointTerminalAction(current checkpointTerminalAction, incoming checkpointTerminalAction) checkpointTerminalAction {
switch {
case incoming.kind == checkpointTerminalActionCancel:
return incoming
case current.kind == checkpointTerminalActionCancel:
return current
case incoming.kind == checkpointTerminalActionComplete:
return incoming
default:
return current
}
}
func (action checkpointTerminalAction) completionValue() *pendingTurnCompletion {
if action.kind != checkpointTerminalActionComplete {
return nil
}
return clonePendingTurnCompletion(&action.completion)
}
func (service *Service) queueCheckpointProjection(stream *ActiveStream, projection *CheckpointProjection, terminalAction checkpointTerminalAction) error {
if service == nil || stream == nil || projection == nil || projection.State == nil {
return nil
}
state, ok := proto.Clone(projection.State).(*agentv1.ConversationStateStructure)
if !ok || state == nil {
return fmt.Errorf("clone checkpoint state")
}
stream.mu.Lock()
if stream.PendingCheckpointBlobWrites == nil {
stream.PendingCheckpointBlobWrites = make(map[uint32]pendingCheckpointBlobWrite)
}
if stream.PendingCheckpointBlobRequests == nil {
stream.PendingCheckpointBlobRequests = make(map[string]uint32)
}
stream.NextCheckpointRevision++
if stream.NextCheckpointRevision == 0 {
stream.NextCheckpointRevision++
}
revision := stream.NextCheckpointRevision
if stream.PendingCheckpoint != nil {
terminalAction = mergeCheckpointTerminalAction(stream.PendingCheckpoint.TerminalAction, terminalAction)
}
required := make(map[string]struct{}, len(projection.Blobs))
toWrite := make([]struct {
requestID uint32
blob CheckpointBlob
}, 0, len(projection.Blobs))
for _, blob := range projection.Blobs {
key := checkpointBlobKey(blob.ID)
if key == "" {
continue
}
required[key] = struct{}{}
if service.confirmedCheckpointBlob(stream.ConversationID, key) {
continue
}
if _, pending := stream.PendingCheckpointBlobRequests[key]; pending {
continue
}
stream.NextCheckpointBlobRequestID++
if stream.NextCheckpointBlobRequestID == 0 {
stream.NextCheckpointBlobRequestID++
}
requestID := stream.NextCheckpointBlobRequestID
stream.PendingCheckpointBlobWrites[requestID] = pendingCheckpointBlobWrite{
Key: key,
Revision: revision,
}
stream.PendingCheckpointBlobRequests[key] = requestID
toWrite = append(toWrite, struct {
requestID uint32
blob CheckpointBlob
}{requestID: requestID, blob: blob})
}
stream.PendingCheckpoint = &pendingCheckpointPublish{
Revision: revision,
State: state,
Required: required,
TerminalAction: terminalAction,
}
if terminalAction.kind != checkpointTerminalActionNone {
stream.Phase = TurnPhaseCheckpointing
}
for requestID, write := range stream.PendingCheckpointBlobWrites {
if _, stillRequired := required[write.Key]; stillRequired {
continue
}
delete(stream.PendingCheckpointBlobWrites, requestID)
delete(stream.PendingCheckpointBlobRequests, write.Key)
}
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
for _, item := range toWrite {
if err := service.broker.Publish(stream.RequestID, StreamEvent{
Message: buildSetCheckpointBlobMessage(item.requestID, item.blob),
}); err != nil {
service.discardPendingCheckpoint(stream, fmt.Errorf("publish checkpoint blob write: %w", err))
return err
}
}
if len(toWrite) > 0 {
service.scheduleStreamTimer(
stream,
providerTimerKey(streamTimerCheckpointBlobs, ""),
checkpointBlobWriteTimeout,
streamTimerCheckpointBlobs,
"",
0,
"checkpoint blob write timeout",
)
}
return service.publishReadyCheckpoint(stream)
}
func clonePendingTurnCompletion(completion *pendingTurnCompletion) *pendingTurnCompletion {
if completion == nil {
return nil
}
cloned := *completion
return &cloned
}
func (service *Service) handleCheckpointBlobResult(stream *ActiveStream, message *agentv1.KvClientMessage) error {
if service == nil || stream == nil || message == nil {
return nil
}
result := message.GetSetBlobResult()
if result == nil {
return nil
}
stream.mu.Lock()
write, ok := stream.PendingCheckpointBlobWrites[message.GetId()]
pendingRequiresBlob := false
if ok {
delete(stream.PendingCheckpointBlobWrites, message.GetId())
delete(stream.PendingCheckpointBlobRequests, write.Key)
if stream.PendingCheckpoint != nil {
_, pendingRequiresBlob = stream.PendingCheckpoint.Required[write.Key]
}
}
conversationID := stream.ConversationID
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
if !ok {
return nil
}
if result.GetError() != nil {
if !pendingRequiresBlob {
return service.publishReadyCheckpoint(stream)
}
return service.abandonPendingCheckpoint(stream, fmt.Errorf(
"write checkpoint blob %s: %s",
checkpointBlobHex(write.Key),
firstNonEmpty(result.GetError().GetMessage(), "client blob store rejected write"),
))
}
service.confirmCheckpointBlob(conversationID, write.Key)
return service.publishReadyCheckpoint(stream)
}
func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
if service == nil || stream == nil {
return nil
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
if pending == nil {
stream.mu.Unlock()
return nil
}
for key := range pending.Required {
if !service.confirmedCheckpointBlob(stream.ConversationID, key) {
stream.mu.Unlock()
return nil
}
}
stream.PendingCheckpoint = nil
state := pending.State
terminalAction := pending.TerminalAction
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
return err
}
switch terminalAction.kind {
case checkpointTerminalActionComplete:
if completion := terminalAction.completionValue(); completion != nil {
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
}
case checkpointTerminalActionCancel:
return service.finishCanceledTurnAfterCheckpoint(stream, terminalAction.cancelMessage)
}
return nil
}
func (service *Service) discardPendingCheckpoint(stream *ActiveStream, cause error) {
if service == nil || stream == nil {
return
}
stream.mu.Lock()
stream.PendingCheckpoint = nil
stream.PendingCheckpointBlobWrites = make(map[uint32]pendingCheckpointBlobWrite)
stream.PendingCheckpointBlobRequests = make(map[string]uint32)
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
if cause != nil {
log.Printf("forwarder pending checkpoint discarded request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, cause)
}
}
func (service *Service) abandonPendingCheckpoint(stream *ActiveStream, cause error) error {
if service == nil || stream == nil {
return nil
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
stream.PendingCheckpoint = nil
stream.PendingCheckpointBlobWrites = make(map[uint32]pendingCheckpointBlobWrite)
stream.PendingCheckpointBlobRequests = make(map[string]uint32)
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
if cause != nil {
log.Printf("forwarder checkpoint blob sync abandoned request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, cause)
}
if pending != nil {
return service.failTerminalCheckpointSync(stream, cause)
}
return nil
}
func (service *Service) finishCanceledTurnAfterCheckpoint(stream *ActiveStream, message string) error {
if stream == nil {
return nil
}
service.setTurnPhase(stream, TurnPhaseCanceled)
return service.broker.Cancel(stream.RequestID, firstNonEmpty(strings.TrimSpace(message), "[canceled] User aborted request"))
}
func (service *Service) failTerminalCheckpointSync(stream *ActiveStream, cause error) error {
if stream == nil {
return nil
}
message := "checkpoint synchronization failed"
if cause != nil && strings.TrimSpace(cause.Error()) != "" {
message = strings.TrimSpace(cause.Error())
}
service.setTurnPhase(stream, TurnPhaseFailed)
return service.broker.Fail(stream.RequestID, "checkpoint_sync_error", message)
}
func (service *Service) handleCheckpointBlobTimeout(stream *ActiveStream) error {
if stream == nil {
return nil
}
stream.mu.Lock()
pendingCount := len(stream.PendingCheckpointBlobWrites)
stream.mu.Unlock()
if pendingCount == 0 {
return service.publishReadyCheckpoint(stream)
}
return service.abandonPendingCheckpoint(stream, fmt.Errorf("%d checkpoint blob writes timed out", pendingCount))
}
@@ -1,546 +0,0 @@
package forwarder
import (
"os"
"path/filepath"
"testing"
"cursor/gen/agentv1"
)
func TestCheckpointBlobSyncPublishesCheckpointAfterAllWrites(t *testing.T) {
service, stream := testCheckpointBlobService(t)
projection, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "hello"),
newAssistantTextEntry(1, "request-1", "hi", "", ""),
}))
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if err := service.queueCheckpointProjection(stream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("ReadFromCursor() error = %v", err)
}
if len(events) != len(projection.Blobs) {
t.Fatalf("events before ACK = %d, want %d blob writes", len(events), len(projection.Blobs))
}
for _, event := range events {
if event.Message.GetKvServerMessage().GetSetBlobArgs() == nil {
t.Fatalf("event before ACK = %#v, want set_blob_args", event.Message)
}
}
for index, event := range events {
requestID := event.Message.GetKvServerMessage().GetId()
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
Id: requestID,
Message: &agentv1.KvClientMessage_SetBlobResult{
SetBlobResult: &agentv1.SetBlobResult{},
},
}); err != nil {
t.Fatalf("handleCheckpointBlobResult(%d) error = %v", index, err)
}
}
events, err = service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("ReadFromCursor() after ACK error = %v", err)
}
if len(events) != len(projection.Blobs)+1 {
t.Fatalf("events after ACK = %d, want %d", len(events), len(projection.Blobs)+1)
}
checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate()
if checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
t.Fatalf("last event checkpoint = %#v, want one Blob-backed turn", checkpoint)
}
}
func TestCheckpointBlobSyncRejectDoesNotPublishDanglingCheckpoint(t *testing.T) {
service, stream := testCheckpointBlobService(t)
projection, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "hello"),
}))
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if err := service.queueCheckpointProjection(stream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil || len(events) == 0 {
t.Fatalf("blob write events = %d, err = %v", len(events), err)
}
requestID := events[0].Message.GetKvServerMessage().GetId()
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
Id: requestID,
Message: &agentv1.KvClientMessage_SetBlobResult{
SetBlobResult: &agentv1.SetBlobResult{Error: &agentv1.Error{Message: "disk full"}},
},
}); err != nil {
t.Fatalf("handleCheckpointBlobResult() error = %v", err)
}
events, err = service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("ReadFromCursor() after rejection error = %v", err)
}
for _, event := range events {
if event.Message.GetConversationCheckpointUpdate() != nil {
t.Fatal("rejected Blob write published a dangling checkpoint")
}
}
}
func TestTerminalCheckpointRejectionFailsInsteadOfCompleting(t *testing.T) {
service, stream := testCheckpointBlobService(t)
projection, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, stream.RequestID, "hello"),
}))
if err != nil {
t.Fatalf("projection: %v", err)
}
completion := &pendingTurnCompletion{RequestID: stream.RequestID}
if err := service.queueCheckpointProjection(stream, projection, checkpointCompletionAction(completion)); err != nil {
t.Fatalf("queue checkpoint: %v", err)
}
stream.mu.Lock()
var requestID uint32
for pendingID := range stream.PendingCheckpointBlobWrites {
requestID = pendingID
break
}
stream.mu.Unlock()
if requestID == 0 {
t.Fatal("test did not queue a Blob write")
}
if err := rejectCheckpointBlob(service, stream, requestID, "disk full"); err != nil {
t.Fatalf("reject terminal checkpoint: %v", err)
}
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("ReadFromCursor() error = %v", err)
}
var failed, completed bool
for _, event := range events {
if event.End && event.TerminalErrorCode == "checkpoint_sync_error" {
failed = true
}
if event.Message.GetInteractionUpdate().GetTurnEnded() != nil {
completed = true
}
}
if !failed || completed {
t.Fatalf("terminal checkpoint events failed=%v completed=%v", failed, completed)
}
}
func TestCheckpointBlobSyncMergesRevisionsWithoutObsoleteFailure(t *testing.T) {
service, stream := testCheckpointBlobService(t)
first, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "hello"),
}))
if err != nil {
t.Fatalf("first ProjectCheckpointProjection() error = %v", err)
}
latest, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "hello"),
newAssistantTextEntry(1, "request-1", "latest answer", "", ""),
}))
if err != nil {
t.Fatalf("latest ProjectCheckpointProjection() error = %v", err)
}
if err := service.queueCheckpointProjection(stream, first, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
t.Fatalf("queue first projection: %v", err)
}
firstEvents, err := service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("read first events: %v", err)
}
if err := service.queueCheckpointProjection(stream, latest, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
t.Fatalf("queue latest projection: %v", err)
}
latestRequired := make(map[string]struct{}, len(latest.Blobs))
for _, blob := range latest.Blobs {
latestRequired[string(blob.ID)] = struct{}{}
}
var obsoleteRequestID uint32
for _, event := range firstEvents {
message := event.Message.GetKvServerMessage()
if message == nil || message.GetSetBlobArgs() == nil {
continue
}
if _, required := latestRequired[string(message.GetSetBlobArgs().GetBlobId())]; !required {
obsoleteRequestID = message.GetId()
break
}
}
if obsoleteRequestID == 0 {
t.Fatal("test did not find an obsolete first-revision Blob write")
}
if err := rejectCheckpointBlob(service, stream, obsoleteRequestID, "obsolete write rejected"); err != nil {
t.Fatalf("reject obsolete Blob: %v", err)
}
if err := acknowledgePendingCheckpointBlobs(service, stream); err != nil {
t.Fatalf("acknowledge latest Blob writes: %v", err)
}
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("read merged events: %v", err)
}
checkpoints := 0
for _, event := range events {
if checkpoint := event.Message.GetConversationCheckpointUpdate(); checkpoint != nil {
checkpoints++
if len(checkpoint.GetTurns()) != len(latest.State.GetTurns()) || string(checkpoint.GetTurns()[0]) != string(latest.State.GetTurns()[0]) {
t.Fatalf("published checkpoint is not the latest revision: %#v", checkpoint)
}
}
}
if checkpoints != 1 {
t.Fatalf("published checkpoints = %d, want exactly latest revision", checkpoints)
}
}
func TestCheckpointBlobSyncCarriesCompletionIntoLatestRevision(t *testing.T) {
service, stream := testCheckpointBlobService(t)
first, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "hello"),
}))
if err != nil {
t.Fatalf("first projection: %v", err)
}
latest, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "hello"),
newAssistantTextEntry(1, "request-1", "done", "", ""),
}))
if err != nil {
t.Fatalf("latest projection: %v", err)
}
completion := &pendingTurnCompletion{
RequestID: stream.RequestID,
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
}
if err := service.queueCheckpointProjection(stream, first, checkpointCompletionAction(completion)); err != nil {
t.Fatalf("queue completion projection: %v", err)
}
if err := service.queueCheckpointProjection(stream, latest, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
t.Fatalf("queue latest projection: %v", err)
}
if err := acknowledgePendingCheckpointBlobs(service, stream); err != nil {
t.Fatalf("acknowledge latest Blob writes: %v", err)
}
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("read completion events: %v", err)
}
checkpointIndex, turnEndedIndex, endIndex := -1, -1, -1
for index, event := range events {
switch {
case event.Message.GetConversationCheckpointUpdate() != nil:
checkpointIndex = index
case event.Message.GetInteractionUpdate().GetTurnEnded() != nil:
turnEndedIndex = index
case event.End:
endIndex = index
}
}
if checkpointIndex < 0 || turnEndedIndex <= checkpointIndex || endIndex <= turnEndedIndex {
t.Fatalf("terminal order checkpoint=%d turn_ended=%d end=%d", checkpointIndex, turnEndedIndex, endIndex)
}
}
func TestCheckpointBlobSyncTimeoutFailsStreamWithoutPublishingDanglingCheckpoint(t *testing.T) {
service, stream := testCheckpointBlobService(t)
projection, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "hello"),
}))
if err != nil {
t.Fatalf("projection: %v", err)
}
if err := service.queueCheckpointProjection(stream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
t.Fatalf("queue checkpoint: %v", err)
}
if err := service.handleCheckpointBlobTimeout(stream); err != nil {
t.Fatalf("timeout checkpoint: %v", err)
}
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("read timeout events: %v", err)
}
var failed bool
for _, event := range events {
if event.Message.GetConversationCheckpointUpdate() != nil {
t.Fatal("timed-out Blob dependency published a dangling checkpoint")
}
if event.End && event.TerminalErrorCode == "checkpoint_sync_error" {
failed = true
}
}
if !failed {
t.Fatal("timed-out checkpoint did not fail the stream explicitly")
}
}
func TestCheckpointBlobSyncReusesConversationCacheAcrossRequests(t *testing.T) {
service, firstStream := testCheckpointBlobService(t)
projection, err := service.projector.ProjectCheckpointProjection(testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "hello"),
}))
if err != nil {
t.Fatalf("projection: %v", err)
}
if err := service.queueCheckpointProjection(firstStream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
t.Fatalf("queue first request: %v", err)
}
if err := acknowledgePendingCheckpointBlobs(service, firstStream); err != nil {
t.Fatalf("acknowledge first request: %v", err)
}
secondStream, err := service.broker.OpenStream(
"request-2", firstStream.ConversationID, 2, "default", "default",
agentv1.AgentMode_AGENT_MODE_AGENT, "continue",
)
if err != nil {
t.Fatalf("OpenStream() second request error = %v", err)
}
if err := service.queueCheckpointProjection(secondStream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
t.Fatalf("queue second request: %v", err)
}
events, err := service.broker.ReadFromCursor(secondStream.RequestID, 0)
if err != nil {
t.Fatalf("read second request events: %v", err)
}
if len(events) != 1 || events[0].Message.GetConversationCheckpointUpdate() == nil {
t.Fatalf("second request events = %#v, want cached immediate checkpoint", events)
}
}
func TestCancellationReplacesUnconfirmedCheckpointBeforeEnding(t *testing.T) {
service, stream := testCheckpointBlobService(t)
conversation := testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, stream.RequestID, "hello"),
})
if err := service.replaceCheckpointConversation(stream, conversation); err != nil {
t.Fatalf("replaceCheckpointConversation() error = %v", err)
}
projection, err := service.projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if err := service.queueCheckpointProjection(stream, projection, checkpointTerminalAction{kind: checkpointTerminalActionNone}); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
stream.mu.Lock()
var staleRequestID uint32
for requestID := range stream.PendingCheckpointBlobWrites {
staleRequestID = requestID
break
}
stream.mu.Unlock()
if staleRequestID == 0 {
t.Fatal("test did not queue an unconfirmed Blob write")
}
if err := service.handleCancelIntent(InboundIntent{
Kind: "cancel",
RequestID: stream.RequestID,
CancelReason: "user stopped",
}); err != nil {
t.Fatalf("handleCancelIntent() error = %v", err)
}
stream.mu.Lock()
phase := stream.Phase
status := stream.Status
pendingCheckpoint := stream.PendingCheckpoint
pendingWrites := len(stream.PendingCheckpointBlobWrites)
stream.mu.Unlock()
if phase != TurnPhaseCheckpointing || status != StreamStatusCreated {
t.Fatalf("before checkpoint ACK phase=%s status=%s, want checkpointing/created", phase, status)
}
if pendingCheckpoint == nil || pendingWrites == 0 {
t.Fatalf("before checkpoint ACK pending_checkpoint=%v pending_writes=%d", pendingCheckpoint != nil, pendingWrites)
}
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
Id: staleRequestID,
Message: &agentv1.KvClientMessage_SetBlobResult{
SetBlobResult: &agentv1.SetBlobResult{},
},
}); err != nil {
t.Fatalf("stale Blob ACK error = %v", err)
}
if err := acknowledgePendingCheckpointBlobs(service, stream); err != nil {
t.Fatalf("acknowledge cancellation checkpoint: %v", err)
}
stream.mu.Lock()
phase = stream.Phase
status = stream.Status
stream.mu.Unlock()
if phase != TurnPhaseCanceled || status != StreamStatusCanceled {
t.Fatalf("after checkpoint ACK phase=%s status=%s, want canceled", phase, status)
}
assertCanceledEndEvent(t, service, stream)
}
func TestCancellationMetadataFailureStillEndsStream(t *testing.T) {
service, stream := testCheckpointBlobService(t)
blockingPath := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(blockingPath, []byte("block child creation"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
service.store = NewConversationFileStore(blockingPath)
if err := service.replaceCheckpointConversation(stream, testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, stream.RequestID, "hello"),
})); err != nil {
t.Fatalf("replaceCheckpointConversation() error = %v", err)
}
if err := service.handleCancelIntent(InboundIntent{Kind: "cancel", RequestID: stream.RequestID}); err != nil {
t.Fatalf("handleCancelIntent() error = %v", err)
}
if err := acknowledgePendingCheckpointBlobs(service, stream); err != nil {
t.Fatalf("acknowledge cancellation checkpoint: %v", err)
}
assertCanceledEndEvent(t, service, stream)
}
func TestCheckpointTerminalActionMergePriority(t *testing.T) {
complete := checkpointCompletionAction(&pendingTurnCompletion{RequestID: "complete"})
cancel := checkpointCancellationAction("user canceled")
none := checkpointTerminalAction{kind: checkpointTerminalActionNone}
tests := []struct {
name string
current checkpointTerminalAction
incoming checkpointTerminalAction
wantKind checkpointTerminalActionKind
wantID string
}{
{name: "none then complete", current: none, incoming: complete, wantKind: checkpointTerminalActionComplete, wantID: "complete"},
{name: "complete then none", current: complete, incoming: none, wantKind: checkpointTerminalActionComplete, wantID: "complete"},
{name: "complete then cancel", current: complete, incoming: cancel, wantKind: checkpointTerminalActionCancel},
{name: "cancel then complete", current: cancel, incoming: complete, wantKind: checkpointTerminalActionCancel},
{name: "cancel then none", current: cancel, incoming: none, wantKind: checkpointTerminalActionCancel},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
merged := mergeCheckpointTerminalAction(test.current, test.incoming)
if merged.kind != test.wantKind {
t.Fatalf("merged kind = %d, want %d", merged.kind, test.wantKind)
}
if test.wantID != "" {
completion := merged.completionValue()
if completion == nil || completion.RequestID != test.wantID {
t.Fatalf("merged completion = %#v, want request_id=%s", completion, test.wantID)
}
}
})
}
}
func TestCheckpointTerminalActionIsMutuallyExclusive(t *testing.T) {
completion := &pendingTurnCompletion{RequestID: "request-1"}
action := checkpointCompletionAction(completion)
if action.kind != checkpointTerminalActionComplete || action.completionValue() == nil {
t.Fatalf("completion action = %#v", action)
}
empty := checkpointCompletionAction(nil)
if empty.kind != checkpointTerminalActionNone || empty.completionValue() != nil {
t.Fatalf("empty action = %#v", empty)
}
}
func assertCanceledEndEvent(t *testing.T, service *Service, stream *ActiveStream) {
t.Helper()
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("ReadFromCursor() error = %v", err)
}
for _, event := range events {
if event.End && event.TerminalErrorCode == "canceled" {
return
}
}
t.Fatal("cancellation did not publish canceled end event")
}
func acknowledgePendingCheckpointBlobs(service *Service, stream *ActiveStream) error {
for {
stream.mu.Lock()
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
for requestID := range stream.PendingCheckpointBlobWrites {
requestIDs = append(requestIDs, requestID)
}
stream.mu.Unlock()
if len(requestIDs) == 0 {
return nil
}
for _, requestID := range requestIDs {
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
Id: requestID,
Message: &agentv1.KvClientMessage_SetBlobResult{
SetBlobResult: &agentv1.SetBlobResult{},
},
}); err != nil {
return err
}
}
}
}
func rejectCheckpointBlob(service *Service, stream *ActiveStream, requestID uint32, message string) error {
return service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
Id: requestID,
Message: &agentv1.KvClientMessage_SetBlobResult{
SetBlobResult: &agentv1.SetBlobResult{Error: &agentv1.Error{Message: message}},
},
})
}
func TestImportedTurnIDsRemainCheckpointPrefix(t *testing.T) {
importedID := make([]byte, 32)
for index := range importedID {
importedID[index] = byte(index + 1)
}
conversation := testConversation([]HistoryEntry{
testUserMessageEntry(t, 2, "request-2", "continued question"),
})
conversation.ImportedTurnIDs = [][]byte{importedID}
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if len(projection.State.GetTurns()) != 2 {
t.Fatalf("turns = %d, want imported prefix plus projected turn", len(projection.State.GetTurns()))
}
if string(projection.State.GetTurns()[0]) != string(importedID) {
t.Fatal("imported turn ID was not preserved as the checkpoint prefix")
}
}
func testCheckpointBlobService(t *testing.T) (*Service, *ActiveStream) {
t.Helper()
broker := NewStreamBroker()
service := &Service{
projector: NewHistoryProjector(),
broker: broker,
checkpointBlobs: make(map[string]*checkpointBlobCacheEntry),
}
stream, err := broker.OpenStream(
"request-1",
"conversation-1",
1,
"default",
"default",
agentv1.AgentMode_AGENT_MODE_AGENT,
"hello",
)
if err != nil {
t.Fatalf("OpenStream() error = %v", err)
}
return service, stream
}
-2
View File
@@ -102,8 +102,6 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
BackgroundShellsByMessageID: make(map[uint32]string), BackgroundShellsByMessageID: make(map[uint32]string),
BackgroundShellsByExecID: make(map[string]string), BackgroundShellsByExecID: make(map[string]string),
BackgroundShellActions: make(map[string]time.Time), BackgroundShellActions: make(map[string]time.Time),
PendingCheckpointBlobWrites: make(map[uint32]pendingCheckpointBlobWrite),
PendingCheckpointBlobRequests: make(map[string]uint32),
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
-16
View File
@@ -245,22 +245,6 @@ func buildCheckpointMessage(state *agentv1.ConversationStateStructure) *agentv1.
} }
} }
func buildSetCheckpointBlobMessage(id uint32, blob CheckpointBlob) *agentv1.AgentServerMessage {
return &agentv1.AgentServerMessage{
Message: &agentv1.AgentServerMessage_KvServerMessage{
KvServerMessage: &agentv1.KvServerMessage{
Id: id,
Message: &agentv1.KvServerMessage_SetBlobArgs{
SetBlobArgs: &agentv1.SetBlobArgs{
BlobId: append([]byte(nil), blob.ID...),
BlobData: append([]byte(nil), blob.Data...),
},
},
},
},
}
}
// buildExecAbortMessage 构造对客户端执行桥的 abort 控制消息。 // buildExecAbortMessage 构造对客户端执行桥的 abort 控制消息。
func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage { func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage {
return &agentv1.AgentServerMessage{ return &agentv1.AgentServerMessage{
-2
View File
@@ -750,7 +750,6 @@ func mergeConversationMetadata(target *ConversationFile, source *ConversationFil
target.CurrentPlanText = source.CurrentPlanText target.CurrentPlanText = source.CurrentPlanText
target.CurrentPlans = clonePlanRegistryEntries(source.CurrentPlans) target.CurrentPlans = clonePlanRegistryEntries(source.CurrentPlans)
target.CurrentTodos = cloneTodoItems(source.CurrentTodos) target.CurrentTodos = cloneTodoItems(source.CurrentTodos)
target.ImportedTurnIDs = cloneByteSlices(source.ImportedTurnIDs)
target.LatestRequestPrefix = cloneConversationRequestPrefix(source.LatestRequestPrefix) target.LatestRequestPrefix = cloneConversationRequestPrefix(source.LatestRequestPrefix)
target.LastProviderCall = cloneConversationProviderCall(source.LastProviderCall) target.LastProviderCall = cloneConversationProviderCall(source.LastProviderCall)
if !source.CreatedAt.IsZero() && (target.CreatedAt.IsZero() || source.CreatedAt.Before(target.CreatedAt)) { if !source.CreatedAt.IsZero() && (target.CreatedAt.IsZero() || source.CreatedAt.Before(target.CreatedAt)) {
@@ -883,7 +882,6 @@ func cloneConversationFile(conversation *ConversationFile) *ConversationFile {
cloned := *conversation cloned := *conversation
cloned.CurrentPlans = clonePlanRegistryEntries(conversation.CurrentPlans) cloned.CurrentPlans = clonePlanRegistryEntries(conversation.CurrentPlans)
cloned.CurrentTodos = cloneTodoItems(conversation.CurrentTodos) cloned.CurrentTodos = cloneTodoItems(conversation.CurrentTodos)
cloned.ImportedTurnIDs = cloneByteSlices(conversation.ImportedTurnIDs)
cloned.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix) cloned.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
cloned.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall) cloned.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
cloned.Entries = append([]HistoryEntry(nil), conversation.Entries...) cloned.Entries = append([]HistoryEntry(nil), conversation.Entries...)
@@ -1,167 +0,0 @@
package forwarder
import (
"crypto/sha256"
"fmt"
"google.golang.org/protobuf/proto"
"cursor/gen/agentv1"
modeladapter "cursor/internal/backend/agent/model"
promptengine "cursor/internal/backend/agent/prompt"
)
type importedBlobStore map[string][]byte
func newImportedBlobStore(items []*agentv1.PreFetchedBlob) (importedBlobStore, error) {
if len(items) == 0 {
return nil, nil
}
store := make(importedBlobStore, len(items))
for _, item := range items {
if item == nil || len(item.GetId()) == 0 {
continue
}
if len(item.GetId()) != sha256.Size {
return nil, fmt.Errorf("prefetched blob id length %d, want %d", len(item.GetId()), sha256.Size)
}
digest := sha256.Sum256(item.GetValue())
if string(digest[:]) != string(item.GetId()) {
return nil, fmt.Errorf("prefetched blob %x failed SHA-256 validation", item.GetId())
}
store[string(item.GetId())] = append([]byte(nil), item.GetValue()...)
}
return store, nil
}
func (store importedBlobStore) resolve(id []byte) ([]byte, bool) {
if len(id) == 0 || len(store) == 0 {
return nil, false
}
value, ok := store[string(id)]
return append([]byte(nil), value...), ok
}
func decodeImportedTurn(raw []byte, blobs importedBlobStore) (*agentv1.ConversationTurnStructure, []byte, error) {
if data, ok := blobs.resolve(raw); ok {
turn := &agentv1.ConversationTurnStructure{}
if err := proto.Unmarshal(data, turn); err != nil || turn.GetTurn() == nil {
return nil, nil, fmt.Errorf("decode imported turn blob %x: %w", raw, firstNonNilError(err, fmt.Errorf("turn payload is empty")))
}
return turn, append([]byte(nil), raw...), nil
}
turn := &agentv1.ConversationTurnStructure{}
if err := proto.Unmarshal(raw, turn); err == nil && turn.GetTurn() != nil {
return turn, nil, nil
}
if len(raw) == sha256.Size {
return nil, append([]byte(nil), raw...), nil
}
return nil, nil, fmt.Errorf("decode imported inline turn")
}
func decodeImportedUserMessage(raw []byte, blobs importedBlobStore) (*agentv1.UserMessage, error) {
data := raw
if resolved, ok := blobs.resolve(raw); ok {
data = resolved
} else if len(raw) == sha256.Size {
candidate := &agentv1.UserMessage{}
if err := proto.Unmarshal(raw, candidate); err != nil || !hasKnownUserMessageContent(candidate) {
return nil, fmt.Errorf("missing prefetched user message blob %x", raw)
}
return candidate, nil
}
message := &agentv1.UserMessage{}
if err := proto.Unmarshal(data, message); err != nil {
return nil, fmt.Errorf("decode imported turn user_message: %w", err)
}
return message, nil
}
func decodeImportedStep(raw []byte, blobs importedBlobStore) (*agentv1.ConversationStep, error) {
data := raw
if resolved, ok := blobs.resolve(raw); ok {
data = resolved
} else if len(raw) == sha256.Size {
candidate := &agentv1.ConversationStep{}
if err := proto.Unmarshal(raw, candidate); err != nil || candidate.GetMessage() == nil {
return nil, fmt.Errorf("missing prefetched conversation step blob %x", raw)
}
return candidate, nil
}
step := &agentv1.ConversationStep{}
if err := proto.Unmarshal(data, step); err != nil {
return nil, fmt.Errorf("decode imported turn step: %w", err)
}
if step.GetMessage() == nil {
return nil, fmt.Errorf("decode imported turn step: payload is empty")
}
return step, nil
}
func importedBlobTurnMessages(turn *agentv1.ConversationTurnStructure, blobs importedBlobStore) ([]modeladapter.Message, error) {
if turn == nil || turn.GetAgentConversationTurn() == nil {
return nil, nil
}
agentTurn := turn.GetAgentConversationTurn()
messages := make([]modeladapter.Message, 0, 1+len(agentTurn.GetSteps()))
if len(agentTurn.GetUserMessage()) > 0 {
userMessage, err := decodeImportedUserMessage(agentTurn.GetUserMessage(), blobs)
if err != nil {
return nil, err
}
if replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage); ok {
messages = append(messages, toModelMessage(replay))
}
}
for _, rawStep := range agentTurn.GetSteps() {
if len(rawStep) == 0 {
continue
}
step, err := decodeImportedStep(rawStep, blobs)
if err != nil {
return nil, err
}
for _, replay := range promptengine.BuildLegacyMessagesFromConversationStep(step) {
messages = append(messages, toModelMessage(replay))
}
}
return messages, nil
}
func importedTurnIDs(turns [][]byte, blobs importedBlobStore) ([][]byte, error) {
ids := make([][]byte, 0, len(turns))
for _, raw := range turns {
if len(raw) == 0 {
continue
}
_, id, err := decodeImportedTurn(raw, blobs)
if err != nil {
return nil, err
}
if len(id) > 0 {
ids = append(ids, id)
}
}
return ids, nil
}
func hasKnownUserMessageContent(message *agentv1.UserMessage) bool {
if message == nil {
return false
}
return message.GetText() != "" ||
message.GetMessageId() != "" ||
message.GetSelectedContext() != nil ||
message.GetRichText() != "" ||
len(message.GetConversationStateBlobId()) > 0 ||
len(message.GetTextBlobId()) > 0 ||
len(message.GetRichTextBlobId()) > 0
}
func firstNonNilError(err error, fallback error) error {
if err != nil {
return err
}
return fallback
}
+213 -236
View File
@@ -2,7 +2,6 @@
package forwarder package forwarder
import ( import (
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
@@ -20,51 +19,6 @@ const projectedConversationMaxTokens = 130000
type HistoryProjector struct { type HistoryProjector struct {
} }
type CheckpointBlob struct {
ID []byte
Data []byte
}
type CheckpointProjection struct {
State *agentv1.ConversationStateStructure
Blobs []CheckpointBlob
}
type checkpointBlobGraph struct {
blobs map[[sha256.Size]byte][]byte
order [][sha256.Size]byte
}
func newCheckpointBlobGraph() *checkpointBlobGraph {
return &checkpointBlobGraph{blobs: make(map[[sha256.Size]byte][]byte)}
}
func (graph *checkpointBlobGraph) add(data []byte) []byte {
if graph == nil || len(data) == 0 {
return nil
}
id := sha256.Sum256(data)
if _, exists := graph.blobs[id]; !exists {
graph.blobs[id] = append([]byte(nil), data...)
graph.order = append(graph.order, id)
}
return append([]byte(nil), id[:]...)
}
func (graph *checkpointBlobGraph) list() []CheckpointBlob {
if graph == nil || len(graph.order) == 0 {
return nil
}
blobs := make([]CheckpointBlob, 0, len(graph.order))
for _, id := range graph.order {
blobs = append(blobs, CheckpointBlob{
ID: append([]byte(nil), id[:]...),
Data: append([]byte(nil), graph.blobs[id]...),
})
}
return blobs
}
// NewHistoryProjector 创建 history 投影器。 // NewHistoryProjector 创建 history 投影器。
func NewHistoryProjector() *HistoryProjector { func NewHistoryProjector() *HistoryProjector {
return &HistoryProjector{} return &HistoryProjector{}
@@ -521,16 +475,6 @@ func isHistoricalReplayToolResult(conversation *ConversationFile, entry HistoryE
// ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。 // ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。
func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *ConversationFile) (*agentv1.ConversationStateStructure, error) { func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *ConversationFile) (*agentv1.ConversationStateStructure, error) {
projection, err := projector.ProjectCheckpointProjection(conversation)
if err != nil || projection == nil {
return nil, err
}
return projection.State, nil
}
// ProjectCheckpointProjection 同时返回 checkpoint 状态及其引用的内容寻址 Blob。
func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *ConversationFile) (*CheckpointProjection, error) {
blobs := newCheckpointBlobGraph()
state := &agentv1.ConversationStateStructure{ state := &agentv1.ConversationStateStructure{
TokenDetails: &agentv1.ConversationTokenDetails{ TokenDetails: &agentv1.ConversationTokenDetails{
UsedTokens: conversationTokenDetailsUsedTokens(conversation), UsedTokens: conversationTokenDetailsUsedTokens(conversation),
@@ -544,7 +488,7 @@ func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *Con
if conversation == nil { if conversation == nil {
mode := agentv1.AgentMode_AGENT_MODE_AGENT mode := agentv1.AgentMode_AGENT_MODE_AGENT
state.Mode = &mode state.Mode = &mode
return &CheckpointProjection{State: state}, nil return state, nil
} }
mode, err := parseModeAlias(conversation.Mode) mode, err := parseModeAlias(conversation.Mode)
if err != nil { if err != nil {
@@ -562,11 +506,158 @@ func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *Con
if structuredState.HasTodos { if structuredState.HasTodos {
state.Todos = encodeConversationTodoBytes(structuredState.Todos) state.Todos = encodeConversationTodoBytes(structuredState.Todos)
} }
turnIDs, err := projectCheckpointTurnBlobs(conversation, blobs) grouped := make(map[int64][]HistoryEntry)
order := make([]int64, 0, conversation.NextTurnSeq)
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
if entry.TurnSeq <= 0 {
continue
}
if _, ok := grouped[entry.TurnSeq]; !ok {
order = append(order, entry.TurnSeq)
}
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
}
for _, turnSeq := range order {
entries := grouped[turnSeq]
var rawUserMessage []byte
var turnRequestID string
steps := make([][]byte, 0, len(entries))
seenToolCalls := make(map[string]struct{})
openToolCalls := make(map[string]struct{})
for _, entry := range entries {
if turnRequestID == "" {
turnRequestID = strings.TrimSpace(entry.RequestID)
}
switch strings.TrimSpace(entry.Kind) {
case "user_message":
userMessage := &agentv1.UserMessage{}
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
}
payload, err := proto.Marshal(userMessage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
state.Turns = append(cloneByteSlices(conversation.ImportedTurnIDs), turnIDs...) rawUserMessage = payload
case "assistant_text":
var payload assistantTextPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
continue
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
if strings.TrimSpace(payload.Text) == "" {
continue
}
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
Message: &agentv1.ConversationStep_AssistantMessage{
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
},
})
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
case "tool_call":
var payload toolCallEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{
ToolCall: toolCall,
},
})
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
seenToolCalls[toolCallID] = struct{}{}
openToolCalls[toolCallID] = struct{}{}
}
case "tool_result":
var payload toolResultEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
if _, ok := seenToolCalls[toolCallID]; ok {
delete(openToolCalls, toolCallID)
continue
}
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
if len(payload.ToolCall) == 0 {
continue
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{
ToolCall: toolCall,
},
})
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
}
if len(rawUserMessage) == 0 && len(steps) == 0 {
continue
}
agentTurn := &agentv1.AgentConversationTurnStructure{
UserMessage: rawUserMessage,
Steps: steps,
}
if turnRequestID != "" {
agentTurn.RequestId = &turnRequestID
}
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
AgentConversationTurn: agentTurn,
},
})
if err != nil {
return nil, err
}
state.Turns = append(state.Turns, turnPayload)
}
replayMessages, err := projector.ProjectPromptReplay(conversation) replayMessages, err := projector.ProjectPromptReplay(conversation)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -594,183 +685,15 @@ func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *Con
return nil, err return nil, err
} }
state.RootPromptMessagesJson = rootPromptMessages state.RootPromptMessagesJson = rootPromptMessages
return &CheckpointProjection{State: state, Blobs: blobs.list()}, nil return state, nil
} }
func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpointBlobGraph) ([][]byte, error) { func marshalThinkingStep(text string) ([]byte, error) {
if conversation == nil || blobs == nil { return proto.Marshal(&agentv1.ConversationStep{
return nil, nil
}
grouped := make(map[int64][]HistoryEntry)
order := make([]int64, 0, conversation.NextTurnSeq)
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
if entry.TurnSeq <= 0 {
continue
}
if _, ok := grouped[entry.TurnSeq]; !ok {
order = append(order, entry.TurnSeq)
}
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
}
turnIDs := make([][]byte, 0, len(order))
for _, turnSeq := range order {
entries := grouped[turnSeq]
var userMessageID []byte
var turnRequestID string
stepIDs := make([][]byte, 0, len(entries))
seenToolCalls := make(map[string]struct{})
openToolCalls := make(map[string]struct{})
for _, entry := range entries {
if turnRequestID == "" {
turnRequestID = strings.TrimSpace(entry.RequestID)
}
switch strings.TrimSpace(entry.Kind) {
case "user_message":
userMessage := &agentv1.UserMessage{}
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
}
payload, err := proto.Marshal(userMessage)
if err != nil {
return nil, err
}
userMessageID = blobs.add(payload)
case "assistant_text":
var payload assistantTextPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
continue
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{ Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent}, ThinkingMessage: &agentv1.ThinkingMessage{Text: text},
}, },
}) })
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
if strings.TrimSpace(payload.Text) == "" {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_AssistantMessage{
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
case "tool_call":
var payload toolCallEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
seenToolCalls[toolCallID] = struct{}{}
openToolCalls[toolCallID] = struct{}{}
}
case "tool_result":
var payload toolResultEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
if _, ok := seenToolCalls[toolCallID]; ok {
delete(openToolCalls, toolCallID)
continue
}
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
if len(payload.ToolCall) == 0 {
continue
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
}
if len(userMessageID) == 0 && len(stepIDs) == 0 {
continue
}
agentTurn := &agentv1.AgentConversationTurnStructure{
UserMessage: userMessageID,
Steps: stepIDs,
}
if turnRequestID != "" {
agentTurn.RequestId = &turnRequestID
}
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
AgentConversationTurn: agentTurn,
},
})
if err != nil {
return nil, err
}
turnIDs = append(turnIDs, blobs.add(turnPayload))
}
return turnIDs, nil
}
func addCheckpointStepBlob(blobs *checkpointBlobGraph, step *agentv1.ConversationStep) ([]byte, error) {
payload, err := proto.Marshal(step)
if err != nil {
return nil, err
}
return blobs.add(payload), nil
} }
func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 { func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 {
@@ -1218,6 +1141,60 @@ 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
@@ -1254,7 +1231,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
return filtered return filtered
} }
func restoreImportedReplayUserMessages(messages []promptengine.Message, importedTurns [][]byte, blobs importedBlobStore) []promptengine.Message { func restoreImportedReplayUserMessages(messages []promptengine.Message, importedTurns [][]byte) []promptengine.Message {
if len(messages) == 0 || len(importedTurns) == 0 { if len(messages) == 0 || len(importedTurns) == 0 {
return messages return messages
} }
@@ -1263,16 +1240,16 @@ func restoreImportedReplayUserMessages(messages []promptengine.Message, imported
if len(rawTurn) == 0 { if len(rawTurn) == 0 {
continue continue
} }
turn, _, err := decodeImportedTurn(rawTurn, blobs) turn := &agentv1.ConversationTurnStructure{}
if err != nil || turn == nil { if err := proto.Unmarshal(rawTurn, turn); err != nil {
continue continue
} }
agentTurn := turn.GetAgentConversationTurn() agentTurn := turn.GetAgentConversationTurn()
if agentTurn == nil || len(agentTurn.GetUserMessage()) == 0 { if agentTurn == nil || len(agentTurn.GetUserMessage()) == 0 {
continue continue
} }
userMessage, err := decodeImportedUserMessage(agentTurn.GetUserMessage(), blobs) userMessage := &agentv1.UserMessage{}
if err != nil { if err := proto.Unmarshal(agentTurn.GetUserMessage(), userMessage); err != nil {
continue continue
} }
replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage) replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage)
@@ -1,433 +0,0 @@
package forwarder
import (
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"testing"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"cursor/gen/agentv1"
modeladapter "cursor/internal/backend/agent/model"
promptengine "cursor/internal/backend/agent/prompt"
)
func TestProjectCheckpointProjectionBuildsBlobBackedTurns(t *testing.T) {
toolCall := testEditToolCall(t, "file.txt")
tests := []struct {
name string
entries []HistoryEntry
}{
{
name: "no tools",
entries: []HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "hello"),
newAssistantTextEntry(1, "request-1", "hi", "", ""),
},
},
{
name: "completed tool call",
entries: []HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "edit the file"),
newToolCallEntry(1, "request-1", "call-1", "Edit", "", "", toolCall),
newToolResultEntry(1, "request-1", "call-1", "Edit", `{"path":"file.txt"}`, "edited", "", toolCall),
newAssistantTextEntry(1, "request-1", "done", "", ""),
},
},
{
name: "unfinished tool call",
entries: []HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "edit the file"),
newToolCallEntry(1, "request-1", "call-1", "Edit", "", "", toolCall),
},
},
{
name: "orphan tool result",
entries: []HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "edit the file"),
newToolResultEntry(1, "request-1", "call-1", "Edit", `{"path":"file.txt"}`, "edited", "", toolCall),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
conversation := testConversation(test.entries)
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if len(projection.State.GetTurns()) != 1 {
t.Fatalf("ProjectCheckpointProjection() turns = %d, want 1 Blob ID", len(projection.State.GetTurns()))
}
assertCheckpointBlobGraph(t, projection)
messages, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
if err != nil {
t.Fatalf("DecodeReplayMessages() error = %v", err)
}
if len(messages) == 0 {
t.Fatal("ProjectCheckpointProjection() removed all root prompt replay history")
}
if messages[0].Role != "user" || messages[0].Content == "" {
t.Fatalf("first replay message = %#v, want retained user history", messages[0])
}
})
}
}
func TestProjectLegacyCheckpointLargeModelHistoryUsesRootReplay(t *testing.T) {
entries := make([]HistoryEntry, 0, 400)
for turn := int64(1); turn <= 200; turn++ {
requestID := fmt.Sprintf("request-%d", turn)
entries = append(entries,
testModelMessageEntry(t, turn, requestID, modeladapter.Message{Role: "user", Content: fmt.Sprintf("question %d", turn)}),
testModelMessageEntry(t, turn, requestID, modeladapter.Message{Role: "assistant", Content: fmt.Sprintf("answer %d", turn)}),
)
}
state, err := NewHistoryProjector().ProjectLegacyCheckpoint(testConversation(entries))
if err != nil {
t.Fatalf("ProjectLegacyCheckpoint() error = %v", err)
}
if len(state.GetTurns()) != 0 {
t.Fatalf("ProjectLegacyCheckpoint() model-only turns = %d, want 0", len(state.GetTurns()))
}
messages, err := promptengine.DecodeReplayMessages(state.GetRootPromptMessagesJson())
if err != nil {
t.Fatalf("DecodeReplayMessages() error = %v", err)
}
if len(messages) != 400 {
t.Fatalf("decoded replay messages = %d, want 400", len(messages))
}
}
func TestProjectLegacyCheckpointSnapshotIsIsolatedFromLaterHistory(t *testing.T) {
conversation := testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "first question"),
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
})
projector := NewHistoryProjector()
midpointProjection, err := projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("midpoint ProjectCheckpointProjection() error = %v", err)
}
midpoint := midpointProjection.State
appendEntriesInPlace(conversation, []HistoryEntry{
testUserMessageEntry(t, 2, "request-2", "second question"),
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
})
latestProjection, err := projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("latest ProjectCheckpointProjection() error = %v", err)
}
latest := latestProjection.State
midpointMessages, err := promptengine.DecodeReplayMessages(midpoint.GetRootPromptMessagesJson())
if err != nil {
t.Fatalf("decode midpoint replay: %v", err)
}
latestMessages, err := promptengine.DecodeReplayMessages(latest.GetRootPromptMessagesJson())
if err != nil {
t.Fatalf("decode latest replay: %v", err)
}
if len(midpointMessages) != 2 {
t.Fatalf("midpoint replay messages = %d, want 2", len(midpointMessages))
}
if len(latestMessages) != 4 {
t.Fatalf("latest replay messages = %d, want 4", len(latestMessages))
}
if len(midpoint.GetTurns()) != 1 || len(latest.GetTurns()) != 2 {
t.Fatalf("checkpoint turn counts = (%d, %d), want (1, 2)", len(midpoint.GetTurns()), len(latest.GetTurns()))
}
assertCheckpointBlobGraph(t, midpointProjection)
assertCheckpointBlobGraph(t, latestProjection)
}
func TestProjectCheckpointProjectionKeepsVisibleTurnsAcrossCompaction(t *testing.T) {
summaryPayload, err := json.Marshal(compactionSummaryEntryPayload{Summary: "first turn summarized"})
if err != nil {
t.Fatalf("marshal compaction summary: %v", err)
}
conversation := testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "first question"),
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
{TurnSeq: 0, Role: "system", Kind: "compaction_summary", Payload: summaryPayload},
testUserMessageEntry(t, 2, "request-2", "second question"),
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
})
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if len(projection.State.GetTurns()) != 2 {
t.Fatalf("visible turns after compaction = %d, want 2", len(projection.State.GetTurns()))
}
assertCheckpointBlobGraph(t, projection)
messages, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
if err != nil {
t.Fatalf("DecodeReplayMessages() error = %v", err)
}
if len(messages) != 3 {
t.Fatalf("compacted root replay messages = %d, want summary plus latest turn", len(messages))
}
if messages[0].Role != "user" || messages[0].Content != "<conversation_summary>\nfirst turn summarized\n</conversation_summary>" {
t.Fatalf("first compacted replay message = %#v", messages[0])
}
}
func TestImportedConversationStateRejectsBlobTurnIDsWithoutPrefetchedData(t *testing.T) {
turnID := sha256.Sum256([]byte("imported turn"))
state := &agentv1.ConversationStateStructure{Turns: [][]byte{turnID[:]}}
if _, err := importedConversationStateModelMessages(state, nil); err == nil {
t.Fatal("importedConversationStateModelMessages() accepted unresolved Blob turn")
}
conversation := testConversation(nil)
service := &Service{}
if _, err := service.importConversationState(conversation, state, nil); err == nil {
t.Fatal("importConversationState() accepted unresolved Blob turn")
}
}
func TestImportedConversationStateRestoresBlobOnlyForkFromPrefetchedBlobs(t *testing.T) {
projection, err := NewHistoryProjector().ProjectCheckpointProjection(testConversation([]HistoryEntry{
testUserMessageEntry(t, 1, "request-1", "parent question"),
newAssistantTextEntry(1, "request-1", "parent answer", "", ""),
}))
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
prefetched := make([]*agentv1.PreFetchedBlob, 0, len(projection.Blobs))
for _, blob := range projection.Blobs {
prefetched = append(prefetched, &agentv1.PreFetchedBlob{Id: blob.ID, Value: blob.Data})
}
state := proto.Clone(projection.State).(*agentv1.ConversationStateStructure)
state.RootPromptMessagesJson = nil
conversation := testConversation(nil)
entries, err := (&Service{}).importConversationState(conversation, state, prefetched)
if err != nil {
t.Fatalf("importConversationState() error = %v", err)
}
if len(conversation.ImportedTurnIDs) != 1 {
t.Fatalf("ImportedTurnIDs = %d, want 1", len(conversation.ImportedTurnIDs))
}
if len(entries) != 2 {
t.Fatalf("imported model entries = %d, want user and assistant", len(entries))
}
}
func TestImportedInlineTurnWithSHA256LengthIsNotMisclassified(t *testing.T) {
var rawTurn []byte
for size := 1; size <= 128; size++ {
rawUser, err := proto.Marshal(&agentv1.UserMessage{Text: strings.Repeat("x", size), MessageId: "inline"})
if err != nil {
t.Fatalf("marshal user message: %v", err)
}
rawTurn, err = proto.Marshal(&agentv1.ConversationTurnStructure{
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
AgentConversationTurn: &agentv1.AgentConversationTurnStructure{UserMessage: rawUser},
},
})
if err != nil {
t.Fatalf("marshal turn: %v", err)
}
if len(rawTurn) == sha256.Size {
break
}
}
if len(rawTurn) != sha256.Size {
t.Fatal("test could not construct a 32-byte inline turn")
}
ids, err := importedTurnIDs([][]byte{rawTurn}, nil)
if err != nil {
t.Fatalf("importedTurnIDs() error = %v", err)
}
if len(ids) != 0 {
t.Fatal("32-byte inline turn was misclassified as a Blob ID")
}
messages, err := importedConversationStateModelMessages(&agentv1.ConversationStateStructure{Turns: [][]byte{rawTurn}}, nil)
if err != nil {
t.Fatalf("importedConversationStateModelMessages() error = %v", err)
}
if len(messages) != 1 || messages[0].Role != "user" {
t.Fatalf("inline turn messages = %#v, want one user message", messages)
}
}
func TestImportedTurnIDsPersistThroughConversationStore(t *testing.T) {
store := NewConversationFileStore(t.TempDir())
turnID := sha256.Sum256([]byte("parent turn"))
conversation := testConversation(nil)
conversation.ImportedTurnIDs = [][]byte{turnID[:]}
persisted, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{
testUserMessageEntry(t, 2, "request-2", "fork question"),
})
if err != nil {
t.Fatalf("SaveConversationWithEntries() error = %v", err)
}
if len(persisted.ImportedTurnIDs) != 1 || string(persisted.ImportedTurnIDs[0]) != string(turnID[:]) {
t.Fatalf("persisted ImportedTurnIDs = %x, want %x", persisted.ImportedTurnIDs, turnID)
}
loaded, err := store.LoadConversation(conversation.ConversationID)
if err != nil {
t.Fatalf("LoadConversation() error = %v", err)
}
if len(loaded.ImportedTurnIDs) != 1 || string(loaded.ImportedTurnIDs[0]) != string(turnID[:]) {
t.Fatalf("loaded ImportedTurnIDs = %x, want %x", loaded.ImportedTurnIDs, turnID)
}
}
func TestRewindImportedTurnPrefixUsesClientForkPoint(t *testing.T) {
ids := make([][]byte, 4)
for index := range ids {
digest := sha256.Sum256([]byte(fmt.Sprintf("turn-%d", index+1)))
ids[index] = digest[:]
}
trimmed := rewindImportedTurnPrefix(ids, runRewindDecision{
TargetTurnSeq: 4,
HasClientTurnCount: true,
ClientTurnCount: 2,
})
if len(trimmed) != 2 || string(trimmed[0]) != string(ids[0]) || string(trimmed[1]) != string(ids[1]) {
t.Fatalf("rewindImportedTurnPrefix() = %x, want first two IDs", trimmed)
}
}
func TestRewindImportedTurnPrefixClearsAllIDsAtClientTurnZero(t *testing.T) {
ids := make([][]byte, 2)
for index := range ids {
digest := sha256.Sum256([]byte(fmt.Sprintf("turn-%d", index+1)))
ids[index] = digest[:]
}
trimmed := rewindImportedTurnPrefix(ids, runRewindDecision{
TargetTurnSeq: 3,
HasClientTurnCount: true,
ClientTurnCount: 0,
})
if trimmed != nil {
t.Fatalf("rewindImportedTurnPrefix() = %x, want nil at client turn zero", trimmed)
}
}
func TestRewindImportedTurnPrefixUsesTargetWithoutClientCount(t *testing.T) {
ids := make([][]byte, 4)
for index := range ids {
digest := sha256.Sum256([]byte(fmt.Sprintf("turn-%d", index+1)))
ids[index] = digest[:]
}
trimmed := rewindImportedTurnPrefix(ids, runRewindDecision{TargetTurnSeq: 3})
if len(trimmed) != 2 || string(trimmed[0]) != string(ids[0]) || string(trimmed[1]) != string(ids[1]) {
t.Fatalf("rewindImportedTurnPrefix() = %x, want target-derived first two IDs", trimmed)
}
}
func assertCheckpointBlobGraph(t *testing.T, projection *CheckpointProjection) {
t.Helper()
if projection == nil || projection.State == nil {
t.Fatal("checkpoint projection is nil")
}
blobByID := make(map[string][]byte, len(projection.Blobs))
for _, blob := range projection.Blobs {
if len(blob.ID) != sha256.Size {
t.Fatalf("blob id length = %d, want %d", len(blob.ID), sha256.Size)
}
digest := sha256.Sum256(blob.Data)
if string(blob.ID) != string(digest[:]) {
t.Fatal("blob id does not match SHA-256(data)")
}
blobByID[string(blob.ID)] = blob.Data
}
for _, turnID := range projection.State.GetTurns() {
turnData, ok := blobByID[string(turnID)]
if !ok {
t.Fatal("turn references missing blob")
}
turn := &agentv1.ConversationTurnStructure{}
if err := proto.Unmarshal(turnData, turn); err != nil {
t.Fatalf("decode turn blob: %v", err)
}
agentTurn := turn.GetAgentConversationTurn()
if agentTurn == nil {
continue
}
if userID := agentTurn.GetUserMessage(); len(userID) > 0 {
userData, exists := blobByID[string(userID)]
if !exists {
t.Fatal("turn references missing user message blob")
}
if err := proto.Unmarshal(userData, &agentv1.UserMessage{}); err != nil {
t.Fatalf("decode user message blob: %v", err)
}
}
for _, stepID := range agentTurn.GetSteps() {
stepData, exists := blobByID[string(stepID)]
if !exists {
t.Fatal("turn references missing step blob")
}
if err := proto.Unmarshal(stepData, &agentv1.ConversationStep{}); err != nil {
t.Fatalf("decode conversation step blob: %v", err)
}
}
}
}
func testConversation(entries []HistoryEntry) *ConversationFile {
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 1,
NextEntrySeq: 1,
Entries: make([]HistoryEntry, 0, len(entries)),
}
appendEntriesInPlace(conversation, entries)
return conversation
}
func testUserMessageEntry(t *testing.T, turnSeq int64, requestID string, text string) HistoryEntry {
t.Helper()
payload, err := protojson.Marshal(&agentv1.UserMessage{Text: text, MessageId: fmt.Sprintf("message-%d", turnSeq)})
if err != nil {
t.Fatalf("marshal user message: %v", err)
}
return HistoryEntry{
TurnSeq: turnSeq,
RequestID: requestID,
Role: "user",
Kind: "user_message",
Payload: payload,
}
}
func testModelMessageEntry(t *testing.T, turnSeq int64, requestID string, message modeladapter.Message) HistoryEntry {
t.Helper()
entry, ok, err := newModelMessageEntry(turnSeq, requestID, message)
if err != nil {
t.Fatalf("newModelMessageEntry() error = %v", err)
}
if !ok {
t.Fatal("newModelMessageEntry() rejected test message")
}
return entry
}
func testEditToolCall(t *testing.T, path string) []byte {
t.Helper()
payload, err := protojson.Marshal(&agentv1.ToolCall{
Tool: &agentv1.ToolCall_EditToolCall{
EditToolCall: &agentv1.EditToolCall{
Args: &agentv1.EditArgs{Path: path},
},
},
})
if err != nil {
t.Fatalf("marshal edit tool call: %v", err)
}
return payload
}
+2 -23
View File
@@ -36,7 +36,7 @@ type runRewindMatch struct {
} }
func (service *Service) decideRunRewind(intent InboundIntent, conversation *ConversationFile) runRewindDecision { func (service *Service) decideRunRewind(intent InboundIntent, conversation *ConversationFile) runRewindDecision {
decision := runRewindDecision{} decision := runRewindDecision{ClientTurnCount: -1}
if !shouldEvaluateRunRewind(intent) { if !shouldEvaluateRunRewind(intent) {
return decision return decision
} }
@@ -121,7 +121,7 @@ func selectRunRewindMatch(matches []runRewindMatch, clientTurnCount int, hasClie
if len(matches) == 0 { if len(matches) == 0 {
return runRewindMatch{}, "no_match" return runRewindMatch{}, "no_match"
} }
if hasClientTurnCount { if hasClientTurnCount && clientTurnCount >= 0 {
targetTurnSeq := int64(clientTurnCount) + 1 targetTurnSeq := int64(clientTurnCount) + 1
for _, match := range matches { for _, match := range matches {
if match.Entry.TurnSeq == targetTurnSeq { if match.Entry.TurnSeq == targetTurnSeq {
@@ -224,7 +224,6 @@ func (service *Service) applyRunRewindToConversation(conversation *ConversationF
conversation.Entries = nil conversation.Entries = nil
conversation.NextEntrySeq = 1 conversation.NextEntrySeq = 1
conversation.NextTurnSeq = 1 conversation.NextTurnSeq = 1
conversation.ImportedTurnIDs = rewindImportedTurnPrefix(conversation.ImportedTurnIDs, decision)
appendEntriesInPlace(conversation, appendReplacementRunEntries(decision.PrefixEntries, entries)) appendEntriesInPlace(conversation, appendReplacementRunEntries(decision.PrefixEntries, entries))
applyRunRewindConversationState(conversation, intent, turnSeq) applyRunRewindConversationState(conversation, intent, turnSeq)
deriveConversationLoopState(conversation) deriveConversationLoopState(conversation)
@@ -270,30 +269,10 @@ func applyRunRewindMetadata(conversation *ConversationFile, source *Conversation
if source.TokenDetailsMaxTokens > 0 { if source.TokenDetailsMaxTokens > 0 {
conversation.TokenDetailsMaxTokens = source.TokenDetailsMaxTokens conversation.TokenDetailsMaxTokens = source.TokenDetailsMaxTokens
} }
decision := runRewindDecision{TargetTurnSeq: turnSeq}
if intent.ConversationState != nil {
decision.HasClientTurnCount = true
decision.ClientTurnCount = len(intent.ConversationState.GetTurns())
}
conversation.ImportedTurnIDs = rewindImportedTurnPrefix(source.ImportedTurnIDs, decision)
} }
applyRunRewindConversationState(conversation, intent, turnSeq) applyRunRewindConversationState(conversation, intent, turnSeq)
} }
func rewindImportedTurnPrefix(importedTurnIDs [][]byte, decision runRewindDecision) [][]byte {
keep := decision.TargetTurnSeq - 1
if decision.HasClientTurnCount {
keep = int64(decision.ClientTurnCount)
}
if keep <= 0 || len(importedTurnIDs) == 0 {
return nil
}
if keep > int64(len(importedTurnIDs)) {
keep = int64(len(importedTurnIDs))
}
return cloneByteSlices(importedTurnIDs[:keep])
}
func (service *Service) logRunRewindDecision(requestID string, conversationID string, eventName string, decision runRewindDecision) { func (service *Service) logRunRewindDecision(requestID string, conversationID string, eventName string, decision runRewindDecision) {
if service == nil || !decision.Evaluated { if service == nil || !decision.Evaluated {
return return
@@ -50,7 +50,7 @@ func (service *Service) bootstrapRuntimeConversation(intent InboundIntent) (*Con
} }
importedEntries := []HistoryEntry(nil) importedEntries := []HistoryEntry(nil)
if len(conversation.Entries) == 0 && intent.ConversationState != nil { if len(conversation.Entries) == 0 && intent.ConversationState != nil {
importedEntries, err = service.importConversationState(conversation, intent.ConversationState, intent.PreFetchedBlobs) importedEntries, err = service.importConversationState(conversation, intent.ConversationState)
if err != nil { if err != nil {
return nil, agentv1.AgentMode_AGENT_MODE_AGENT, 0, nil, err return nil, agentv1.AgentMode_AGENT_MODE_AGENT, 0, nil, err
} }
@@ -138,7 +138,6 @@ func (service *Service) syncConversationRecord(conversationID string, conversati
item.AutoCompactionReserveTokens = conversation.AutoCompactionReserveTokens item.AutoCompactionReserveTokens = conversation.AutoCompactionReserveTokens
item.AutoCompactionTriggeredAt = conversation.AutoCompactionTriggeredAt item.AutoCompactionTriggeredAt = conversation.AutoCompactionTriggeredAt
item.AutoCompactionSourceModelCallID = conversation.AutoCompactionSourceModelCallID item.AutoCompactionSourceModelCallID = conversation.AutoCompactionSourceModelCallID
item.ImportedTurnIDs = cloneByteSlices(conversation.ImportedTurnIDs)
item.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix) item.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
item.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall) item.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
item.CreatedAt = conversation.CreatedAt item.CreatedAt = conversation.CreatedAt
+31 -77
View File
@@ -9,7 +9,6 @@ import (
"log" "log"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
"connectrpc.com/connect" "connectrpc.com/connect"
@@ -262,8 +261,6 @@ type Service struct {
execBridge execbridge.ExecBridge execBridge execbridge.ExecBridge
interactionBridge interactionbridge.InteractionBridge interactionBridge interactionbridge.InteractionBridge
appendSeq *appendSequenceTracker appendSeq *appendSequenceTracker
checkpointBlobMu sync.Mutex
checkpointBlobs map[string]*checkpointBlobCacheEntry
} }
type agentModelMemory interface { type agentModelMemory interface {
@@ -303,7 +300,6 @@ func NewService(historyRoot string, resolver modeladapter.ChannelResolver) *Serv
execBridge: execbridge.NewBridge(), execBridge: execbridge.NewBridge(),
interactionBridge: interactionbridge.NewBridge(), interactionBridge: interactionbridge.NewBridge(),
appendSeq: newAppendSequenceTracker(), appendSeq: newAppendSequenceTracker(),
checkpointBlobs: make(map[string]*checkpointBlobCacheEntry),
} }
service.startHistoryMaintenance() service.startHistoryMaintenance()
store.SyncAllCursorTranscriptsBestEffort() store.SyncAllCursorTranscriptsBestEffort()
@@ -332,7 +328,6 @@ func newServiceWithDependencies(store *ConversationFileStore, projector *History
execBridge: execbridge.NewBridge(), execBridge: execbridge.NewBridge(),
interactionBridge: interactionbridge.NewBridge(), interactionBridge: interactionbridge.NewBridge(),
appendSeq: newAppendSequenceTracker(), appendSeq: newAppendSequenceTracker(),
checkpointBlobs: make(map[string]*checkpointBlobCacheEntry),
} }
} }
@@ -562,7 +557,6 @@ func (service *Service) decodeInboundIntent(requestID string, message *agentv1.A
} }
intent.ConversationID = conversationID intent.ConversationID = conversationID
intent.ConversationState = runRequest.GetConversationState() intent.ConversationState = runRequest.GetConversationState()
intent.PreFetchedBlobs = runRequest.GetPreFetchedBlobs()
intent.UserMessage = extractUserMessage(message) intent.UserMessage = extractUserMessage(message)
intent.RequestContext = extractRequestContext(message) intent.RequestContext = extractRequestContext(message)
if service.shouldIgnoreEmptyResumeRunRequest(requestID, runRequest, intent.UserMessage, intent.RequestContext) { if service.shouldIgnoreEmptyResumeRunRequest(requestID, runRequest, intent.UserMessage, intent.RequestContext) {
@@ -610,7 +604,6 @@ func (service *Service) decodeInboundIntent(requestID string, message *agentv1.A
intent.ConversationID = conversationID intent.ConversationID = conversationID
intent.SubagentTypeName = strings.TrimSpace(prewarmRequest.GetSubagentTypeName()) intent.SubagentTypeName = strings.TrimSpace(prewarmRequest.GetSubagentTypeName())
intent.ConversationState = prewarmRequest.GetConversationState() intent.ConversationState = prewarmRequest.GetConversationState()
intent.PreFetchedBlobs = prewarmRequest.GetPreFetchedBlobs()
intent.Mode, intent.ModeSource, intent.HasExplicitMode, err = extractPrewarmMode(prewarmRequest) intent.Mode, intent.ModeSource, intent.HasExplicitMode, err = extractPrewarmMode(prewarmRequest)
if err != nil { if err != nil {
return InboundIntent{}, err return InboundIntent{}, err
@@ -826,14 +819,11 @@ func (service *Service) snapshotVisibleTurns(conversation *ConversationFile) ([]
if service == nil || service.projector == nil || conversation == nil { if service == nil || service.projector == nil || conversation == nil {
return nil, nil return nil, nil
} }
projection, err := service.projector.ProjectCheckpointProjection(conversation) state, err := service.projector.ProjectLegacyCheckpoint(conversation)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if projection == nil || projection.State == nil { return cloneByteSlices(state.GetTurns()), nil
return nil, fmt.Errorf("checkpoint projection is empty")
}
return cloneByteSlices(projection.State.GetTurns()), nil
} }
// handleCancelIntent 处理取消请求,并向客户端发送执行桥 abort。 // handleCancelIntent 处理取消请求,并向客户端发送执行桥 abort。
@@ -843,60 +833,42 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
return fmt.Errorf("request is not active: %s", intent.RequestID) return fmt.Errorf("request is not active: %s", intent.RequestID)
} }
hasCheckpoint := checkpointConversationInitialized(stream) hasCheckpoint := checkpointConversationInitialized(stream)
if hasCheckpoint {
cancelReason := firstNonEmpty(intent.CancelReason, "user aborted")
_, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{
newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
"status": "canceled",
"reason": cancelReason,
"replay_policy": cancelReplayPolicyForReason(cancelReason),
}),
})
if err != nil {
return err
}
}
stream.mu.Lock() stream.mu.Lock()
pendingExecs := make([]runtimecore.PendingExec, 0, len(stream.PendingExecs)) pendingExecs := make([]runtimecore.PendingExec, 0, len(stream.PendingExecs))
for _, pending := range stream.PendingExecs { for _, pending := range stream.PendingExecs {
pendingExecs = append(pendingExecs, pending) pendingExecs = append(pendingExecs, pending)
} }
if stream.ProviderCancel != nil {
stream.ProviderCancel()
stream.ProviderCancel = nil
}
stream.ProviderActive = false
stream.CurrentProviderToken++
stream.CurrentCompactionToken++
stream.PendingProviderAction = providerActionNone
stream.PendingCompaction = nil
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock() stream.mu.Unlock()
if hasCheckpoint {
cancelReason := firstNonEmpty(intent.CancelReason, "user aborted")
cancelEntry := newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
"status": "canceled",
"reason": cancelReason,
"replay_policy": cancelReplayPolicyForReason(cancelReason),
})
if _, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{cancelEntry}); err != nil {
log.Printf("forwarder cancellation metadata persistence failed request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, err)
if memoryErr := service.appendCheckpointEntries(stream, []HistoryEntry{cancelEntry}); memoryErr != nil {
return memoryErr
}
}
}
for _, pending := range pendingExecs { for _, pending := range pendingExecs {
_ = service.broker.Publish(intent.RequestID, StreamEvent{ _ = service.broker.Publish(intent.RequestID, StreamEvent{
Message: buildExecAbortMessage(pending), Message: buildExecAbortMessage(pending),
}) })
} }
if hasCheckpoint {
if err := service.publishCheckpoint(stream.RequestID, stream.ConversationID); err != nil {
return err
}
}
clearPendingProviderCompletion(stream) clearPendingProviderCompletion(stream)
terminalMessage := firstNonEmpty(intent.CancelReason, "[canceled] User aborted request")
stream.mu.Lock() stream.mu.Lock()
stream.PendingExecs = make(map[string]runtimecore.PendingExec) stream.PendingProviderAction = providerActionNone
stream.PendingInteractions = make(map[string]runtimecore.PendingInteraction)
stream.UpdatedAt = time.Now().UTC() stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock() stream.mu.Unlock()
service.discardPendingCheckpoint(stream, fmt.Errorf("checkpoint superseded by cancellation")) service.setTurnPhase(stream, TurnPhaseCanceled)
if hasCheckpoint { return service.broker.Cancel(intent.RequestID, firstNonEmpty(intent.CancelReason, "[canceled] User aborted request"))
if err := service.publishCheckpointWithTerminalAction(
stream.RequestID,
stream.ConversationID,
checkpointCancellationAction(terminalMessage),
); err != nil {
return service.failTerminalCheckpointSync(stream, err)
}
return nil
}
return service.finishCanceledTurnAfterCheckpoint(stream, terminalMessage)
} }
// handleExecResult 处理客户端返回的执行桥结果,并在终态时把 tool_result 写回 history。 // handleExecResult 处理客户端返回的执行桥结果,并在终态时把 tool_result 写回 history。
@@ -2150,18 +2122,9 @@ func (service *Service) completeSuccessfulTurn(stream *ActiveStream, completion
err, err,
) )
} }
if err := service.publishCheckpointWithCompletion(requestID, conversationID, &completion); err != nil { if err := service.publishCheckpoint(requestID, conversationID); err != nil {
return err return err
} }
return nil
}
func (service *Service) finishSuccessfulTurnAfterCheckpoint(stream *ActiveStream, completion pendingTurnCompletion) error {
if stream == nil {
return nil
}
requestID := firstNonEmpty(strings.TrimSpace(completion.RequestID), strings.TrimSpace(stream.RequestID))
usage := completion.Usage
if err := service.broker.Publish(requestID, StreamEvent{ if err := service.broker.Publish(requestID, StreamEvent{
Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens), Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens),
}); err != nil { }); err != nil {
@@ -2188,15 +2151,7 @@ func (service *Service) failStreamIfNonTerminal(stream *ActiveStream, terminalCo
} }
// publishCheckpoint 按当前内存会话镜像投影出 checkpoint,并广播给所有 RunSSE 订阅者。 // publishCheckpoint 按当前内存会话镜像投影出 checkpoint,并广播给所有 RunSSE 订阅者。
func (service *Service) publishCheckpoint(requestID string, conversationID string) error { func (service *Service) publishCheckpoint(requestID string, _ string) error {
return service.publishCheckpointWithCompletion(requestID, conversationID, nil)
}
func (service *Service) publishCheckpointWithCompletion(requestID string, conversationID string, completion *pendingTurnCompletion) error {
return service.publishCheckpointWithTerminalAction(requestID, conversationID, checkpointCompletionAction(completion))
}
func (service *Service) publishCheckpointWithTerminalAction(requestID string, conversationID string, terminalAction checkpointTerminalAction) error {
stream, ok := service.broker.Get(requestID) stream, ok := service.broker.Get(requestID)
if !ok || stream == nil { if !ok || stream == nil {
return fmt.Errorf("request is not active: %s", requestID) return fmt.Errorf("request is not active: %s", requestID)
@@ -2205,16 +2160,15 @@ func (service *Service) publishCheckpointWithTerminalAction(requestID string, co
if err != nil { if err != nil {
return err return err
} }
projection, err := service.projector.ProjectCheckpointProjection(conversation) state, err := service.projector.ProjectLegacyCheckpoint(conversation)
if err != nil { if err != nil {
return err return err
} }
if projection == nil || projection.State == nil { state.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
return fmt.Errorf("checkpoint projection is empty") service.rewriteCheckpointTokenDetailsForClient(stream, conversation, state)
} return service.broker.Publish(requestID, StreamEvent{
projection.State.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions) Message: buildCheckpointMessage(state),
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, projection.State) })
return service.queueCheckpointProjection(stream, projection, terminalAction)
} }
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) { func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
+30 -25
View File
@@ -45,25 +45,13 @@ func (snapshot turnUsageSnapshot) requestTokensTotal() int64 {
return snapshot.promptTokensTotal() + nonNegativeInt64(snapshot.OutputTokens) return snapshot.promptTokensTotal() + nonNegativeInt64(snapshot.OutputTokens)
} }
func (service *Service) importConversationState(item *ConversationFile, state *agentv1.ConversationStateStructure, prefetchedBlobs []*agentv1.PreFetchedBlob) ([]HistoryEntry, error) { func (service *Service) importConversationState(item *ConversationFile, state *agentv1.ConversationStateStructure) ([]HistoryEntry, error) {
if item == nil || state == nil { if item == nil || state == nil {
return nil, nil return nil, nil
} }
blobs, err := newImportedBlobStore(prefetchedBlobs)
if err != nil {
return nil, err
}
importedIDs, err := importedTurnIDs(state.GetTurns(), blobs)
if err != nil {
return nil, err
}
item.TokenDetailsUsedTokens = state.GetTokenDetails().GetUsedTokens() item.TokenDetailsUsedTokens = state.GetTokenDetails().GetUsedTokens()
item.ImportedTurnIDs = importedIDs
if minimumNextTurnSeq := int64(len(item.ImportedTurnIDs)) + 1; item.NextTurnSeq < minimumNextTurnSeq {
item.NextTurnSeq = minimumNextTurnSeq
}
entries := make([]HistoryEntry, 0, 2) entries := make([]HistoryEntry, 0, 2)
if messages, err := importedConversationStateModelMessages(state, blobs); err != nil { if messages, err := importedConversationStateModelMessages(state); err != nil {
return nil, err return nil, err
} else { } else {
for _, message := range messages { for _, message := range messages {
@@ -116,7 +104,7 @@ func (service *Service) importConversationState(item *ConversationFile, state *a
return entries, nil return entries, nil
} }
func importedConversationStateModelMessages(state *agentv1.ConversationStateStructure, blobs importedBlobStore) ([]modeladapter.Message, error) { func importedConversationStateModelMessages(state *agentv1.ConversationStateStructure) ([]modeladapter.Message, error) {
if state == nil { if state == nil {
return nil, nil return nil, nil
} }
@@ -125,7 +113,7 @@ func importedConversationStateModelMessages(state *agentv1.ConversationStateStru
if err != nil { if err != nil {
return nil, fmt.Errorf("decode imported replay messages: %w", err) return nil, fmt.Errorf("decode imported replay messages: %w", err)
} }
decoded = restoreImportedReplayUserMessages(decoded, state.GetTurns(), blobs) decoded = restoreImportedReplayUserMessages(decoded, state.GetTurns())
decoded = filterLegacyPlainWriteReplay(decoded) decoded = filterLegacyPlainWriteReplay(decoded)
decoded = filterInternalPromptContextReplay(decoded) decoded = filterInternalPromptContextReplay(decoded)
messages := make([]modeladapter.Message, 0, len(decoded)) messages := make([]modeladapter.Message, 0, len(decoded))
@@ -145,18 +133,35 @@ func importedConversationStateModelMessages(state *agentv1.ConversationStateStru
if len(rawTurn) == 0 { if len(rawTurn) == 0 {
continue continue
} }
turn, turnID, err := decodeImportedTurn(rawTurn, blobs) turn := &agentv1.ConversationTurnStructure{}
if err != nil { if err := proto.Unmarshal(rawTurn, turn); err != nil {
return nil, err return nil, fmt.Errorf("decode imported turn: %w", err)
} }
if turn == nil && len(turnID) > 0 { agentTurn := turn.GetAgentConversationTurn()
return nil, fmt.Errorf("missing prefetched turn blob %x", turnID) if agentTurn == nil {
continue
}
if rawUser := agentTurn.GetUserMessage(); len(rawUser) > 0 {
userMessage := &agentv1.UserMessage{}
if err := proto.Unmarshal(rawUser, userMessage); err != nil {
return nil, fmt.Errorf("decode imported turn user_message: %w", err)
}
if replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage); ok {
messages = append(messages, toModelMessage(replay))
}
}
for _, rawStep := range agentTurn.GetSteps() {
if len(rawStep) == 0 {
continue
}
step := &agentv1.ConversationStep{}
if err := proto.Unmarshal(rawStep, step); err != nil {
return nil, fmt.Errorf("decode imported turn step: %w", err)
}
for _, replay := range promptengine.BuildLegacyMessagesFromConversationStep(step) {
messages = append(messages, toModelMessage(replay))
} }
turnMessages, err := importedBlobTurnMessages(turn, blobs)
if err != nil {
return nil, err
} }
messages = append(messages, turnMessages...)
} }
return normalizeReplayMessageSequence(messages), nil return normalizeReplayMessageSequence(messages), nil
} }
-33
View File
@@ -39,7 +39,6 @@ type ConversationFile struct {
CurrentPlanText string `json:"current_plan_text,omitempty"` CurrentPlanText string `json:"current_plan_text,omitempty"`
CurrentPlans map[string]*agentv1.PlanRegistryEntry `json:"current_plans,omitempty"` CurrentPlans map[string]*agentv1.PlanRegistryEntry `json:"current_plans,omitempty"`
CurrentTodos []*agentv1.TodoItem `json:"current_todos,omitempty"` CurrentTodos []*agentv1.TodoItem `json:"current_todos,omitempty"`
ImportedTurnIDs [][]byte `json:"imported_turn_ids,omitempty"`
LatestRequestPrefix *ConversationRequestPrefix `json:"latest_request_prefix,omitempty"` LatestRequestPrefix *ConversationRequestPrefix `json:"latest_request_prefix,omitempty"`
LastProviderCall *ConversationProviderCall `json:"last_provider_call,omitempty"` LastProviderCall *ConversationProviderCall `json:"last_provider_call,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -164,11 +163,6 @@ type ActiveStream struct {
ProviderUsage turnUsageSnapshot ProviderUsage turnUsageSnapshot
ProviderTerminalToolInvocation bool ProviderTerminalToolInvocation bool
PendingCompaction *PendingCompaction PendingCompaction *PendingCompaction
PendingCheckpointBlobWrites map[uint32]pendingCheckpointBlobWrite
PendingCheckpointBlobRequests map[string]uint32
NextCheckpointBlobRequestID uint32
NextCheckpointRevision uint64
PendingCheckpoint *pendingCheckpointPublish
Backlog []StreamEvent Backlog []StreamEvent
Subscribers map[string]*StreamSubscriber Subscribers map[string]*StreamSubscriber
@@ -225,32 +219,6 @@ type pendingTurnCompletion struct {
Disposition pendingCompletionDisposition Disposition pendingCompletionDisposition
} }
type pendingCheckpointBlobWrite struct {
Key string
Revision uint64
}
type checkpointTerminalActionKind uint8
const (
checkpointTerminalActionNone checkpointTerminalActionKind = iota
checkpointTerminalActionComplete
checkpointTerminalActionCancel
)
type checkpointTerminalAction struct {
kind checkpointTerminalActionKind
completion pendingTurnCompletion
cancelMessage string
}
type pendingCheckpointPublish struct {
Revision uint64
State *agentv1.ConversationStateStructure
Required map[string]struct{}
TerminalAction checkpointTerminalAction
}
type PendingCompaction struct { type PendingCompaction struct {
Trigger string Trigger string
ContextTokens int64 ContextTokens int64
@@ -450,7 +418,6 @@ type InboundIntent struct {
SubagentTypeName string SubagentTypeName string
SubagentModelOverrides map[string]runtimecore.SubagentModelOverrideSelection SubagentModelOverrides map[string]runtimecore.SubagentModelOverrideSelection
ConversationState *agentv1.ConversationStateStructure ConversationState *agentv1.ConversationStateStructure
PreFetchedBlobs []*agentv1.PreFetchedBlob
UserMessage *agentv1.UserMessage UserMessage *agentv1.UserMessage
RequestContext *agentv1.RequestContext RequestContext *agentv1.RequestContext
ClientMessage *agentv1.AgentClientMessage ClientMessage *agentv1.AgentClientMessage
+2500 -739
View File
File diff suppressed because it is too large Load Diff
+10898 -4350
View File
File diff suppressed because it is too large Load Diff
+205 -39
View File
@@ -113,10 +113,14 @@ var (
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$.]+)`)
shorthandTRe = regexp.MustCompile(`(?:^|[,\{])\s*T\s*(?:[,\}])`)
oneofNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) oneofNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
fieldNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) fieldNameRe = 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*;`) 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*;`)
varAliasRe = regexp.MustCompile(`\b(?:let|const|var)\s+([\w$]+)\s*=\s*([\w$]+)\s*(?:[,;])`) varAliasRe = regexp.MustCompile(`\b(?:let|const|var)\s+([\w$]+)\s*=\s*([\w$]+)\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*;`) 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*;`) shellStdoutRe = regexp.MustCompile(`(?s)message\s+ShellStream\s*\{.*?ShellStreamStdout\s+stdout\s*=\s*1\s*;`)
) )
@@ -186,12 +190,17 @@ type symbolDef struct {
type TypeResolver struct { type TypeResolver struct {
bySymbol map[string][]symbolDef bySymbol map[string][]symbolDef
byAlias map[string][]symbolDef
byShort 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)
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
}
for moduleStart, direct := range directByModule {
for alias := range direct { for alias := range direct {
root := resolveRoot(alias) root := resolveRoot(direct, alias)
if root == 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])
}
}
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])
} }
for root := range aliases {
sort.Strings(aliases[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)
+72
View File
@@ -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 -15
View File
@@ -2,22 +2,8 @@
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="$(
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" 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}"
@@ -57,7 +43,8 @@ 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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+809
View File
@@ -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 -4
View File
@@ -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