diff --git a/.agents/skills/coding-guidance/SKILL.md b/.agents/skills/coding-guidance/SKILL.md index 9bbc9b1..09dcabd 100644 --- a/.agents/skills/coding-guidance/SKILL.md +++ b/.agents/skills/coding-guidance/SKILL.md @@ -342,3 +342,15 @@ go run ./cmd/cursor-proxy-debugger - `PendingInteraction` - 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending - backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history//state.json` + `history//context.json` + +### 5.1 checkpoint 投影必须幂等且只有一个事实源 + +- 把 checkpoint 当作 `state.json + context.json` 的纯投影,不要把它写成第二套语义历史。 +- 不要创建或维护 `checkpoint.json`、checkpoint history、独立 checkpoint entry 序列等持久化事实源。 +- 允许在当前 stream 内存中保留 latest checkpoint 供 retry/resume 使用;进程重启后必须能从唯一事实源重新投影。 +- 对同一份 semantic history 重复投影时,要求 state、turn 顺序、blob ID 和 blob 内容在语义上完全一致;投影函数不得修改输入 history。 +- 把重复发送视为同一快照的幂等覆盖,不要追加一条新的会话历史;内容寻址 blob 的重复写入必须可安全忽略。 +- 将 `turns` 投影为 UI 可恢复的完整结构,保留所有需要展示的 `ThinkingMessage`、`ToolCall` 和工具结果;不要为了模型 prompt 过滤而删除 UI step。 +- 将 `root_prompt_messages_json` 单独投影为模型 replay;只在这条投影上应用 provider/context 过滤,不能反向改变 `turns`。 +- 将工具完成结果合并回同一 `ToolCall`,保留开始态的 `args`、调用 ID 和开始时间,再补齐 `result` 与完成时间;不要制造协议不存在的独立 `ToolResult` step。 +- 用 TDD 覆盖至少这些性质:重复投影相等、投影不修改 history、开始态字段在结果合并后仍存在、UI turns 保留思考/工具内容而模型 replay 仍遵守独立过滤规则。 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8ecf8b4..bb1cc33 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,9 +10,3 @@ -## 检查清单 / Checklist - -- [ ] 代码已通过 `gofmt` 和 `go vet` / Code passes `gofmt` and `go vet` -- [ ] 前端构建无报错 / Frontend builds without errors -- [ ] 新增 UI 文案已同步所有 locale 文件 / New UI strings synced to all locale files -- [ ] 提交信息符合 Conventional Commits 规范 / Commit messages follow Conventional Commits \ No newline at end of file diff --git a/.gitignore b/.gitignore index 56f163f..acc7eda 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ server-go/log/ .cursor-local-assistant .cursor-local-assistant-v2 .cursor-app-formatted/ +proto/extensions-cursor-app/ ads-server-linux-amd64.tar cmd/ads-server/*.db cmd/ads-server/*.db-* diff --git a/build/Taskfile.yml b/build/Taskfile.yml index e88106d..f3b067c 100644 --- a/build/Taskfile.yml +++ b/build/Taskfile.yml @@ -13,8 +13,8 @@ tasks: preconditions: - sh: 'test -z "{{.PROTO_INPUT}}" || test -f "{{.PROTO_INPUT}}"' 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' - msg: "未找到 Cursor 扩展 bundle;请传入 PROTO_INPUT=/path/to/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。" cmds: - chmod +x ./proto/extract_extensions_proto.sh - '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}' @@ -29,9 +29,16 @@ tasks: - cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto - perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto - perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto + - ./proto/check_proto_sync.sh - rm -rf ./gen/agentv1 ./gen/aiserverv1 - task: generate:proto + check:proto: + summary: 检查根 proto 与扩展提取快照是否一致 + dir: '{{.ROOT_DIR}}' + cmds: + - ./proto/check_proto_sync.sh + generate:proto: summary: 生成 proto Go/Connect 代码 dir: '{{.ROOT_DIR}}' diff --git a/cursor-proxy-debugger/README.en.md b/cursor-proxy-debugger/README.en.md index 377b450..934dcd9 100644 --- a/cursor-proxy-debugger/README.en.md +++ b/cursor-proxy-debugger/README.en.md @@ -2,7 +2,7 @@ [中文](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 @@ -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. - `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`. +- 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`. - 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. diff --git a/cursor-proxy-debugger/README.md b/cursor-proxy-debugger/README.md index 3c39f29..934c0e1 100644 --- a/cursor-proxy-debugger/README.md +++ b/cursor-proxy-debugger/README.md @@ -2,7 +2,7 @@ [中文](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 流量直接透传。 - `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。 - `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。 +- Fork Chat 相关的 `ForkBackgroundComposer`、`NotifyConversationClone` 和 `UploadConversationBlobs` 会双向解码为 protobuf JSON。 +- 本地 Fork Chat 主要在客户端完成,只有启用克隆 blob 同步且隐私设置允许时才会产生 `NotifyConversationClone` 和 `UploadConversationBlobs` 流量。 - 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。 - 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。 - 抓包只保留在当前进程内存中;关闭进程后消失。 diff --git a/cursor-proxy-debugger/decode.go b/cursor-proxy-debugger/decode.go index fa8b136..470716f 100644 --- a/cursor-proxy-debugger/decode.go +++ b/cursor-proxy-debugger/decode.go @@ -20,6 +20,13 @@ import ( 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 { buffer []byte messageType string @@ -140,10 +147,9 @@ func decompressPayload(payload []byte, codec string) ([]byte, error) { return decoded, nil } -func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) { - var message proto.Message +func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) { switch path { - case "/aiserver.v1.BidiService/BidiAppend": + case bidiAppendPath: request := &aiserverv1.BidiAppendRequest{} if err := proto.Unmarshal(payload, request); err != nil { return "", "", "", err @@ -165,13 +171,65 @@ func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, } formatted, marshalErr := json.MarshalIndent(combined, "", " ") return string(formatted), clientKind, requestID, marshalErr - default: - message = nil } + message, kind := unaryRequestMessage(path) if message == 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 { diff --git a/cursor-proxy-debugger/decode_test.go b/cursor-proxy-debugger/decode_test.go new file mode 100644 index 0000000..61e8faf --- /dev/null +++ b/cursor-proxy-debugger/decode_test.go @@ -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() +} diff --git a/cursor-proxy-debugger/proxy.go b/cursor-proxy-debugger/proxy.go index 11d15d7..21a53c3 100644 --- a/cursor-proxy-debugger/proxy.go +++ b/cursor-proxy-debugger/proxy.go @@ -226,28 +226,29 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy. if id == "" || response == nil { 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) { exchange.Status = response.StatusCode exchange.State = "streaming" exchange.DurationMS = elapsedMS(exchange.StartedAt) exchange.Response.Headers = sortedHeaders(response.Header) exchange.Response.ContentType = response.Header.Get("Content-Type") - exchange.Response.ContentCodec = responseContentCodec(response.Header) + exchange.Response.ContentCodec = responseCodec }) if response.Body == nil { - server.finishResponseBody(id, nil, 0, false, nil) + server.finishResponseBody(id, path, responseCodec, nil, 0, false, nil) return response } - path := "" - if response.Request != nil && response.Request.URL != nil { - path = response.Request.URL.Path - } var frameDecoder *connectFrameDecoder if path == "/agent.v1.AgentService/RunSSE" { frameDecoder = newConnectFrameDecoder( "agent.v1.AgentServerMessage", - response.Header.Get("Connect-Content-Encoding"), + responseCodec, server.config.MaxFrames, func(frame FrameView) { server.appendResponseFrame(id, frame) }, ) @@ -264,7 +265,7 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy. if frameDecoder != nil { frameDecoder.Close() } - server.finishResponseBody(id, captured, size, truncated, readErr) + server.finishResponseBody(id, path, responseCodec, captured, size, truncated, readErr) }, ) 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) { decodePayload := captured var contentDecodeErr error - if path == "/aiserver.v1.BidiService/BidiAppend" && truncated { + if decodesUnaryRequest(path) && truncated { 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) } decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr if decodeErr == nil { - decodedJSON, kind, requestID, decodeErr = decodeUnary(path, decodePayload) + decodedJSON, kind, requestID, decodeErr = decodeUnaryRequest(path, decodePayload) } server.store.update(id, func(exchange *Exchange) { exchange.RequestBytes = size @@ -312,19 +313,44 @@ func requestContentCodec(path string, headers http.Header) string { return strings.TrimSpace(headers.Get("Content-Encoding")) } -func responseContentCodec(headers http.Header) string { - if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" { - return codec +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 != "" { + return codec + } } 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) { exchange.ResponseBytes = size exchange.Response.Size = size exchange.Response.RawHex = rawHex(captured) 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.State = "completed" if readErr != nil && !errors.Is(readErr, io.EOF) { diff --git a/cursor-proxy-debugger/web/app.js b/cursor-proxy-debugger/web/app.js index b854047..9181368 100644 --- a/cursor-proxy-debugger/web/app.js +++ b/cursor-proxy-debugger/web/app.js @@ -138,6 +138,7 @@ function filteredExchanges() { .filter((item) => { if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false; if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false; + if (state.endpoint === "fork" && !isForkTrafficPath(item.path)) return false; if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false; if (!query) return true; return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)] @@ -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() { const exchanges = filteredExchanges(); elements.requestCount.textContent = t("count.requests", { count: exchanges.length }); diff --git a/cursor-proxy-debugger/web/i18n.js b/cursor-proxy-debugger/web/i18n.js index ebfd5fd..ec79089 100644 --- a/cursor-proxy-debugger/web/i18n.js +++ b/cursor-proxy-debugger/web/i18n.js @@ -22,6 +22,7 @@ const messages = { "filters.requestIdPlaceholder": "按 Request ID 过滤", "filters.endpoint": "接口过滤", "filters.all": "全部", + "filters.fork": "Fork", "filters.sort": "排序方向", "filters.ascending": "正序", "filters.descending": "倒序", @@ -81,6 +82,7 @@ const messages = { "filters.requestIdPlaceholder": "Filter by Request ID", "filters.endpoint": "Endpoint filter", "filters.all": "All", + "filters.fork": "Fork", "filters.sort": "Sort order", "filters.ascending": "Oldest first", "filters.descending": "Newest first", diff --git a/cursor-proxy-debugger/web/index.html b/cursor-proxy-debugger/web/index.html index 34eddb4..c9b2dfe 100644 --- a/cursor-proxy-debugger/web/index.html +++ b/cursor-proxy-debugger/web/index.html @@ -46,6 +46,7 @@ +
diff --git a/frontend/package.json b/frontend/package.json index 6a1ff35..59027fb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "copy-text-to-clipboard": "^3.2.2", "dayjs": "^1.11.20", "resize-observer-polyfill": "^1.5.1", + "sortablejs": "^1.15.7", "vue": "^3.5.22", "vue-chartjs": "^5.3.3", "vue-router": "^4.6.3" diff --git a/frontend/src/components/AdModelProvider.vue b/frontend/src/components/AdModelProvider.vue index 20e9c1f..f4e3aa2 100644 --- a/frontend/src/components/AdModelProvider.vue +++ b/frontend/src/components/AdModelProvider.vue @@ -196,6 +196,7 @@ onBeforeUnmount(() => {