From 17f30a88521518554aecca299c05dda26cfb649f Mon Sep 17 00:00:00 2001 From: DedSecer Date: Tue, 28 Jul 2026 10:46:07 +0800 Subject: [PATCH 1/3] 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 4451a6df3b0252d2ff291c341f401bd25edde4ca Mon Sep 17 00:00:00 2001 From: DedSecer Date: Thu, 30 Jul 2026 19:32:42 +0800 Subject: [PATCH 2/3] 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 3/3] 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 {