From 17f30a88521518554aecca299c05dda26cfb649f Mon Sep 17 00:00:00 2001 From: DedSecer Date: Tue, 28 Jul 2026 10:46:07 +0800 Subject: [PATCH 1/9] fix(prompt): replay selected Cursor commands Include client-resolved command content in model-visible user history so slash commands such as /init retain their actual instructions. --- internal/backend/agent/prompt/replay.go | 31 ++++++++++- internal/backend/agent/prompt/replay_test.go | 58 ++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/internal/backend/agent/prompt/replay.go b/internal/backend/agent/prompt/replay.go index baf86f6..68560b7 100644 --- a/internal/backend/agent/prompt/replay.go +++ b/internal/backend/agent/prompt/replay.go @@ -26,10 +26,13 @@ func BuildUserMessageReplayMessage(userMessage *agentv1.UserMessage) (Message, b func buildUserReplayMessage(text string, selectedContext *agentv1.SelectedContext) (Message, bool) { images := buildSelectedImageContentParts(selectedContext) - sections := make([]string, 0, 4) + sections := make([]string, 0, 5) if text != "" { sections = append(sections, formatMessageText(fmt.Sprintf("\n%s\n", text))) } + if cursorCommands := buildSelectedCursorCommandsPromptSection(selectedContext); cursorCommands != "" { + sections = append(sections, cursorCommands) + } if ideState := buildSelectedIDEStatePromptSection(selectedContext); ideState != "" { sections = append(sections, ideState) } @@ -62,6 +65,32 @@ func buildUserReplayMessage(text string, selectedContext *agentv1.SelectedContex }, true } +func buildSelectedCursorCommandsPromptSection(selectedContext *agentv1.SelectedContext) string { + if selectedContext == nil || len(selectedContext.GetCursorCommands()) == 0 { + return "" + } + entries := make([]string, 0, len(selectedContext.GetCursorCommands())) + for _, command := range selectedContext.GetCursorCommands() { + if command == nil { + continue + } + content := strings.TrimSpace(command.GetContent()) + if content == "" { + continue + } + name := strings.TrimSpace(command.GetName()) + if name == "" { + entries = append(entries, "\n"+content+"\n") + continue + } + entries = append(entries, fmt.Sprintf("\n%s\n", escapePromptXML(name), content)) + } + if len(entries) == 0 { + return "" + } + return "\n" + strings.Join(entries, "\n\n") + "\n" +} + func buildSelectedIDEStatePromptSection(selectedContext *agentv1.SelectedContext) string { if selectedContext == nil || selectedContext.GetInvocationContext() == nil { return "" diff --git a/internal/backend/agent/prompt/replay_test.go b/internal/backend/agent/prompt/replay_test.go index 04abd85..fc91b90 100644 --- a/internal/backend/agent/prompt/replay_test.go +++ b/internal/backend/agent/prompt/replay_test.go @@ -2,9 +2,67 @@ package promptengine import ( "reflect" + "strings" "testing" + + "cursor/gen/agentv1" ) +func TestBuildUserMessageReplayMessageIncludesSelectedCursorCommands(t *testing.T) { + message, ok := BuildUserMessageReplayMessage(&agentv1.UserMessage{ + Text: "/init", + SelectedContext: &agentv1.SelectedContext{ + CursorCommands: []*agentv1.SelectedCursorCommand{ + {Name: "init", Content: "Analyze the repository and create AGENTS.md."}, + {Name: `review"<&`, Content: "Review the implementation."}, + }, + }, + }) + if !ok { + t.Fatal("BuildUserMessageReplayMessage() returned ok=false") + } + + want := strings.Join([]string{ + "\n/init\n", + "\n" + + "\nAnalyze the repository and create AGENTS.md.\n\n\n" + + "\nReview the implementation.\n\n" + + "", + }, "\n\n") + if message.Role != "user" || message.Content != want { + t.Fatalf("message = %#v, want content %q", message, want) + } +} + +func TestBuildUserMessageReplayMessageSkipsEmptyCursorCommandsAndKeepsOrder(t *testing.T) { + message, ok := BuildUserMessageReplayMessage(&agentv1.UserMessage{ + Text: "run commands", + SelectedContext: &agentv1.SelectedContext{ + CursorCommands: []*agentv1.SelectedCursorCommand{ + nil, + {Name: "empty", Content: " "}, + {Content: "First command."}, + {Name: "second", Content: "Second command."}, + }, + }, + }) + if !ok { + t.Fatal("BuildUserMessageReplayMessage() returned ok=false") + } + + first := strings.Index(message.Content, "First command.") + second := strings.Index(message.Content, "Second command.") + if first < 0 || second < 0 || first >= second { + t.Fatalf("cursor command order was not preserved: %q", message.Content) + } + if strings.Contains(message.Content, "empty") { + t.Fatalf("empty cursor command was not skipped: %q", message.Content) + } + if !strings.Contains(message.Content, "\nFirst command.\n") { + t.Fatalf("unnamed cursor command was not rendered safely: %q", message.Content) + } +} + func TestBuildReplayMessagesFromPendingAssistantOutputsKeepsTextAndToolCallInOneAssistantTurn(t *testing.T) { raw := `{ "id":"1", From 7b1c1d39af8960bdd5e78a5db059ea30e6f21fd8 Mon Sep 17 00:00:00 2001 From: leokun <131544788+leookun@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:33:49 +0800 Subject: [PATCH 2/9] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 509f347..16d7a2f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ image +## 交流群组 +https://t.me/cursor_byok + ## 为什么做这个项目 From 4451a6df3b0252d2ff291c341f401bd25edde4ca Mon Sep 17 00:00:00 2001 From: DedSecer Date: Thu, 30 Jul 2026 19:32:42 +0800 Subject: [PATCH 3/9] fix(forwarder): handle Cursor summarize actions Map Cursor's protocol-level SummarizeAction to manual context compaction while keeping the legacy /compact directive disabled. Co-authored-by: Cursor --- internal/backend/forwarder/compaction.go | 59 +++++- internal/backend/forwarder/compaction_test.go | 198 ++++++++++++++++++ internal/backend/forwarder/service.go | 36 ++++ internal/backend/forwarder/types.go | 7 + 4 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 internal/backend/forwarder/compaction_test.go diff --git a/internal/backend/forwarder/compaction.go b/internal/backend/forwarder/compaction.go index c3fd67c..f28220c 100644 --- a/internal/backend/forwarder/compaction.go +++ b/internal/backend/forwarder/compaction.go @@ -77,7 +77,7 @@ func (service *Service) maybeCompactBeforeProvider(stream *ActiveStream, convers if service == nil || stream == nil || conversation == nil { return false, nil } - manualInstruction, manual := parseManualCompactionDirective(stream.LatestUserText) + manualInstruction, manual := streamManualCompactionDirective(stream) plan, err := service.buildCompactionPlan(stream, conversation, compiled, manual, manualInstruction) if err != nil { return false, err @@ -827,7 +827,7 @@ func buildFallbackCompactionSummary(plan *PendingCompaction) string { sections = append(sections, "Compaction note:\n"+truncateCompactionText(plan.HookMessage, 800)) } if strings.TrimSpace(plan.ManualInstruction) != "" { - sections = append(sections, "Manual compact instruction:\n"+truncateCompactionText(plan.ManualInstruction, 800)) + sections = append(sections, "Manual summarize instruction:\n"+truncateCompactionText(plan.ManualInstruction, 800)) } return strings.TrimSpace(truncateCompactionText(strings.Join(sections, "\n\n"), compactionSummaryMaxChars)) } @@ -875,13 +875,62 @@ func (service *Service) resolveCompactionReserveTokens(modelID string) int64 { return compactionAutoReserveTokens } +func parseManualCompactionRequest(userMessage *agentv1.UserMessage) (string, bool) { + if userMessage == nil { + return "", false + } + userText := strings.TrimSpace(userMessage.GetText()) + if instruction, ok := parseManualCompactionDirective(userText); ok { + return instruction, true + } + if userText != "" { + return "", false + } + selectedContext := userMessage.GetSelectedContext() + if selectedContext == nil { + return "", false + } + for _, command := range selectedContext.GetCursorCommands() { + if !isCursorSummarizeCommand(command) { + continue + } + instruction, _ := parseManualCompactionDirective(command.GetContent()) + return instruction, true + } + return "", false +} + +func streamManualCompactionDirective(stream *ActiveStream) (string, bool) { + if stream == nil { + return "", false + } + stream.mu.Lock() + defer stream.mu.Unlock() + if stream.ManualCompaction.Requested { + return strings.TrimSpace(stream.ManualCompaction.Instruction), true + } + return parseManualCompactionDirective(stream.LatestUserText) +} + +func isCursorSummarizeCommand(command *agentv1.SelectedCursorCommand) bool { + if command == nil { + return false + } + if strings.EqualFold(strings.TrimSpace(command.GetName()), "glass-action-summarize") { + return true + } + _, ok := parseManualCompactionDirective(command.GetContent()) + return ok +} + func parseManualCompactionDirective(latestUserText string) (string, bool) { trimmed := strings.TrimSpace(latestUserText) + const directive = "/summarize" switch { - case trimmed == "/compact": + case trimmed == directive: return "", true - case strings.HasPrefix(trimmed, "/compact "): - return strings.TrimSpace(strings.TrimPrefix(trimmed, "/compact")), true + case strings.HasPrefix(trimmed, directive+" "): + return strings.TrimSpace(strings.TrimPrefix(trimmed, directive)), true default: return "", false } diff --git a/internal/backend/forwarder/compaction_test.go b/internal/backend/forwarder/compaction_test.go new file mode 100644 index 0000000..c65131c --- /dev/null +++ b/internal/backend/forwarder/compaction_test.go @@ -0,0 +1,198 @@ +package forwarder + +import ( + "testing" + + "cursor/gen/agentv1" +) + +func TestParseManualCompactionDirectiveSupportsCursorSummarize(t *testing.T) { + tests := []struct { + name string + text string + wantInstruction string + want bool + }{ + {name: "compact removed", text: "/compact", want: false}, + {name: "compact instruction removed", text: "/compact keep deployment details", want: false}, + {name: "summarize", text: "/summarize", want: true}, + {name: "summarize instruction", text: "/summarize keep failing tests", wantInstruction: "keep failing tests", want: true}, + {name: "surrounding whitespace", text: " /summarize ", want: true}, + {name: "similar command", text: "/summarized", want: false}, + {name: "ordinary text", text: "please summarize this file", want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + instruction, ok := parseManualCompactionDirective(test.text) + if ok != test.want || instruction != test.wantInstruction { + t.Fatalf("parseManualCompactionDirective(%q) = (%q, %v), want (%q, %v)", test.text, instruction, ok, test.wantInstruction, test.want) + } + }) + } +} + +func TestParseManualCompactionRequestRecognizesCursorSummarizeCommand(t *testing.T) { + instruction, ok := parseManualCompactionRequest(&agentv1.UserMessage{ + SelectedContext: &agentv1.SelectedContext{ + CursorCommands: []*agentv1.SelectedCursorCommand{ + {Name: "glass-action-summarize", Content: "/summarize"}, + }, + }, + }) + if !ok || instruction != "" { + t.Fatalf("parseManualCompactionRequest() = (%q, %v), want empty instruction and true", instruction, ok) + } +} + +func TestParseManualCompactionRequestIgnoresSummarizeMetadataWhenUserTextIsPresent(t *testing.T) { + instruction, ok := parseManualCompactionRequest(&agentv1.UserMessage{ + Text: "why does /summarize not work?", + SelectedContext: &agentv1.SelectedContext{ + CursorCommands: []*agentv1.SelectedCursorCommand{ + {Name: "glass-action-summarize", Content: "/summarize"}, + }, + }, + }) + if ok || instruction != "" { + t.Fatalf("parseManualCompactionRequest() = (%q, %v), want empty instruction and false", instruction, ok) + } +} + +func TestParseManualCompactionRequestIgnoresOrdinaryCursorCommands(t *testing.T) { + instruction, ok := parseManualCompactionRequest(&agentv1.UserMessage{ + Text: "review this implementation", + SelectedContext: &agentv1.SelectedContext{ + CursorCommands: []*agentv1.SelectedCursorCommand{ + nil, + {Name: "review", Content: "Review the implementation."}, + }, + }, + }) + if ok || instruction != "" { + t.Fatalf("parseManualCompactionRequest() = (%q, %v), want empty instruction and false", instruction, ok) + } +} + +func TestDecodeInboundIntentMapsRunRequestSummarizeActionToManualCompaction(t *testing.T) { + service := &Service{debug: newDebugRecorder("", nil, nil)} + intent, err := service.decodeInboundIntent( + "summarize-request", + newRunRequestMessage(newSummarizeConversationAction()), + "run_request", + ) + if err != nil { + t.Fatalf("decodeInboundIntent() error = %v", err) + } + if intent.Kind != "run" || !intent.StartsRun { + t.Fatalf("decodeInboundIntent() kind = %q, starts_run = %v, want run and true", intent.Kind, intent.StartsRun) + } + if intent.UserMessage != nil { + t.Fatalf("decodeInboundIntent() user_message = %#v, want nil", intent.UserMessage) + } + if !intent.ManualCompaction.Requested || intent.ManualCompaction.Instruction != "" { + t.Fatalf("decodeInboundIntent() manual_compaction = %#v, want requested with empty instruction", intent.ManualCompaction) + } +} + +func TestResolveInboundManualCompactionSupportsStandaloneConversationAction(t *testing.T) { + directive := resolveInboundManualCompaction(&agentv1.AgentClientMessage{ + Message: &agentv1.AgentClientMessage_ConversationAction{ + ConversationAction: newSummarizeConversationAction(), + }, + }, nil) + if !directive.Requested || directive.Instruction != "" { + t.Fatalf("resolveInboundManualCompaction() = %#v, want requested with empty instruction", directive) + } +} + +func TestResolveInboundManualCompactionIgnoresOtherStandaloneActions(t *testing.T) { + directive := resolveInboundManualCompaction(&agentv1.AgentClientMessage{ + Message: &agentv1.AgentClientMessage_ConversationAction{ + ConversationAction: &agentv1.ConversationAction{ + Action: &agentv1.ConversationAction_CancelAction{CancelAction: &agentv1.CancelAction{}}, + }, + }, + }, nil) + if directive.Requested { + t.Fatalf("resolveInboundManualCompaction() = %#v, want not requested", directive) + } +} + +func TestDecodeInboundIntentDoesNotMapOrdinaryRunActionsToManualCompaction(t *testing.T) { + tests := []struct { + name string + text string + }{ + {name: "ordinary message", text: "review this implementation"}, + {name: "compact command removed", text: "/compact"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + service := &Service{debug: newDebugRecorder("", nil, nil)} + intent, err := service.decodeInboundIntent( + "ordinary-request", + newRunRequestMessage(&agentv1.ConversationAction{ + Action: &agentv1.ConversationAction_UserMessageAction{ + UserMessageAction: &agentv1.UserMessageAction{ + UserMessage: &agentv1.UserMessage{Text: test.text}, + }, + }, + }), + "run_request", + ) + if err != nil { + t.Fatalf("decodeInboundIntent() error = %v", err) + } + if intent.ManualCompaction.Requested { + t.Fatalf("decodeInboundIntent() manual_compaction = %#v, want not requested", intent.ManualCompaction) + } + }) + } +} + +func TestSummarizeConversationActionStartsRun(t *testing.T) { + if !conversationActionStartsRun(newSummarizeConversationAction()) { + t.Fatal("conversationActionStartsRun(summarize) = false, want true") + } + if conversationActionStartsRun(&agentv1.ConversationAction{ + Action: &agentv1.ConversationAction_CancelAction{CancelAction: &agentv1.CancelAction{}}, + }) { + t.Fatal("conversationActionStartsRun(cancel) = true, want false") + } +} + +func TestStreamManualCompactionDirectiveUsesStructuredRequest(t *testing.T) { + stream := &ActiveStream{ + LatestUserText: "visible user text", + ManualCompaction: manualCompactionDirective{ + Requested: true, + Instruction: "keep decisions", + }, + } + instruction, ok := streamManualCompactionDirective(stream) + if !ok || instruction != "keep decisions" { + t.Fatalf("streamManualCompactionDirective() = (%q, %v), want (%q, true)", instruction, ok, "keep decisions") + } +} + +func newRunRequestMessage(action *agentv1.ConversationAction) *agentv1.AgentClientMessage { + conversationID := "test-conversation" + return &agentv1.AgentClientMessage{ + Message: &agentv1.AgentClientMessage_RunRequest{ + RunRequest: &agentv1.AgentRunRequest{ + ConversationId: &conversationID, + Action: action, + }, + }, + } +} + +func newSummarizeConversationAction() *agentv1.ConversationAction { + return &agentv1.ConversationAction{ + Action: &agentv1.ConversationAction_SummarizeAction{ + SummarizeAction: &agentv1.SummarizeAction{}, + }, + } +} diff --git a/internal/backend/forwarder/service.go b/internal/backend/forwarder/service.go index d9c265e..5909c8e 100644 --- a/internal/backend/forwarder/service.go +++ b/internal/backend/forwarder/service.go @@ -672,6 +672,7 @@ func (service *Service) decodeInboundIntent(requestID string, message *agentv1.A default: return InboundIntent{}, fmt.Errorf("unsupported client message kind: %s", clientKind) } + intent.ManualCompaction = resolveInboundManualCompaction(message, intent.UserMessage) return intent, nil } @@ -751,6 +752,7 @@ func (service *Service) handleRunIntent(intent InboundIntent) error { stream.mu.Lock() stream.ThinkingEffort = strings.TrimSpace(intent.ThinkingEffort) stream.SubagentModelOverrides = cloneSubagentModelOverrides(intent.SubagentModelOverrides) + stream.ManualCompaction = intent.ManualCompaction stream.PendingProviderAction = providerActionNone stream.PendingCompaction = nil stream.PendingExecs = make(map[string]runtimecore.PendingExec) @@ -788,6 +790,7 @@ func (service *Service) handleRunIntent(intent InboundIntent) error { "subagent_model_override_count": len(intent.SubagentModelOverrides), "subagent_model_overrides": subagentModelOverrideSummaries(intent.SubagentModelOverrides), "latest_user_text": userMessageText(intent.UserMessage), + "manual_compaction_requested": intent.ManualCompaction.Requested, }) if err := service.publishCheckpoint(intent.RequestID, intent.ConversationID); err != nil { return err @@ -2643,6 +2646,38 @@ func conversationActionIsResume(action *agentv1.ConversationAction) bool { return ok } +func inboundConversationAction(message *agentv1.AgentClientMessage) *agentv1.ConversationAction { + if message == nil { + return nil + } + if action := message.GetConversationAction(); action != nil { + return action + } + if runRequest := message.GetRunRequest(); runRequest != nil { + return runRequest.GetAction() + } + return nil +} + +func conversationActionIsSummarize(action *agentv1.ConversationAction) bool { + if action == nil { + return false + } + _, ok := action.GetAction().(*agentv1.ConversationAction_SummarizeAction) + return ok +} + +func resolveInboundManualCompaction(message *agentv1.AgentClientMessage, userMessage *agentv1.UserMessage) manualCompactionDirective { + instruction, requested := parseManualCompactionRequest(userMessage) + if conversationActionIsSummarize(inboundConversationAction(message)) { + requested = true + } + return manualCompactionDirective{ + Requested: requested, + Instruction: instruction, + } +} + func conversationActionStartsRun(action *agentv1.ConversationAction) bool { if action == nil { return false @@ -2650,6 +2685,7 @@ func conversationActionStartsRun(action *agentv1.ConversationAction) bool { switch action.GetAction().(type) { case *agentv1.ConversationAction_UserMessageAction, *agentv1.ConversationAction_ResumeAction, + *agentv1.ConversationAction_SummarizeAction, *agentv1.ConversationAction_StartPlanAction, *agentv1.ConversationAction_ExecutePlanAction: return true diff --git a/internal/backend/forwarder/types.go b/internal/backend/forwarder/types.go index 4f7111e..c87ef98 100644 --- a/internal/backend/forwarder/types.go +++ b/internal/backend/forwarder/types.go @@ -116,6 +116,11 @@ type StreamSubscriber struct { Signal chan struct{} } +type manualCompactionDirective struct { + Requested bool + Instruction string +} + type ActiveStream struct { mu sync.Mutex @@ -126,6 +131,7 @@ type ActiveStream struct { ModelName string Mode agentv1.AgentMode LatestUserText string + ManualCompaction manualCompactionDirective Status StreamStatus ThinkingEffort string SubagentModelOverrides map[string]runtimecore.SubagentModelOverrideSelection @@ -407,6 +413,7 @@ type InboundIntent struct { HasExplicitMode bool ModeSource ModeSource StartsRun bool + ManualCompaction manualCompactionDirective SubagentTypeName string SubagentModelOverrides map[string]runtimecore.SubagentModelOverrideSelection ConversationState *agentv1.ConversationStateStructure From 3349b13a2b3756535f4cb371426bcee6174fb696 Mon Sep 17 00:00:00 2001 From: leokun Date: Thu, 30 Jul 2026 20:28:20 +0800 Subject: [PATCH 4/9] fix(prompt): persist cursor command replay context --- internal/backend/agent/prompt/replay.go | 19 +- internal/backend/agent/prompt/replay_test.go | 58 ----- internal/backend/forwarder/compaction_test.go | 198 ------------------ internal/backend/forwarder/prompt_context.go | 2 + internal/backend/forwarder/prompt_guard.go | 35 ++++ internal/backend/forwarder/service.go | 11 +- 6 files changed, 62 insertions(+), 261 deletions(-) delete mode 100644 internal/backend/forwarder/compaction_test.go diff --git a/internal/backend/agent/prompt/replay.go b/internal/backend/agent/prompt/replay.go index 68560b7..4b2eb0c 100644 --- a/internal/backend/agent/prompt/replay.go +++ b/internal/backend/agent/prompt/replay.go @@ -26,13 +26,10 @@ func BuildUserMessageReplayMessage(userMessage *agentv1.UserMessage) (Message, b func buildUserReplayMessage(text string, selectedContext *agentv1.SelectedContext) (Message, bool) { images := buildSelectedImageContentParts(selectedContext) - sections := make([]string, 0, 5) + sections := make([]string, 0, 4) if text != "" { sections = append(sections, formatMessageText(fmt.Sprintf("\n%s\n", text))) } - if cursorCommands := buildSelectedCursorCommandsPromptSection(selectedContext); cursorCommands != "" { - sections = append(sections, cursorCommands) - } if ideState := buildSelectedIDEStatePromptSection(selectedContext); ideState != "" { sections = append(sections, ideState) } @@ -65,6 +62,20 @@ func buildUserReplayMessage(text string, selectedContext *agentv1.SelectedContex }, true } +// BuildSelectedCursorCommandsReplayMessage renders command content for new history entries. +// Keeping this separate from BuildUserMessageReplayMessage prevents old user_message entries +// from changing their model-visible meaning after a backend upgrade. +func BuildSelectedCursorCommandsReplayMessage(userMessage *agentv1.UserMessage) (Message, bool) { + if userMessage == nil { + return Message{}, false + } + content := buildSelectedCursorCommandsPromptSection(userMessage.GetSelectedContext()) + if content == "" { + return Message{}, false + } + return Message{Role: "user", Content: content}, true +} + func buildSelectedCursorCommandsPromptSection(selectedContext *agentv1.SelectedContext) string { if selectedContext == nil || len(selectedContext.GetCursorCommands()) == 0 { return "" diff --git a/internal/backend/agent/prompt/replay_test.go b/internal/backend/agent/prompt/replay_test.go index fc91b90..04abd85 100644 --- a/internal/backend/agent/prompt/replay_test.go +++ b/internal/backend/agent/prompt/replay_test.go @@ -2,67 +2,9 @@ package promptengine import ( "reflect" - "strings" "testing" - - "cursor/gen/agentv1" ) -func TestBuildUserMessageReplayMessageIncludesSelectedCursorCommands(t *testing.T) { - message, ok := BuildUserMessageReplayMessage(&agentv1.UserMessage{ - Text: "/init", - SelectedContext: &agentv1.SelectedContext{ - CursorCommands: []*agentv1.SelectedCursorCommand{ - {Name: "init", Content: "Analyze the repository and create AGENTS.md."}, - {Name: `review"<&`, Content: "Review the implementation."}, - }, - }, - }) - if !ok { - t.Fatal("BuildUserMessageReplayMessage() returned ok=false") - } - - want := strings.Join([]string{ - "\n/init\n", - "\n" + - "\nAnalyze the repository and create AGENTS.md.\n\n\n" + - "\nReview the implementation.\n\n" + - "", - }, "\n\n") - if message.Role != "user" || message.Content != want { - t.Fatalf("message = %#v, want content %q", message, want) - } -} - -func TestBuildUserMessageReplayMessageSkipsEmptyCursorCommandsAndKeepsOrder(t *testing.T) { - message, ok := BuildUserMessageReplayMessage(&agentv1.UserMessage{ - Text: "run commands", - SelectedContext: &agentv1.SelectedContext{ - CursorCommands: []*agentv1.SelectedCursorCommand{ - nil, - {Name: "empty", Content: " "}, - {Content: "First command."}, - {Name: "second", Content: "Second command."}, - }, - }, - }) - if !ok { - t.Fatal("BuildUserMessageReplayMessage() returned ok=false") - } - - first := strings.Index(message.Content, "First command.") - second := strings.Index(message.Content, "Second command.") - if first < 0 || second < 0 || first >= second { - t.Fatalf("cursor command order was not preserved: %q", message.Content) - } - if strings.Contains(message.Content, "empty") { - t.Fatalf("empty cursor command was not skipped: %q", message.Content) - } - if !strings.Contains(message.Content, "\nFirst command.\n") { - t.Fatalf("unnamed cursor command was not rendered safely: %q", message.Content) - } -} - func TestBuildReplayMessagesFromPendingAssistantOutputsKeepsTextAndToolCallInOneAssistantTurn(t *testing.T) { raw := `{ "id":"1", diff --git a/internal/backend/forwarder/compaction_test.go b/internal/backend/forwarder/compaction_test.go deleted file mode 100644 index c65131c..0000000 --- a/internal/backend/forwarder/compaction_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package forwarder - -import ( - "testing" - - "cursor/gen/agentv1" -) - -func TestParseManualCompactionDirectiveSupportsCursorSummarize(t *testing.T) { - tests := []struct { - name string - text string - wantInstruction string - want bool - }{ - {name: "compact removed", text: "/compact", want: false}, - {name: "compact instruction removed", text: "/compact keep deployment details", want: false}, - {name: "summarize", text: "/summarize", want: true}, - {name: "summarize instruction", text: "/summarize keep failing tests", wantInstruction: "keep failing tests", want: true}, - {name: "surrounding whitespace", text: " /summarize ", want: true}, - {name: "similar command", text: "/summarized", want: false}, - {name: "ordinary text", text: "please summarize this file", want: false}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - instruction, ok := parseManualCompactionDirective(test.text) - if ok != test.want || instruction != test.wantInstruction { - t.Fatalf("parseManualCompactionDirective(%q) = (%q, %v), want (%q, %v)", test.text, instruction, ok, test.wantInstruction, test.want) - } - }) - } -} - -func TestParseManualCompactionRequestRecognizesCursorSummarizeCommand(t *testing.T) { - instruction, ok := parseManualCompactionRequest(&agentv1.UserMessage{ - SelectedContext: &agentv1.SelectedContext{ - CursorCommands: []*agentv1.SelectedCursorCommand{ - {Name: "glass-action-summarize", Content: "/summarize"}, - }, - }, - }) - if !ok || instruction != "" { - t.Fatalf("parseManualCompactionRequest() = (%q, %v), want empty instruction and true", instruction, ok) - } -} - -func TestParseManualCompactionRequestIgnoresSummarizeMetadataWhenUserTextIsPresent(t *testing.T) { - instruction, ok := parseManualCompactionRequest(&agentv1.UserMessage{ - Text: "why does /summarize not work?", - SelectedContext: &agentv1.SelectedContext{ - CursorCommands: []*agentv1.SelectedCursorCommand{ - {Name: "glass-action-summarize", Content: "/summarize"}, - }, - }, - }) - if ok || instruction != "" { - t.Fatalf("parseManualCompactionRequest() = (%q, %v), want empty instruction and false", instruction, ok) - } -} - -func TestParseManualCompactionRequestIgnoresOrdinaryCursorCommands(t *testing.T) { - instruction, ok := parseManualCompactionRequest(&agentv1.UserMessage{ - Text: "review this implementation", - SelectedContext: &agentv1.SelectedContext{ - CursorCommands: []*agentv1.SelectedCursorCommand{ - nil, - {Name: "review", Content: "Review the implementation."}, - }, - }, - }) - if ok || instruction != "" { - t.Fatalf("parseManualCompactionRequest() = (%q, %v), want empty instruction and false", instruction, ok) - } -} - -func TestDecodeInboundIntentMapsRunRequestSummarizeActionToManualCompaction(t *testing.T) { - service := &Service{debug: newDebugRecorder("", nil, nil)} - intent, err := service.decodeInboundIntent( - "summarize-request", - newRunRequestMessage(newSummarizeConversationAction()), - "run_request", - ) - if err != nil { - t.Fatalf("decodeInboundIntent() error = %v", err) - } - if intent.Kind != "run" || !intent.StartsRun { - t.Fatalf("decodeInboundIntent() kind = %q, starts_run = %v, want run and true", intent.Kind, intent.StartsRun) - } - if intent.UserMessage != nil { - t.Fatalf("decodeInboundIntent() user_message = %#v, want nil", intent.UserMessage) - } - if !intent.ManualCompaction.Requested || intent.ManualCompaction.Instruction != "" { - t.Fatalf("decodeInboundIntent() manual_compaction = %#v, want requested with empty instruction", intent.ManualCompaction) - } -} - -func TestResolveInboundManualCompactionSupportsStandaloneConversationAction(t *testing.T) { - directive := resolveInboundManualCompaction(&agentv1.AgentClientMessage{ - Message: &agentv1.AgentClientMessage_ConversationAction{ - ConversationAction: newSummarizeConversationAction(), - }, - }, nil) - if !directive.Requested || directive.Instruction != "" { - t.Fatalf("resolveInboundManualCompaction() = %#v, want requested with empty instruction", directive) - } -} - -func TestResolveInboundManualCompactionIgnoresOtherStandaloneActions(t *testing.T) { - directive := resolveInboundManualCompaction(&agentv1.AgentClientMessage{ - Message: &agentv1.AgentClientMessage_ConversationAction{ - ConversationAction: &agentv1.ConversationAction{ - Action: &agentv1.ConversationAction_CancelAction{CancelAction: &agentv1.CancelAction{}}, - }, - }, - }, nil) - if directive.Requested { - t.Fatalf("resolveInboundManualCompaction() = %#v, want not requested", directive) - } -} - -func TestDecodeInboundIntentDoesNotMapOrdinaryRunActionsToManualCompaction(t *testing.T) { - tests := []struct { - name string - text string - }{ - {name: "ordinary message", text: "review this implementation"}, - {name: "compact command removed", text: "/compact"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - service := &Service{debug: newDebugRecorder("", nil, nil)} - intent, err := service.decodeInboundIntent( - "ordinary-request", - newRunRequestMessage(&agentv1.ConversationAction{ - Action: &agentv1.ConversationAction_UserMessageAction{ - UserMessageAction: &agentv1.UserMessageAction{ - UserMessage: &agentv1.UserMessage{Text: test.text}, - }, - }, - }), - "run_request", - ) - if err != nil { - t.Fatalf("decodeInboundIntent() error = %v", err) - } - if intent.ManualCompaction.Requested { - t.Fatalf("decodeInboundIntent() manual_compaction = %#v, want not requested", intent.ManualCompaction) - } - }) - } -} - -func TestSummarizeConversationActionStartsRun(t *testing.T) { - if !conversationActionStartsRun(newSummarizeConversationAction()) { - t.Fatal("conversationActionStartsRun(summarize) = false, want true") - } - if conversationActionStartsRun(&agentv1.ConversationAction{ - Action: &agentv1.ConversationAction_CancelAction{CancelAction: &agentv1.CancelAction{}}, - }) { - t.Fatal("conversationActionStartsRun(cancel) = true, want false") - } -} - -func TestStreamManualCompactionDirectiveUsesStructuredRequest(t *testing.T) { - stream := &ActiveStream{ - LatestUserText: "visible user text", - ManualCompaction: manualCompactionDirective{ - Requested: true, - Instruction: "keep decisions", - }, - } - instruction, ok := streamManualCompactionDirective(stream) - if !ok || instruction != "keep decisions" { - t.Fatalf("streamManualCompactionDirective() = (%q, %v), want (%q, true)", instruction, ok, "keep decisions") - } -} - -func newRunRequestMessage(action *agentv1.ConversationAction) *agentv1.AgentClientMessage { - conversationID := "test-conversation" - return &agentv1.AgentClientMessage{ - Message: &agentv1.AgentClientMessage_RunRequest{ - RunRequest: &agentv1.AgentRunRequest{ - ConversationId: &conversationID, - Action: action, - }, - }, - } -} - -func newSummarizeConversationAction() *agentv1.ConversationAction { - return &agentv1.ConversationAction{ - Action: &agentv1.ConversationAction_SummarizeAction{ - SummarizeAction: &agentv1.SummarizeAction{}, - }, - } -} diff --git a/internal/backend/forwarder/prompt_context.go b/internal/backend/forwarder/prompt_context.go index 798dcda..f282a04 100644 --- a/internal/backend/forwarder/prompt_context.go +++ b/internal/backend/forwarder/prompt_context.go @@ -9,6 +9,8 @@ import ( modeladapter "cursor/internal/backend/agent/model" ) +const promptContextSourceSelectedCursorCommands = "selected_cursor_commands" + func newPromptContextMessage(source string, message modeladapter.Message, persist bool) PromptContextMessage { context := PromptContextMessage{ Source: strings.TrimSpace(source), diff --git a/internal/backend/forwarder/prompt_guard.go b/internal/backend/forwarder/prompt_guard.go index 3545325..a3bcc99 100644 --- a/internal/backend/forwarder/prompt_guard.go +++ b/internal/backend/forwarder/prompt_guard.go @@ -18,6 +18,10 @@ const ( promptGuardSelectedFileChars = 16000 promptGuardSelectedFilesTotalChars = 64000 promptGuardSelectedFilesMaxCount = 12 + promptGuardCursorCommandNameChars = 256 + promptGuardCursorCommandChars = 12000 + promptGuardCursorCommandsTotalChars = 32000 + promptGuardCursorCommandsMaxCount = 8 promptGuardRequestFileChars = 16000 promptGuardRequestFilesTotalChars = 64000 promptGuardRequestFilesMaxCount = 12 @@ -106,11 +110,42 @@ func guardSelectedContext(selectedContext *agentv1.SelectedContext) *agentv1.Sel return selectedContext } cloned.Files = guardSelectedFiles(cloned.GetFiles()) + cloned.CursorCommands = guardSelectedCursorCommands(cloned.GetCursorCommands()) cloned.SelectedSkills = guardAgentSkills(cloned.GetSelectedSkills()) cloned.ExtraContext = guardStringSlice(cloned.GetExtraContext(), "selected_context.extra_context", promptGuardRealtimeTextChars, promptGuardRealtimeTextChars, promptGuardAgentSkillsMaxCount) return cloned } +func guardSelectedCursorCommands(commands []*agentv1.SelectedCursorCommand) []*agentv1.SelectedCursorCommand { + if len(commands) == 0 { + return nil + } + result := make([]*agentv1.SelectedCursorCommand, 0, minInt(len(commands), promptGuardCursorCommandsMaxCount)) + remaining := promptGuardCursorCommandsTotalChars + for _, command := range commands { + if command == nil || len(result) >= promptGuardCursorCommandsMaxCount { + continue + } + content := strings.TrimSpace(command.GetContent()) + if content == "" { + continue + } + limit := minInt(promptGuardCursorCommandChars, remaining) + if limit <= 0 { + break + } + cloned, ok := proto.Clone(command).(*agentv1.SelectedCursorCommand) + if !ok || cloned == nil { + continue + } + cloned.Name = truncatePromptGuardText("selected_context.cursor_commands.name", strings.TrimSpace(cloned.GetName()), promptGuardCursorCommandNameChars) + cloned.Content = truncatePromptGuardText("selected_context.cursor_commands.content", content, limit) + remaining -= promptGuardRuneCount(cloned.GetContent()) + result = append(result, cloned) + } + return result +} + func guardSelectedFiles(files []*agentv1.SelectedFile) []*agentv1.SelectedFile { if len(files) == 0 { return nil diff --git a/internal/backend/forwarder/service.go b/internal/backend/forwarder/service.go index 5909c8e..09c2a1a 100644 --- a/internal/backend/forwarder/service.go +++ b/internal/backend/forwarder/service.go @@ -23,6 +23,7 @@ import ( interactionbridge "cursor/internal/backend/agent/bridge/interaction" runtimecore "cursor/internal/backend/agent/core" modeladapter "cursor/internal/backend/agent/model" + promptengine "cursor/internal/backend/agent/prompt" protocol "cursor/internal/backend/agent/protocol" ) @@ -2335,7 +2336,8 @@ func buildRunEntries(intent InboundIntent, effectiveMode agentv1.AgentMode, turn } } if intent.UserMessage != nil { - payload, err := protojson.Marshal(normalizeUserMessageForStorage(intent.UserMessage)) + normalized := normalizeUserMessageForStorage(intent.UserMessage) + payload, err := protojson.Marshal(normalized) if err != nil { return nil, err } @@ -2346,6 +2348,13 @@ func buildRunEntries(intent InboundIntent, effectiveMode agentv1.AgentMode, turn Kind: "user_message", Payload: payload, }) + if commandMessage, ok := promptengine.BuildSelectedCursorCommandsReplayMessage(normalized); ok { + entries = append(entries, newPromptContextEntry(turnSeq, intent.RequestID, newPromptContextMessage( + promptContextSourceSelectedCursorCommands, + modeladapter.Message{Role: commandMessage.Role, Content: commandMessage.Content}, + true, + ))) + } } modeEntry, err := newModeMetadataEntry(turnSeq, intent.RequestID, effectiveMode, intent.HasExplicitMode, intent.ModeSource) if err != nil { From 68e63c7a728780cbea1e032644341be77707e9dd Mon Sep 17 00:00:00 2001 From: DedSecer Date: Sat, 1 Aug 2026 21:04:26 +0800 Subject: [PATCH 5/9] feat(cursor): sync local conversation history to Cursor transcripts Project sidecar context history into Cursor-compatible JSONL transcripts so previous conversations remain readable when referenced from new chats. Backfill existing sessions on startup and preserve Cursor-managed turn status entries during atomic updates. --- internal/backend/forwarder/file_store.go | 78 ++- internal/backend/forwarder/rewind.go | 3 + internal/backend/forwarder/service.go | 6 + .../backend/forwarder/transcript_adapter.go | 449 ++++++++++++++++++ .../forwarder/transcript_adapter_test.go | 213 +++++++++ internal/backend/forwarder/types.go | 1 + 6 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 internal/backend/forwarder/transcript_adapter.go create mode 100644 internal/backend/forwarder/transcript_adapter_test.go diff --git a/internal/backend/forwarder/file_store.go b/internal/backend/forwarder/file_store.go index 4f34720..0732614 100644 --- a/internal/backend/forwarder/file_store.go +++ b/internal/backend/forwarder/file_store.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "os" "path/filepath" "runtime" @@ -438,7 +439,11 @@ func (store *ConversationFileStore) writeConversationLocked(conversationID strin if err := store.writeContextLocked(conversationID, conversation); err != nil { return err } - return store.writeConversationMetaLocked(conversationID, conversation) + if err := store.writeConversationMetaLocked(conversationID, conversation); err != nil { + return err + } + store.syncCursorTranscriptBestEffort(conversationID, conversation) + return nil } func (store *ConversationFileStore) writeConversationMetaLocked(conversationID string, conversation *ConversationFile) error { @@ -477,6 +482,70 @@ func (store *ConversationFileStore) writeContextLocked(conversationID string, co return writeJSONFileAtomic(store.contextPath(conversationID), context) } +func (store *ConversationFileStore) syncCursorTranscriptBestEffort(conversationID string, conversation *ConversationFile) { + if store == nil || conversation == nil { + return + } + folder := normalizeAgentTranscriptsFolder(conversation.AgentTranscriptsFolder) + if folder == "" { + return + } + if err := store.syncCursorTranscript(conversationID, conversation, folder); err != nil { + log.Printf("forwarder transcript sync failed conversation_id=%s err=%v", strings.TrimSpace(conversationID), err) + } +} + +func (store *ConversationFileStore) syncCursorTranscript(conversationID string, conversation *ConversationFile, transcriptsFolder string) error { + return store.syncCursorTranscriptWithLatestStatus(conversationID, conversation, transcriptsFolder, false) +} + +func (store *ConversationFileStore) syncCursorTranscriptWithLatestStatus(conversationID string, conversation *ConversationFile, transcriptsFolder string, includeLatestStatus bool) error { + if store == nil || conversation == nil { + return nil + } + path, err := cursorTranscriptPath(transcriptsFolder, conversationID) + if err != nil { + return err + } + data, err := projectCursorTranscriptJSONLWithLatestStatus(conversation, includeLatestStatus) + if err != nil { + return err + } + if len(data) == 0 { + return nil + } + data = preserveCursorAppendedTurnEnded(path, data) + return writeCursorTranscriptAtomic(path, data) +} + +func (store *ConversationFileStore) SyncAllCursorTranscriptsBestEffort() { + if store == nil { + return + } + conversationIDs, err := store.ListConversationIDs() + if err != nil { + log.Printf("forwarder transcript backfill scan failed err=%v", err) + return + } + for _, conversationID := range conversationIDs { + conversation, err := store.LoadConversation(conversationID) + if err != nil { + log.Printf("forwarder transcript backfill load failed conversation_id=%s err=%v", conversationID, err) + continue + } + if conversation == nil || conversation.AgentTranscriptsFolder == "" { + continue + } + info, err := os.Stat(conversation.AgentTranscriptsFolder) + if err != nil || !info.IsDir() { + continue + } + if err := store.syncCursorTranscriptWithLatestStatus(conversationID, conversation, conversation.AgentTranscriptsFolder, true); err != nil { + log.Printf("forwarder transcript backfill failed conversation_id=%s err=%v", conversationID, err) + } + } +} + func contextVersionForEntries(entries []HistoryEntry) int64 { var version int64 for _, entry := range entries { @@ -663,6 +732,9 @@ func mergeConversationMetadata(target *ConversationFile, source *ConversationFil target.ParentConversationID = strings.TrimSpace(source.ParentConversationID) target.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID) target.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName) + if folder := normalizeAgentTranscriptsFolder(source.AgentTranscriptsFolder); folder != "" { + target.AgentTranscriptsFolder = folder + } if strings.TrimSpace(source.Mode) != "" { target.Mode = strings.TrimSpace(source.Mode) } @@ -718,6 +790,10 @@ func normalizeLoadedConversation(conversationID string, conversation *Conversati if conversation.Entries == nil { conversation.Entries = make([]HistoryEntry, 0, 16) } + conversation.AgentTranscriptsFolder = normalizeAgentTranscriptsFolder(conversation.AgentTranscriptsFolder) + if conversation.AgentTranscriptsFolder == "" { + conversation.AgentTranscriptsFolder = agentTranscriptsFolderFromEntries(conversation.Entries) + } for _, entry := range conversation.Entries { if entry.Seq >= conversation.NextEntrySeq { conversation.NextEntrySeq = entry.Seq + 1 diff --git a/internal/backend/forwarder/rewind.go b/internal/backend/forwarder/rewind.go index e0f5898..ba7c851 100644 --- a/internal/backend/forwarder/rewind.go +++ b/internal/backend/forwarder/rewind.go @@ -260,6 +260,9 @@ func applyRunRewindMetadata(conversation *ConversationFile, source *Conversation conversation.ParentConversationID = strings.TrimSpace(source.ParentConversationID) conversation.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID) conversation.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName) + if folder := normalizeAgentTranscriptsFolder(source.AgentTranscriptsFolder); folder != "" { + conversation.AgentTranscriptsFolder = folder + } if strings.TrimSpace(source.Mode) != "" { conversation.Mode = strings.TrimSpace(source.Mode) } diff --git a/internal/backend/forwarder/service.go b/internal/backend/forwarder/service.go index 09c2a1a..5bb04ca 100644 --- a/internal/backend/forwarder/service.go +++ b/internal/backend/forwarder/service.go @@ -302,6 +302,7 @@ func NewService(historyRoot string, resolver modeladapter.ChannelResolver) *Serv appendSeq: newAppendSequenceTracker(), } service.startHistoryMaintenance() + store.SyncAllCursorTranscriptsBestEffort() return service } @@ -691,6 +692,11 @@ func (service *Service) handleRunIntent(intent InboundIntent) error { if err != nil { return err } + if intent.RequestContext != nil { + if folder := normalizeAgentTranscriptsFolder(intent.RequestContext.GetEnv().GetAgentTranscriptsFolder()); folder != "" { + conversation.AgentTranscriptsFolder = folder + } + } rewindDecision := service.decideRunRewind(intent, conversation) if rewindDecision.Evaluated && !rewindDecision.Apply { service.logRunRewindDecision(intent.RequestID, intent.ConversationID, "rewind_skipped", rewindDecision) diff --git a/internal/backend/forwarder/transcript_adapter.go b/internal/backend/forwarder/transcript_adapter.go new file mode 100644 index 0000000..86605a0 --- /dev/null +++ b/internal/backend/forwarder/transcript_adapter.go @@ -0,0 +1,449 @@ +package forwarder + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "google.golang.org/protobuf/encoding/protojson" + + "cursor/gen/agentv1" + modeladapter "cursor/internal/backend/agent/model" + promptengine "cursor/internal/backend/agent/prompt" +) + +var ( + transcriptContextTagPatterns = compileTranscriptContextTagPatterns([]string{ + "user_info", + "project_layout", + "rules", + "always_applied_workspace_rules", + "agent_requestable_workspace_rules", + "user_rules", + "agent_skills", + "available_skills", + "cloud_instructions", + "cloud_task_instructions", + "open_and_recently_viewed_files", + "system_reminder", + "system-reminder", + "mcp_instructions", + "mcp_file_system", + "mcp_file_system_servers", + "git_status", + "agent_transcripts", + "cursor_rules_context", + "attached_files", + "system_notification", + "task_notification", + "agent_notification", + }) + transcriptThinkingPattern = regexp.MustCompile(`(?is)<(?:think|thinking)>.*?`) + transcriptBlankLinesPattern = regexp.MustCompile(`\n{3,}`) +) + +type cursorTranscriptLine struct { + Role string `json:"role,omitempty"` + Message *cursorTranscriptMessage `json:"message,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` +} + +type cursorTranscriptMessage struct { + Content []cursorTranscriptContent `json:"content"` +} + +type cursorTranscriptContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` +} + +// projectCursorTranscriptJSONL projects the local semantic history into Cursor's +// current agent transcript JSONL contract. context.json remains the source of truth. +func projectCursorTranscriptJSONL(conversation *ConversationFile) ([]byte, error) { + return projectCursorTranscriptJSONLWithLatestStatus(conversation, false) +} + +func projectCursorTranscriptJSONLWithLatestStatus(conversation *ConversationFile, includeLatestStatus bool) ([]byte, error) { + if conversation == nil { + return nil, nil + } + lines := make([]cursorTranscriptLine, 0, len(conversation.Entries)) + maxTurnSeq := int64(0) + for _, entry := range conversation.Entries { + if entry.TurnSeq > maxTurnSeq { + maxTurnSeq = entry.TurnSeq + } + } + currentTurnSeq := int64(0) + pendingTurnStatus := cursorTranscriptLine{} + flushTurnStatus := func() { + if currentTurnSeq > 0 && (includeLatestStatus || currentTurnSeq < maxTurnSeq) && pendingTurnStatus.Type != "" { + lines = append(lines, pendingTurnStatus) + } + pendingTurnStatus = cursorTranscriptLine{} + } + for _, entry := range conversation.Entries { + if entry.TurnSeq > 0 && entry.TurnSeq != currentTurnSeq { + flushTurnStatus() + currentTurnSeq = entry.TurnSeq + } + projected, ok, err := projectCursorTranscriptEntry(entry) + if err != nil { + return nil, err + } + if ok { + lines = append(lines, projected) + } + if status, ok := cursorTranscriptTurnStatus(entry); ok { + pendingTurnStatus = status + } + } + flushTurnStatus() + + if len(lines) == 0 { + return nil, nil + } + var output bytes.Buffer + encoder := json.NewEncoder(&output) + encoder.SetEscapeHTML(false) + for _, line := range lines { + if err := encoder.Encode(line); err != nil { + return nil, fmt.Errorf("encode cursor transcript line: %w", err) + } + } + return output.Bytes(), nil +} + +func projectCursorTranscriptEntry(entry HistoryEntry) (cursorTranscriptLine, bool, error) { + switch strings.TrimSpace(entry.Kind) { + case "user_message": + message := &agentv1.UserMessage{} + if err := protojson.Unmarshal(entry.Payload, message); err != nil { + return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript user_message: %w", err) + } + text := cleanCursorTranscriptUserText(message.GetText()) + if text == "" { + return cursorTranscriptLine{}, false, nil + } + return cursorTranscriptTextLine("user", text), true, nil + case "assistant_text": + var payload assistantTextPayload + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript assistant_text: %w", err) + } + text := cleanCursorTranscriptAssistantText(payload.Text) + thinking := strings.TrimSpace(payload.ReasoningContent) + content := joinTranscriptText(text, thinking) + if content == "" { + return cursorTranscriptLine{}, false, nil + } + return cursorTranscriptTextLine("assistant", content), true, nil + case "tool_call": + var payload toolCallEntryPayload + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript tool_call: %w", err) + } + toolCall := &agentv1.ToolCall{} + if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil { + return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript tool_call payload: %w", err) + } + descriptor, ok := promptengine.BuildToolCallReplayDescriptor(firstNonEmpty(payload.ToolCallID, entry.ToolCallID), toolCall) + if !ok { + return cursorTranscriptLine{}, false, nil + } + return cursorTranscriptToolCallLine(descriptor.Function.Name, descriptor.Function.Arguments, payload.ReasoningContent), true, nil + case "model_message": + var payload modelMessageEntryPayload + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript model_message: %w", err) + } + return projectCursorTranscriptModelMessage(payload.Message) + default: + return cursorTranscriptLine{}, false, nil + } +} + +func projectCursorTranscriptModelMessage(message modeladapter.Message) (cursorTranscriptLine, bool, error) { + role := strings.TrimSpace(message.Role) + if role == "" || role == "system" || role == "tool" { + return cursorTranscriptLine{}, false, nil + } + content := make([]cursorTranscriptContent, 0, len(message.ToolCalls)+1) + texts := make([]string, 0, len(message.ContentParts)+2) + if text := strings.TrimSpace(message.Content); text != "" { + texts = append(texts, text) + } + for _, part := range message.ContentParts { + switch strings.TrimSpace(strings.ToLower(part.Type)) { + case "text", "": + if text := strings.TrimSpace(part.Text); text != "" { + texts = append(texts, text) + } + case "image": + texts = append(texts, "[Image]") + } + } + if thinking := strings.TrimSpace(message.ReasoningContent); thinking != "" { + texts = append(texts, thinking) + } + if len(texts) > 0 { + text := strings.Join(texts, "\n\n") + if role == "user" { + text = cleanCursorTranscriptUserText(text) + } else if role == "assistant" { + text = cleanCursorTranscriptAssistantText(text) + } + if text != "" { + content = append(content, cursorTranscriptContent{Type: "text", Text: text}) + } + } + for _, call := range message.ToolCalls { + name := strings.TrimSpace(call.Function.Name) + if name == "" { + continue + } + content = append(content, cursorTranscriptContent{ + Type: "tool_use", + Name: name, + Input: decodeTranscriptToolInput(call.Function.Arguments), + }) + } + if len(content) == 0 { + return cursorTranscriptLine{}, false, nil + } + return cursorTranscriptLine{Role: role, Message: &cursorTranscriptMessage{Content: content}}, true, nil +} + +func cursorTranscriptTextLine(role string, text string) cursorTranscriptLine { + if strings.TrimSpace(text) == "" { + return cursorTranscriptLine{} + } + return cursorTranscriptLine{ + Role: strings.TrimSpace(role), + Message: &cursorTranscriptMessage{Content: []cursorTranscriptContent{{ + Type: "text", + Text: text, + }}}, + } +} + +func cursorTranscriptToolCallLine(name string, arguments string, reasoning string) cursorTranscriptLine { + content := make([]cursorTranscriptContent, 0, 2) + if thinking := strings.TrimSpace(reasoning); thinking != "" { + content = append(content, cursorTranscriptContent{Type: "text", Text: thinking}) + } + content = append(content, cursorTranscriptContent{ + Type: "tool_use", + Name: strings.TrimSpace(name), + Input: decodeTranscriptToolInput(arguments), + }) + return cursorTranscriptLine{Role: "assistant", Message: &cursorTranscriptMessage{Content: content}} +} + +func decodeTranscriptToolInput(arguments string) any { + trimmed := strings.TrimSpace(arguments) + if trimmed == "" { + return map[string]any{} + } + var decoded any + if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil { + return decoded + } + return trimmed +} + +func cursorTranscriptTurnStatus(entry HistoryEntry) (cursorTranscriptLine, bool) { + if strings.TrimSpace(entry.Kind) != "metadata" || entry.TurnSeq <= 0 { + return cursorTranscriptLine{}, false + } + var payload metadataPayload + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return cursorTranscriptLine{}, false + } + switch strings.TrimSpace(payload.Type) { + case "turn_completed": + return cursorTranscriptLine{Type: "turn_ended", Status: "success"}, true + case "provider_error", "failed": + return cursorTranscriptLine{ + Type: "turn_ended", + Status: "error", + Error: firstNonEmpty(readStringValue(payload.Value["error"]), readStringValue(payload.Value["message"]), "Request failed"), + }, true + case "control": + if strings.TrimSpace(readStringValue(payload.Value["status"])) != "canceled" { + return cursorTranscriptLine{}, false + } + return cursorTranscriptLine{ + Type: "turn_ended", + Status: "aborted", + Error: firstNonEmpty(readStringValue(payload.Value["reason"]), readStringValue(payload.Value["message"]), "User aborted request"), + }, true + default: + return cursorTranscriptLine{}, false + } +} + +func cleanCursorTranscriptUserText(text string) string { + return cleanTranscriptContextTags(text) +} + +func cleanCursorTranscriptAssistantText(text string) string { + cleaned := transcriptThinkingPattern.ReplaceAllString(text, "") + return collapseTranscriptBlankLines(cleaned) +} + +func cleanTranscriptContextTags(text string) string { + cleaned := text + for _, pattern := range transcriptContextTagPatterns { + cleaned = pattern.ReplaceAllString(cleaned, "") + } + return collapseTranscriptBlankLines(cleaned) +} + +func compileTranscriptContextTagPatterns(tags []string) []*regexp.Regexp { + patterns := make([]*regexp.Regexp, 0, len(tags)) + for _, tag := range tags { + patterns = append(patterns, regexp.MustCompile(`(?is)<`+regexp.QuoteMeta(tag)+`(?:\s[^>]*)?>.*?`)) + } + return patterns +} + +func collapseTranscriptBlankLines(text string) string { + return strings.TrimSpace(transcriptBlankLinesPattern.ReplaceAllString(text, "\n\n")) +} + +func joinTranscriptText(text string, thinking string) string { + parts := make([]string, 0, 2) + if strings.TrimSpace(text) != "" { + parts = append(parts, strings.TrimSpace(text)) + } + if strings.TrimSpace(thinking) != "" { + parts = append(parts, strings.TrimSpace(thinking)) + } + return strings.Join(parts, "\n\n") +} + +func normalizeAgentTranscriptsFolder(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" || !filepath.IsAbs(trimmed) { + return "" + } + cleaned := filepath.Clean(trimmed) + if filepath.Base(cleaned) != "agent-transcripts" { + return "" + } + return cleaned +} + +func agentTranscriptsFolderFromEntries(entries []HistoryEntry) string { + for _, entry := range entries { + if strings.TrimSpace(entry.Kind) != "request_context" { + continue + } + requestContext := &agentv1.RequestContext{} + if err := protojson.Unmarshal(entry.Payload, requestContext); err != nil { + continue + } + if folder := normalizeAgentTranscriptsFolder(requestContext.GetEnv().GetAgentTranscriptsFolder()); folder != "" { + return folder + } + } + return "" +} + +func cursorTranscriptPath(transcriptsFolder string, conversationID string) (string, error) { + folder := normalizeAgentTranscriptsFolder(transcriptsFolder) + if folder == "" { + return "", fmt.Errorf("invalid agent transcripts folder") + } + id, err := validateConversationID(conversationID) + if err != nil { + return "", err + } + return filepath.Join(folder, id, id+".jsonl"), nil +} + +func preserveCursorAppendedTurnEnded(path string, projected []byte) []byte { + existing, err := os.ReadFile(path) + if err != nil { + return projected + } + lastLine := lastNonEmptyJSONLLine(existing) + if len(lastLine) == 0 { + return projected + } + var terminal cursorTranscriptLine + if json.Unmarshal(lastLine, &terminal) != nil || terminal.Type != "turn_ended" { + return projected + } + if countTranscriptTurnEnded(existing) <= countTranscriptTurnEnded(projected) { + return projected + } + result := append([]byte(nil), projected...) + if len(result) > 0 && result[len(result)-1] != '\n' { + result = append(result, '\n') + } + result = append(result, lastLine...) + return append(result, '\n') +} + +func lastNonEmptyJSONLLine(data []byte) []byte { + lines := bytes.Split(data, []byte{'\n'}) + for index := len(lines) - 1; index >= 0; index-- { + if line := bytes.TrimSpace(lines[index]); len(line) > 0 { + return append([]byte(nil), line...) + } + } + return nil +} + +func countTranscriptTurnEnded(data []byte) int { + count := 0 + for _, line := range bytes.Split(data, []byte{'\n'}) { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + continue + } + var item cursorTranscriptLine + if json.Unmarshal(trimmed, &item) == nil && item.Type == "turn_ended" { + count++ + } + } + return count +} + +func writeCursorTranscriptAtomic(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create transcript directory: %w", err) + } + file, tempPath, err := openUniqueArtifactTempFile(path) + if err != nil { + return fmt.Errorf("open transcript temp file: %w", err) + } + renamed := false + defer func() { + if !renamed { + _ = os.Remove(tempPath) + } + }() + if _, err := file.Write(data); err != nil { + _ = file.Close() + return fmt.Errorf("write transcript temp file: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close transcript temp file: %w", err) + } + if err := renameArtifactTempFile(tempPath, path); err != nil { + return fmt.Errorf("rename transcript temp file: %w", err) + } + renamed = true + return syncDirectory(filepath.Dir(path)) +} diff --git a/internal/backend/forwarder/transcript_adapter_test.go b/internal/backend/forwarder/transcript_adapter_test.go new file mode 100644 index 0000000..3074a24 --- /dev/null +++ b/internal/backend/forwarder/transcript_adapter_test.go @@ -0,0 +1,213 @@ +package forwarder + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/protobuf/encoding/protojson" + + "cursor/gen/agentv1" +) + +func TestProjectCursorTranscriptJSONLMatchesCursorContract(t *testing.T) { + toolCall := testEditToolCall(t, "file.txt") + conversation := testConversation([]HistoryEntry{ + testUserMessageEntry(t, 1, "request-1", "hidden\n\nchange the file"), + newAssistantTextEntry(1, "request-1", "hidden\nDone", "checked carefully", ""), + newToolCallEntry(1, "request-1", "call-1", "Edit", "", "", toolCall), + newToolResultEntry(1, "request-1", "call-1", "Edit", `{"path":"file.txt"}`, "edited", "", toolCall), + newMetadataEntry(1, "request-1", "turn_completed", nil), + testUserMessageEntry(t, 2, "request-2", "next question"), + newMetadataEntry(2, "request-2", "turn_completed", nil), + }) + + data, err := projectCursorTranscriptJSONL(conversation) + if err != nil { + t.Fatalf("projectCursorTranscriptJSONL() error = %v", err) + } + lines := decodeCursorTranscriptLines(t, data) + if len(lines) != 5 { + t.Fatalf("transcript lines = %d, want 5\n%s", len(lines), data) + } + + if lines[0].Role != "user" || transcriptLineText(lines[0]) != "change the file" { + t.Fatalf("user line = %#v", lines[0]) + } + if lines[1].Role != "assistant" || transcriptLineText(lines[1]) != "Done\n\nchecked carefully" { + t.Fatalf("assistant line = %#v", lines[1]) + } + if lines[2].Role != "assistant" || lines[2].Message == nil || len(lines[2].Message.Content) != 1 { + t.Fatalf("tool line = %#v", lines[2]) + } + toolUse := lines[2].Message.Content[0] + if toolUse.Type != "tool_use" || toolUse.Name != "Edit" { + t.Fatalf("tool use = %#v", toolUse) + } + input, ok := toolUse.Input.(map[string]any) + if !ok || input["path"] != "file.txt" { + t.Fatalf("tool input = %#v", toolUse.Input) + } + if lines[3].Type != "turn_ended" || lines[3].Status != "success" { + t.Fatalf("turn status = %#v", lines[3]) + } + if lines[4].Role != "user" || transcriptLineText(lines[4]) != "next question" { + t.Fatalf("current user line = %#v", lines[4]) + } +} + +func TestConversationFileStoreSyncsCursorTranscript(t *testing.T) { + historyRoot := filepath.Join(t.TempDir(), "history") + transcriptsFolder := filepath.Join(t.TempDir(), "agent-transcripts") + store := NewConversationFileStore(historyRoot) + conversation := testConversation(nil) + conversation.AgentTranscriptsFolder = transcriptsFolder + + persisted, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{ + testUserMessageEntry(t, 1, "request-1", "hello"), + newAssistantTextEntry(1, "request-1", "hi", "", ""), + }) + if err != nil { + t.Fatalf("SaveConversationWithEntries() error = %v", err) + } + if persisted.AgentTranscriptsFolder != transcriptsFolder { + t.Fatalf("persisted transcript folder = %q", persisted.AgentTranscriptsFolder) + } + + path := filepath.Join(transcriptsFolder, conversation.ConversationID, conversation.ConversationID+".jsonl") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read synced transcript: %v", err) + } + lines := decodeCursorTranscriptLines(t, data) + if len(lines) != 2 || lines[0].Role != "user" || lines[1].Role != "assistant" { + t.Fatalf("synced transcript = %s", data) + } + + reloaded, err := store.LoadConversation(conversation.ConversationID) + if err != nil { + t.Fatalf("LoadConversation() error = %v", err) + } + if reloaded.AgentTranscriptsFolder != transcriptsFolder { + t.Fatalf("reloaded transcript folder = %q", reloaded.AgentTranscriptsFolder) + } +} + +func TestConversationFileStoreBackfillsTranscriptOnStartup(t *testing.T) { + historyRoot := filepath.Join(t.TempDir(), "history") + transcriptsFolder := filepath.Join(t.TempDir(), "agent-transcripts") + if err := os.MkdirAll(transcriptsFolder, 0o755); err != nil { + t.Fatalf("create transcript root: %v", err) + } + store := NewConversationFileStore(historyRoot) + conversation := testConversation(nil) + conversation.AgentTranscriptsFolder = transcriptsFolder + _, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{ + testUserMessageEntry(t, 1, "request-1", "hello"), + newAssistantTextEntry(1, "request-1", "hi", "", ""), + newMetadataEntry(1, "request-1", "turn_completed", nil), + }) + if err != nil { + t.Fatalf("SaveConversationWithEntries() error = %v", err) + } + path := filepath.Join(transcriptsFolder, conversation.ConversationID, conversation.ConversationID+".jsonl") + if err := os.RemoveAll(filepath.Dir(path)); err != nil { + t.Fatalf("remove generated transcript: %v", err) + } + if err := os.MkdirAll(transcriptsFolder, 0o755); err != nil { + t.Fatalf("restore transcript root: %v", err) + } + + store.SyncAllCursorTranscriptsBestEffort() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read backfilled transcript: %v", err) + } + lines := decodeCursorTranscriptLines(t, data) + if len(lines) != 3 || lines[2].Type != "turn_ended" || lines[2].Status != "success" { + t.Fatalf("backfilled transcript = %s", data) + } +} + +func TestNormalizeAgentTranscriptsFolderRejectsUnexpectedPaths(t *testing.T) { + root := t.TempDir() + if got := normalizeAgentTranscriptsFolder(filepath.Join(root, "agent-transcripts")); got == "" { + t.Fatal("valid transcript folder was rejected") + } + if got := normalizeAgentTranscriptsFolder(filepath.Join(root, "other")); got != "" { + t.Fatalf("unexpected folder accepted: %q", got) + } + if got := normalizeAgentTranscriptsFolder("agent-transcripts"); got != "" { + t.Fatalf("relative folder accepted: %q", got) + } +} + +func TestPreserveCursorAppendedTurnEnded(t *testing.T) { + path := filepath.Join(t.TempDir(), "conversation.jsonl") + existing := []byte("{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n{\"type\":\"turn_ended\",\"status\":\"success\"}\n") + if err := os.WriteFile(path, existing, 0o644); err != nil { + t.Fatalf("write existing transcript: %v", err) + } + projected := []byte("{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}}\n") + preserved := preserveCursorAppendedTurnEnded(path, projected) + if countTranscriptTurnEnded(preserved) != 1 { + t.Fatalf("preserved transcript = %s", preserved) + } + if !strings.HasSuffix(string(preserved), "{\"type\":\"turn_ended\",\"status\":\"success\"}\n") { + t.Fatalf("terminal line not preserved: %s", preserved) + } +} + +func TestAgentTranscriptsFolderRecoveredFromLegacyRequestContext(t *testing.T) { + folder := filepath.Join(t.TempDir(), "agent-transcripts") + payload, err := protojson.Marshal(&agentv1.RequestContext{ + Env: &agentv1.RequestContextEnv{AgentTranscriptsFolder: folder}, + }) + if err != nil { + t.Fatalf("marshal request context: %v", err) + } + conversation := testConversation([]HistoryEntry{{ + TurnSeq: 1, + Role: "user", + Kind: "request_context", + Payload: payload, + }}) + conversation.AgentTranscriptsFolder = "" + normalizeLoadedConversation(conversation.ConversationID, conversation) + if conversation.AgentTranscriptsFolder != folder { + t.Fatalf("recovered transcript folder = %q", conversation.AgentTranscriptsFolder) + } +} + +func decodeCursorTranscriptLines(t *testing.T, data []byte) []cursorTranscriptLine { + t.Helper() + lines := make([]cursorTranscriptLine, 0) + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + var line cursorTranscriptLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + t.Fatalf("decode transcript line %q: %v", scanner.Text(), err) + } + lines = append(lines, line) + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan transcript: %v", err) + } + return lines +} + +func transcriptLineText(line cursorTranscriptLine) string { + if line.Message == nil { + return "" + } + texts := make([]string, 0, len(line.Message.Content)) + for _, content := range line.Message.Content { + if content.Type == "text" { + texts = append(texts, content.Text) + } + } + return strings.Join(texts, "\n\n") +} diff --git a/internal/backend/forwarder/types.go b/internal/backend/forwarder/types.go index c87ef98..db78eee 100644 --- a/internal/backend/forwarder/types.go +++ b/internal/backend/forwarder/types.go @@ -22,6 +22,7 @@ type ConversationFile struct { ParentConversationID string `json:"parent_conversation_id"` ParentToolCallID string `json:"parent_tool_call_id"` SubagentTypeName string `json:"subagent_type_name,omitempty"` + AgentTranscriptsFolder string `json:"agent_transcripts_folder,omitempty"` Mode string `json:"mode"` ContextVersion int64 `json:"context_version,omitempty"` CurrentLoopID string `json:"current_loop_id,omitempty"` From 834288839b006c888109fae291fa68910fec22fd Mon Sep 17 00:00:00 2001 From: DedSecer Date: Sat, 1 Aug 2026 21:10:46 +0800 Subject: [PATCH 6/9] test(cursor): make transcript adapter tests self-contained Keep the transcript coverage independent from branch-specific projector helpers so it runs cleanly on the upstream main test layout. --- .../forwarder/transcript_adapter_test.go | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/internal/backend/forwarder/transcript_adapter_test.go b/internal/backend/forwarder/transcript_adapter_test.go index 3074a24..8f932f0 100644 --- a/internal/backend/forwarder/transcript_adapter_test.go +++ b/internal/backend/forwarder/transcript_adapter_test.go @@ -3,6 +3,7 @@ package forwarder import ( "bufio" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -14,14 +15,14 @@ import ( ) func TestProjectCursorTranscriptJSONLMatchesCursorContract(t *testing.T) { - toolCall := testEditToolCall(t, "file.txt") - conversation := testConversation([]HistoryEntry{ - testUserMessageEntry(t, 1, "request-1", "hidden\n\nchange the file"), + toolCall := transcriptTestEditToolCall(t, "file.txt") + conversation := transcriptTestConversation([]HistoryEntry{ + transcriptTestUserMessageEntry(t, 1, "request-1", "hidden\n\nchange the file"), newAssistantTextEntry(1, "request-1", "hidden\nDone", "checked carefully", ""), newToolCallEntry(1, "request-1", "call-1", "Edit", "", "", toolCall), newToolResultEntry(1, "request-1", "call-1", "Edit", `{"path":"file.txt"}`, "edited", "", toolCall), newMetadataEntry(1, "request-1", "turn_completed", nil), - testUserMessageEntry(t, 2, "request-2", "next question"), + transcriptTestUserMessageEntry(t, 2, "request-2", "next question"), newMetadataEntry(2, "request-2", "turn_completed", nil), }) @@ -63,11 +64,11 @@ func TestConversationFileStoreSyncsCursorTranscript(t *testing.T) { historyRoot := filepath.Join(t.TempDir(), "history") transcriptsFolder := filepath.Join(t.TempDir(), "agent-transcripts") store := NewConversationFileStore(historyRoot) - conversation := testConversation(nil) + conversation := transcriptTestConversation(nil) conversation.AgentTranscriptsFolder = transcriptsFolder persisted, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{ - testUserMessageEntry(t, 1, "request-1", "hello"), + transcriptTestUserMessageEntry(t, 1, "request-1", "hello"), newAssistantTextEntry(1, "request-1", "hi", "", ""), }) if err != nil { @@ -103,10 +104,10 @@ func TestConversationFileStoreBackfillsTranscriptOnStartup(t *testing.T) { t.Fatalf("create transcript root: %v", err) } store := NewConversationFileStore(historyRoot) - conversation := testConversation(nil) + conversation := transcriptTestConversation(nil) conversation.AgentTranscriptsFolder = transcriptsFolder _, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{ - testUserMessageEntry(t, 1, "request-1", "hello"), + transcriptTestUserMessageEntry(t, 1, "request-1", "hello"), newAssistantTextEntry(1, "request-1", "hi", "", ""), newMetadataEntry(1, "request-1", "turn_completed", nil), }) @@ -169,7 +170,7 @@ func TestAgentTranscriptsFolderRecoveredFromLegacyRequestContext(t *testing.T) { if err != nil { t.Fatalf("marshal request context: %v", err) } - conversation := testConversation([]HistoryEntry{{ + conversation := transcriptTestConversation([]HistoryEntry{{ TurnSeq: 1, Role: "user", Kind: "request_context", @@ -182,6 +183,49 @@ func TestAgentTranscriptsFolderRecoveredFromLegacyRequestContext(t *testing.T) { } } +func transcriptTestConversation(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 transcriptTestUserMessageEntry(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 transcriptTestEditToolCall(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 +} + func decodeCursorTranscriptLines(t *testing.T, data []byte) []cursorTranscriptLine { t.Helper() lines := make([]cursorTranscriptLine, 0) From 9d316c0b3d17905ae77598f19006d42e74b86514 Mon Sep 17 00:00:00 2001 From: aike1202 <2433442840@qq.com> Date: Sat, 1 Aug 2026 22:05:08 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat(cursor):=20=E6=94=AF=E6=8C=81=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=8E=A7=E5=88=B6=E9=9D=A2=E8=B4=A6=E5=8F=B7=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/CursorAccountCard.vue | 142 +++++ frontend/src/i18n/generated/catalog.json | 251 ++++++-- frontend/src/i18n/locales/en-US.json | 12 + frontend/src/i18n/locales/ja-JP.json | 12 + frontend/src/i18n/locales/ru-RU.json | 12 + frontend/src/i18n/locales/zh-CN.json | 12 + frontend/src/services/clientApi.js | 15 + frontend/src/views/Home.vue | 5 +- internal/backend/host.go | 113 +++- internal/backend/server/upstream/action.go | 29 + internal/backend/server/upstream/types.go | 7 + internal/bridge/proxy.go | 18 + internal/client/cursor_account.go | 35 ++ internal/client/lifecycle.go | 3 + internal/client/service.go | 12 +- internal/cursoraccount/manager.go | 589 ++++++++++++++++++ 16 files changed, 1204 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/CursorAccountCard.vue create mode 100644 internal/client/cursor_account.go create mode 100644 internal/cursoraccount/manager.go diff --git a/frontend/src/components/CursorAccountCard.vue b/frontend/src/components/CursorAccountCard.vue new file mode 100644 index 0000000..71a172c --- /dev/null +++ b/frontend/src/components/CursorAccountCard.vue @@ -0,0 +1,142 @@ + + + diff --git a/frontend/src/i18n/generated/catalog.json b/frontend/src/i18n/generated/catalog.json index f3dd2e5..4e919e4 100644 --- a/frontend/src/i18n/generated/catalog.json +++ b/frontend/src/i18n/generated/catalog.json @@ -19,8 +19,8 @@ "refs": [ { "file": "src/views/ModelConfig.vue", - "line": 256, - "column": 1 + "line": 255, + "column": 165 } ] }, @@ -43,8 +43,8 @@ "refs": [ { "file": "src/views/Config.vue", - "line": 72, - "column": 1 + "line": 71, + "column": 48 } ] }, @@ -108,8 +108,8 @@ "refs": [ { "file": "src/views/Config.vue", - "line": 58, - "column": 1 + "line": 57, + "column": 48 } ] }, @@ -197,8 +197,8 @@ "refs": [ { "file": "src/components/HomeMetricsCard.vue", - "line": 338, - "column": 1 + "line": 337, + "column": 65 } ] }, @@ -298,6 +298,18 @@ } ] }, + "1c631615c1d85c9e": { + "source": "登录 Cursor", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 138, + "column": 12 + } + ] + }, "1e238093b79b3165": { "source": "留空时默认 65536", "kind": "text", @@ -349,12 +361,12 @@ }, { "file": "src/views/Home.vue", - "line": 118, + "line": 119, "column": 27 }, { "file": "src/views/Home.vue", - "line": 126, + "line": 127, "column": 27 }, { @@ -407,8 +419,8 @@ "refs": [ { "file": "src/views/Config.vue", - "line": 90, - "column": 1 + "line": 89, + "column": 48 } ] }, @@ -429,6 +441,11 @@ "kind": "text", "placeholders": 0, "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 62, + "column": 17 + }, { "file": "src/components/ModelAdapterModal.vue", "line": 297, @@ -531,8 +548,8 @@ "refs": [ { "file": "src/components/ModelAdapterTestCard.vue", - "line": 151, - "column": 1 + "line": 150, + "column": 7 } ] }, @@ -579,8 +596,8 @@ "refs": [ { "file": "src/components/ModelAdapterTestCard.vue", - "line": 130, - "column": 1 + "line": 129, + "column": 60 } ] }, @@ -659,6 +676,28 @@ } ] }, + "3ab8cc15939f3b5c": { + "source": "退出登录", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 59, + "column": 12 + }, + { + "file": "src/components/CursorAccountCard.vue", + "line": 61, + "column": 18 + }, + { + "file": "src/components/CursorAccountCard.vue", + "line": 130, + "column": 1 + } + ] + }, "3af7e5489e61ea51": { "source": "刷新中", "kind": "text", @@ -717,6 +756,18 @@ } ] }, + "3d52574ce1500561": { + "source": "未连接", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 31, + "column": 10 + } + ] + }, "3ea83f9f55062582": { "source": "发布时间:{0}", "kind": "template", @@ -892,8 +943,8 @@ "refs": [ { "file": "src/components/ModelAdapterTestCard.vue", - "line": 125, - "column": 1 + "line": 124, + "column": 9 } ] }, @@ -1010,7 +1061,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 106, + "line": 107, "column": 27 } ] @@ -1150,6 +1201,18 @@ } ] }, + "688102a402ba015a": { + "source": "等待登录...", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 138, + "column": 12 + } + ] + }, "699fe7ade5407687": { "source": "直连模式", "kind": "text", @@ -1157,7 +1220,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 185, + "line": 186, "column": 11 } ] @@ -1196,6 +1259,16 @@ "kind": "text", "placeholders": 0, "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 37, + "column": 30 + }, + { + "file": "src/components/CursorAccountCard.vue", + "line": 37, + "column": 48 + }, { "file": "src/state/appState.js", "line": 23, @@ -1213,12 +1286,12 @@ }, { "file": "src/views/Home.vue", - "line": 89, + "line": 90, "column": 30 }, { "file": "src/views/Home.vue", - "line": 89, + "line": 90, "column": 48 }, { @@ -1426,7 +1499,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 96, + "line": 97, "column": 27 } ] @@ -1455,8 +1528,8 @@ "refs": [ { "file": "src/components/HomeMetricsCard.vue", - "line": 390, - "column": 1 + "line": 389, + "column": 65 } ] }, @@ -1484,6 +1557,18 @@ } ] }, + "83be9cac28873059": { + "source": "Cursor 控制面账号", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 99, + "column": 56 + } + ] + }, "8672864e90417138": { "source": "最高", "kind": "text", @@ -1556,7 +1641,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 188, + "line": 189, "column": 11 } ] @@ -1595,7 +1680,7 @@ }, { "file": "src/views/Home.vue", - "line": 205, + "line": 208, "column": 68 } ] @@ -1708,8 +1793,8 @@ "refs": [ { "file": "src/components/HomeMetricsCard.vue", - "line": 342, - "column": 1 + "line": 341, + "column": 23 } ] }, @@ -1845,7 +1930,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 136, + "line": 137, "column": 29 } ] @@ -1893,8 +1978,8 @@ "refs": [ { "file": "src/views/ModelEditor.vue", - "line": 297, - "column": 1 + "line": 296, + "column": 97 } ] }, @@ -2016,7 +2101,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 187, + "line": 188, "column": 11 } ] @@ -2147,7 +2232,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 201, + "line": 204, "column": 47 } ] @@ -2222,7 +2307,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 136, + "line": 137, "column": 50 } ] @@ -2400,6 +2485,18 @@ } ] }, + "c3d46b387eeadb23": { + "source": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 60, + "column": 14 + } + ] + }, "c3e9c3c60020b8b7": { "source": "选择模式", "kind": "text", @@ -2431,11 +2528,23 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 204, + "line": 207, "column": 63 } ] }, + "c8a52b66651d294c": { + "source": "退出登录失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 71, + "column": 27 + } + ] + }, "c8c14507b2d37395": { "source": "推理强度", "kind": "text", @@ -2525,11 +2634,23 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 186, + "line": 187, "column": 11 } ] }, + "cfa6c803eb3fc713": { + "source": "等待浏览器登录", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 30, + "column": 42 + } + ] + }, "d0325067fed88e5a": { "source": "缓存命中率 {0}", "kind": "template", @@ -2549,7 +2670,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 133, + "line": 134, "column": 27 } ] @@ -2653,6 +2774,18 @@ } ] }, + "d6ce4f0f88178144": { + "source": "独立用于插件、Skills 和 MCP;不会改变 Cursor 客户端当前账号", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 113, + "column": 1 + } + ] + }, "d7889896c5b7732a": { "source": "Anthropic 额外参数 JSON", "kind": "text", @@ -2771,8 +2904,8 @@ "refs": [ { "file": "src/views/Config.vue", - "line": 102, - "column": 1 + "line": 101, + "column": 48 } ] }, @@ -2812,11 +2945,35 @@ }, { "file": "src/views/Home.vue", - "line": 200, + "line": 203, "column": 56 } ] }, + "e4343921c928a856": { + "source": "登录失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 50, + "column": 27 + } + ] + }, + "e53580f8031f13c0": { + "source": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 116, + "column": 1 + } + ] + }, "e552c2accdbf5178": { "source": "新增模型", "kind": "text", @@ -2841,6 +2998,18 @@ } ] }, + "e8a0a6053998ebfa": { + "source": "已经登录", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/CursorAccountCard.vue", + "line": 29, + "column": 43 + } + ] + }, "eaffd48cd2ea9f1a": { "source": "例如:https://api.anthropic.com", "kind": "text", diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 2701794..dae0f4a 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -23,6 +23,7 @@ "1af38868896cf53d": "Routing mode only supports local or upstream", "1baddde657dd2720": "Current outbound requests use system proxy", "1bc77f5ab979f4c1": "Add Model Settings", + "1c631615c1d85c9e": "Log in to Cursor", "1e238093b79b3165": "Uses 65536 by default when left blank", "21296ab18ad9af25": "Extra Params JSON", "24343a2096988d42": "Failed to open", @@ -46,10 +47,12 @@ "37d23612f78a2e63": "Restart Now to Update", "392d0dceb45998d3": "Extreme", "393df9bb13ea4900": "Hit", + "3ab8cc15939f3b5c": "Log out", "3af7e5489e61ea51": "Refreshing", "3bf8512aa520ed21": "Local Service Mode", "3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic", "3d13868593ae4eeb": "Interface Language", + "3d52574ce1500561": "Not connected", "3ea83f9f55062582": "Release date: {0}", "3edda85621fd03b2": "model adapters", "3fd47edce45b3603": "Close", @@ -84,6 +87,7 @@ "66af574b8948fe83": "{0} API key cannot be empty", "6744b4c6a9aa0038": "Disabled", "675109292da4eb36": "Not tested yet", + "688102a402ba015a": "Waiting for login...", "699fe7ade5407687": "Direct Mode", "6a7b96f399e58138": "e.g. sk-xxxxxx", "6aa8f49cc992dfd7": "Test", @@ -106,6 +110,7 @@ "80296f4aa3f4543b": "Cache Read/Write", "81123c56d5d880d0": "API Key", "8139cb3dd11f5a67": "When enabled, the JSON object will override the final request headers. Duplicate headers are determined by this field, and values must be strings.", + "83be9cac28873059": "Cursor Control Plane Account", "8672864e90417138": "Max", "86df7ec743047234": "Service running", "87ed126f7bd1121e": "Routing Mode", @@ -174,9 +179,11 @@ "bddd504af0c92fd0": "System PAC/automatic proxy detected; current version is handled as a direct connection", "bef280f9eb392495": "Conversation Turns", "c228558cf257fc49": "Delete failed", + "c3d46b387eeadb23": "This only logs the Cursor account out of cursor-byok; it does not log out of the Cursor client. Continue?", "c3e9c3c60020b8b7": "Select Mode", "c5af02060847d167": "Thinking effort for Anthropic adaptive thinking. Requests will consistently use the new thinking.type=adaptive.", "c69f5bce63b9f14c": "Settings Folder", + "c8a52b66651d294c": "Failed to log out", "c8c14507b2d37395": "Reasoning Effort", "c98e118e0a43f078": "Model", "c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)", @@ -184,6 +191,7 @@ "ca1d1059408b3837": "Invalid turns: {0}", "cd7ca5fb221e1c53": "{0} cannot be empty", "ce46f23cea3bf3c5": "When enabled, Cursor connects directly to the official service. Do not enable this.", + "cfa6c803eb3fc713": "Waiting for browser login", "d0325067fed88e5a": "Cache hit rate {0}", "d08fd4224abcd69d": "Switch failed", "d1bde4a4e057b2c7": "[MainLayout] Failed to load author info", @@ -193,6 +201,7 @@ "d373809ab86ba93b": "Copy", "d3b1da3088ddd334": "Model test failed", "d53d32f1a1211371": "Custom Headers JSON", + "d6ce4f0f88178144": "Used only for Plugins, Skills, and MCP; does not change the account in the Cursor client", "d7889896c5b7732a": "Anthropic Extra Params JSON", "d7da2aabd35772ec": "e.g. 200000 (leave blank to use the default)", "d95e5cb6bdcee553": "Include Cache Creation", @@ -205,8 +214,11 @@ "e01c5dae36cf8c35": "When enabled, the JSON object will override the OpenAI request body. Duplicate fields are determined by this field. OpenAI service_tier supports auto, default, flex, scale, priority.", "e14c41ef2b7253c9": "Total request tokens: {0}", "e406825e0a72d2c2": "Local Settings", + "e4343921c928a856": "Login failed", + "e53580f8031f13c0": "Complete login in the browser, then return to Cursor and reopen the plugin marketplace", "e552c2accdbf5178": "Add Model", "e6faccfddce722e8": "Cache read tokens: {0}", + "e8a0a6053998ebfa": "Logged in", "eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com", "eb1be07f2ca6e506": "Estimated based on Claude Opus 4.7 pricing.", "ec3b17a75db49e24": "{0} t/s | First token {1}", diff --git a/frontend/src/i18n/locales/ja-JP.json b/frontend/src/i18n/locales/ja-JP.json index 95fe244..468ac4d 100644 --- a/frontend/src/i18n/locales/ja-JP.json +++ b/frontend/src/i18n/locales/ja-JP.json @@ -23,6 +23,7 @@ "1af38868896cf53d": "ルーティングモードは local または upstream のみサポートします", "1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています", "1bc77f5ab979f4c1": "モデル設定を追加", + "1c631615c1d85c9e": "Cursor にログイン", "1e238093b79b3165": "空欄で 65536", "21296ab18ad9af25": "追加パラメータ JSON", "24343a2096988d42": "開けませんでした", @@ -46,10 +47,12 @@ "37d23612f78a2e63": "今すぐ再起動して更新", "392d0dceb45998d3": "最高", "393df9bb13ea4900": "ヒット", + "3ab8cc15939f3b5c": "ログアウト", "3af7e5489e61ea51": "更新中", "3bf8512aa520ed21": "ローカルサービスモード", "3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします", "3d13868593ae4eeb": "表示言語", + "3d52574ce1500561": "未接続", "3ea83f9f55062582": "公開日時: {0}", "3edda85621fd03b2": "件のモデルアダプター", "3fd47edce45b3603": "閉じる", @@ -84,6 +87,7 @@ "66af574b8948fe83": "{0} の API キーは必須です", "6744b4c6a9aa0038": "無効化", "675109292da4eb36": "まだテストしていません", + "688102a402ba015a": "ログインを待っています...", "699fe7ade5407687": "直結モード", "6a7b96f399e58138": "例: sk-xxxxxx", "6aa8f49cc992dfd7": "テスト", @@ -106,6 +110,7 @@ "80296f4aa3f4543b": "キャッシュ読み書き", "81123c56d5d880d0": "API キー", "8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。", + "83be9cac28873059": "Cursor コントロールプレーンアカウント", "8672864e90417138": "最大", "86df7ec743047234": "サービス稼働中", "87ed126f7bd1121e": "ルーティングモード", @@ -174,9 +179,11 @@ "bddd504af0c92fd0": "システムのPAC/自動プロキシが検出されました。現在のバージョンは直接接続として処理されます", "bef280f9eb392495": "会話ターン", "c228558cf257fc49": "削除に失敗しました", + "c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?", "c3e9c3c60020b8b7": "モードを選択", "c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。", "c69f5bce63b9f14c": "設定フォルダー", + "c8a52b66651d294c": "ログアウトに失敗しました", "c8c14507b2d37395": "推論強度", "c98e118e0a43f078": "モデル", "c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)", @@ -184,6 +191,7 @@ "ca1d1059408b3837": "異常ターン: {0}", "cd7ca5fb221e1c53": "{0}は空にできません", "ce46f23cea3bf3c5": "有効にすると、Cursor は公式サービスへ直接接続します。オンにしないでください", + "cfa6c803eb3fc713": "ブラウザでのログインを待っています", "d0325067fed88e5a": "キャッシュヒット率 {0}", "d08fd4224abcd69d": "切替に失敗しました", "d1bde4a4e057b2c7": "[MainLayout] 作者情報の読み込みに失敗しました", @@ -193,6 +201,7 @@ "d373809ab86ba93b": "コピー", "d3b1da3088ddd334": "モデルテストに失敗しました", "d53d32f1a1211371": "カスタムヘッダー JSON", + "d6ce4f0f88178144": "プラグイン、Skills、MCP 専用です。Cursor クライアントの現在のアカウントは変更しません", "d7889896c5b7732a": "Anthropic 追加パラメータ JSON", "d7da2aabd35772ec": "例: 200000(空欄でデフォルト値)", "d95e5cb6bdcee553": "キャッシュ作成を含める", @@ -205,8 +214,11 @@ "e01c5dae36cf8c35": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしています。", "e14c41ef2b7253c9": "総リクエスト Token: {0}", "e406825e0a72d2c2": "ローカル設定", + "e4343921c928a856": "ログインに失敗しました", + "e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください", "e552c2accdbf5178": "モデルを追加", "e6faccfddce722e8": "キャッシュ読込 Token: {0}", + "e8a0a6053998ebfa": "ログイン済み", "eaffd48cd2ea9f1a": "例: https://api.anthropic.com", "eb1be07f2ca6e506": "Claude Opus 4.7の価格に基づいて見積もられます。", "ec3b17a75db49e24": "{0} t/s | 初回 Token {1}", diff --git a/frontend/src/i18n/locales/ru-RU.json b/frontend/src/i18n/locales/ru-RU.json index 19e8608..bbbb0a8 100644 --- a/frontend/src/i18n/locales/ru-RU.json +++ b/frontend/src/i18n/locales/ru-RU.json @@ -23,6 +23,7 @@ "1af38868896cf53d": "Режим маршрутизации поддерживает только local или upstream", "1baddde657dd2720": "Исходящие запросы используют системный прокси", "1bc77f5ab979f4c1": "Добавить настройки модели", + "1c631615c1d85c9e": "Войти в Cursor", "1e238093b79b3165": "Если оставить пустым, используется 65536", "21296ab18ad9af25": "Дополнительные параметры JSON", "24343a2096988d42": "Не удалось открыть", @@ -46,10 +47,12 @@ "37d23612f78a2e63": "Перезапустить и обновить", "392d0dceb45998d3": "Очень высокая", "393df9bb13ea4900": "Попадание", + "3ab8cc15939f3b5c": "Выйти", "3af7e5489e61ea51": "Обновление", "3bf8512aa520ed21": "Режим локального сервиса", "3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic", "3d13868593ae4eeb": "Язык интерфейса", + "3d52574ce1500561": "Не подключено", "3ea83f9f55062582": "Дата выпуска: {0}", "3edda85621fd03b2": "адаптеров моделей", "3fd47edce45b3603": "Закрыть", @@ -84,6 +87,7 @@ "66af574b8948fe83": "Ключ API {0} не может быть пустым", "6744b4c6a9aa0038": "Выключено", "675109292da4eb36": "Еще не проверено", + "688102a402ba015a": "Ожидание входа...", "699fe7ade5407687": "Прямой режим", "6a7b96f399e58138": "например, sk-xxxxxx", "6aa8f49cc992dfd7": "Проверить", @@ -106,6 +110,7 @@ "80296f4aa3f4543b": "Чтение/запись кеша", "81123c56d5d880d0": "Ключ API", "8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.", + "83be9cac28873059": "Аккаунт управляющего уровня Cursor", "8672864e90417138": "Максимальная", "86df7ec743047234": "Сервис запущен", "87ed126f7bd1121e": "Режим маршрутизации", @@ -174,9 +179,11 @@ "bddd504af0c92fd0": "Обнаружен системный PAC/автоматический прокси; в текущей версии используется прямое подключение", "bef280f9eb392495": "Ходы диалога", "c228558cf257fc49": "Не удалось удалить", + "c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?", "c3e9c3c60020b8b7": "Выберите режим", "c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.", "c69f5bce63b9f14c": "Папка настроек", + "c8a52b66651d294c": "Не удалось выйти", "c8c14507b2d37395": "Интенсивность рассуждений", "c98e118e0a43f078": "Модель", "c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)", @@ -184,6 +191,7 @@ "ca1d1059408b3837": "Ошибочных ходов: {0}", "cd7ca5fb221e1c53": "{0} не может быть пустым", "ce46f23cea3bf3c5": "Если включено, Cursor подключается напрямую к официальному сервису. Не включайте этот режим.", + "cfa6c803eb3fc713": "Ожидание входа в браузере", "d0325067fed88e5a": "Доля попаданий в кеш: {0}", "d08fd4224abcd69d": "Не удалось переключить", "d1bde4a4e057b2c7": "[MainLayout] Не удалось загрузить сведения об авторе", @@ -193,6 +201,7 @@ "d373809ab86ba93b": "Копировать", "d3b1da3088ddd334": "Проверка модели не пройдена", "d53d32f1a1211371": "Пользовательские заголовки JSON", + "d6ce4f0f88178144": "Используется только для Plugins, Skills и MCP; текущий аккаунт клиента Cursor не изменяется", "d7889896c5b7732a": "Дополнительные параметры Anthropic JSON", "d7da2aabd35772ec": "например, 200000 (оставьте пустым для значения по умолчанию)", "d95e5cb6bdcee553": "Учитывать создание кеша", @@ -205,8 +214,11 @@ "e01c5dae36cf8c35": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority.", "e14c41ef2b7253c9": "Всего токенов запроса: {0}", "e406825e0a72d2c2": "Локальные настройки", + "e4343921c928a856": "Не удалось войти", + "e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов", "e552c2accdbf5178": "Добавить модель", "e6faccfddce722e8": "Токены чтения из кеша: {0}", + "e8a0a6053998ebfa": "Выполнен вход", "eaffd48cd2ea9f1a": "например, https://api.anthropic.com", "eb1be07f2ca6e506": "Расчет основан на тарифах Claude Opus 4.7.", "ec3b17a75db49e24": "{0} т/с | Первый токен {1}", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index eec55b6..105301f 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -23,6 +23,7 @@ "1af38868896cf53d": "运行模式仅支持 local 或 upstream", "1baddde657dd2720": "当前出站请求使用系统代理", "1bc77f5ab979f4c1": "新增模型配置", + "1c631615c1d85c9e": "登录 Cursor", "1e238093b79b3165": "留空时默认 65536", "21296ab18ad9af25": "额外参数 JSON", "24343a2096988d42": "打开失败", @@ -46,10 +47,12 @@ "37d23612f78a2e63": "立即重启更新", "392d0dceb45998d3": "极高", "393df9bb13ea4900": "命中", + "3ab8cc15939f3b5c": "退出登录", "3af7e5489e61ea51": "刷新中", "3bf8512aa520ed21": "本地服务模式", "3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic", "3d13868593ae4eeb": "界面语言", + "3d52574ce1500561": "未连接", "3ea83f9f55062582": "发布时间:{0}", "3edda85621fd03b2": "个模型适配器", "3fd47edce45b3603": "关闭", @@ -84,6 +87,7 @@ "66af574b8948fe83": "{0} 的访问密钥不能为空", "6744b4c6a9aa0038": "已关闭", "675109292da4eb36": "尚未测试", + "688102a402ba015a": "等待登录...", "699fe7ade5407687": "直连模式", "6a7b96f399e58138": "例如:sk-xxxxxx", "6aa8f49cc992dfd7": "测试", @@ -106,6 +110,7 @@ "80296f4aa3f4543b": "缓存读写", "81123c56d5d880d0": "访问密钥", "8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。", + "83be9cac28873059": "Cursor 控制面账号", "8672864e90417138": "最高", "86df7ec743047234": "服务运行中", "87ed126f7bd1121e": "运行模式", @@ -174,9 +179,11 @@ "bddd504af0c92fd0": "检测到系统 PAC/自动代理,当前版本按直连处理", "bef280f9eb392495": "对话轮次", "c228558cf257fc49": "删除失败", + "c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?", "c3e9c3c60020b8b7": "选择模式", "c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。", "c69f5bce63b9f14c": "设置文件夹", + "c8a52b66651d294c": "退出登录失败", "c8c14507b2d37395": "推理强度", "c98e118e0a43f078": "模型", "c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)", @@ -184,6 +191,7 @@ "ca1d1059408b3837": "异常轮次:{0}", "cd7ca5fb221e1c53": "{0}不能为空", "ce46f23cea3bf3c5": "开启后,Cursor将直接接通官方,请勿开启", + "cfa6c803eb3fc713": "等待浏览器登录", "d0325067fed88e5a": "缓存命中率 {0}", "d08fd4224abcd69d": "切换失败", "d1bde4a4e057b2c7": "[MainLayout] 加载作者信息失败", @@ -193,6 +201,7 @@ "d373809ab86ba93b": "拷贝", "d3b1da3088ddd334": "模型测试失败", "d53d32f1a1211371": "自定义请求头 JSON", + "d6ce4f0f88178144": "独立用于插件、Skills 和 MCP;不会改变 Cursor 客户端当前账号", "d7889896c5b7732a": "Anthropic 额外参数 JSON", "d7da2aabd35772ec": "例如:200000(留空用默认值)", "d95e5cb6bdcee553": "计入缓存创建", @@ -205,8 +214,11 @@ "e01c5dae36cf8c35": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。", "e14c41ef2b7253c9": "总请求:{0}", "e406825e0a72d2c2": "本地配置", + "e4343921c928a856": "登录失败", + "e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场", "e552c2accdbf5178": "新增模型", "e6faccfddce722e8": "缓存读取:{0}", + "e8a0a6053998ebfa": "已经登录", "eaffd48cd2ea9f1a": "例如:https://api.anthropic.com", "eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。", "ec3b17a75db49e24": "{0} t/s | 首字 {1}", diff --git a/frontend/src/services/clientApi.js b/frontend/src/services/clientApi.js index 9afce5d..621e003 100644 --- a/frontend/src/services/clientApi.js +++ b/frontend/src/services/clientApi.js @@ -1,7 +1,10 @@ import { + DisconnectCursorAccount, + GetCursorAccountStatus, GetState, LoadUserConfig, SaveUserConfig, + StartCursorAccountLogin, StartProxy, StopProxy, } from "@bindings/cursor/internal/bridge/proxyservice.js"; @@ -62,6 +65,18 @@ export function saveUserConfig(payload) { return withApiLogging("SaveUserConfig", payload, () => SaveUserConfig(payload)); } +export function getCursorAccountStatus() { + return withApiLogging("GetCursorAccountStatus", undefined, () => GetCursorAccountStatus()); +} + +export function startCursorAccountLogin() { + return withApiLogging("StartCursorAccountLogin", undefined, () => StartCursorAccountLogin()); +} + +export function disconnectCursorAccount() { + return withApiLogging("DisconnectCursorAccount", undefined, () => DisconnectCursorAccount()); +} + export function getProxyState() { return withApiLogging("GetState", undefined, () => GetState()); } diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 52d701d..c23acad 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -3,6 +3,7 @@ import Button from "@/components/ui/Button.vue"; import Card from "@/components/ui/Card.vue"; import Switch from "@/components/ui/Switch.vue"; import HomeMetricsCard from "@/components/HomeMetricsCard.vue"; +import CursorAccountCard from "@/components/CursorAccountCard.vue"; import { useMessage } from "@/composables/useMessage"; import { showModal } from "@/composables/useModal"; import { getAdRuntime } from "@/services/clientApi"; @@ -149,7 +150,7 @@ onBeforeUnmount(() => {